Skip to content
6 changes: 4 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ plugins {
alias(libs.plugins.navigationSafeArgs)
alias(libs.plugins.googleServices)
alias(libs.plugins.firebaseCrashlytics)
alias(libs.plugins.firebasePerf)
}

val properties = Properties().apply {
Expand All @@ -24,8 +25,8 @@ android {
applicationId = "com.kuit.findu"
minSdk = 28
targetSdk = 35
versionCode = 19
versionName = "1.1.4"
versionCode = 21
versionName = "1.1.6"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "GPT_KEY", properties["GPT_KEY"].toString())
Expand Down Expand Up @@ -171,6 +172,7 @@ dependencies {
implementation(libs.firebase.analytics.ktx)
implementation(libs.firebase.config.ktx)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.perf)

// AdMob
implementation("com.google.android.gms:play-services-ads:23.1.0")
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- CameraX -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:ignore="AdvertisingIdPolicy" />

<queries>
Expand Down Expand Up @@ -47,6 +48,11 @@
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-7675272869453438~5374193050" />

<!-- Performance -->
<meta-data
android:name="firebase_performance_logcat_enabled"
android:value="true" />

<!-- Kakao Login -->
<activity
android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity"
Expand Down
49 changes: 49 additions & 0 deletions app/src/main/java/com/kuit/findu/FindUApp.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
package com.kuit.findu

import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import com.google.android.gms.ads.MobileAds
import com.kakao.sdk.common.KakaoSdk
import com.kuit.findu.data.dataremote.util.SessionExpiredEventManager
import com.kuit.findu.presentation.ui.login.LoginActivity
import com.kuit.findu.presentation.ui.splash.SplashActivity
import com.naver.maps.map.NaverMapSdk
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import javax.inject.Inject

@HiltAndroidApp
class FindUApp : Application() {

@Inject
lateinit var sessionExpiredEventManager: SessionExpiredEventManager

private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var currentActivity: WeakReference<Activity>? = null

override fun onCreate() {
super.onCreate()

Expand All @@ -21,5 +40,35 @@ class FindUApp : Application() {

// AdMob 초기화
MobileAds.initialize(this)

registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
currentActivity = WeakReference(activity)
}

override fun onActivityPaused(activity: Activity) {
if (currentActivity?.get() === activity) {
currentActivity = null
}
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})

applicationScope.launch {
sessionExpiredEventManager.sessionExpiredEvent.collect {
val activity = currentActivity?.get() ?: return@collect
if (activity is LoginActivity || activity is SplashActivity) return@collect

val intent = Intent(activity, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
activity.startActivity(intent)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,41 +13,50 @@ import javax.inject.Inject
class AuthAuthenticator @Inject constructor(
private val tokenLocalDataSource: TokenLocalDataSource,
private val reissueService: ReissueService,
private val sessionExpiredEventManager: SessionExpiredEventManager,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// 401 Unauthorized 에러 감지
if (response.code == 401) {
val staleToken = tokenLocalDataSource.accessToken

synchronized(LOCK) {
val currentToken = tokenLocalDataSource.accessToken

if (currentToken != staleToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}

return try {
val refreshToken = tokenLocalDataSource.refreshToken

// 토큰 재발급 요청
val reissueResponse = runBlocking {
reissueService.postReissueToken(
TokenReissueRequestDto(refreshToken)
)
}

if (reissueResponse.success) {
// 새로운 토큰으로 저장
tokenLocalDataSource.accessToken = reissueResponse.data.accessToken
tokenLocalDataSource.refreshToken = reissueResponse.data.refreshToken

// 새로운 토큰으로 원래 요청 재시도
response.request.newBuilder()
.header("Authorization", "Bearer ${reissueResponse.data.accessToken}")
.build()
} else {
// 토큰 재발급 실패 - 로그인 화면으로 이동
tokenLocalDataSource.clearToken()
sessionExpiredEventManager.notifySessionExpired()
null
}
} catch (e: Exception) {
// 토큰 재발급 중 오류 발생 - 로그인 화면으로 이동
tokenLocalDataSource.clearToken()
sessionExpiredEventManager.notifySessionExpired()
null
}
}
}

return null
companion object {
private val LOCK = Any()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.kuit.findu.data.dataremote.util

import com.kuit.findu.data.datalocal.datasource.TokenLocalDataSource
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject

class AuthErrorInterceptor @Inject constructor(
private val tokenLocalDataSource: TokenLocalDataSource,
private val sessionExpiredEventManager: SessionExpiredEventManager,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())

if (response.code == 403) {
tokenLocalDataSource.clearToken()
sessionExpiredEventManager.notifySessionExpired()
}

return response
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package com.kuit.findu.data.dataremote.util

class AuthenticationException: Exception()
class AuthenticationException(message: String = "Session expired") : Exception(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.kuit.findu.data.dataremote.util

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SessionExpiredEventManager @Inject constructor() {
private val _sessionExpiredEvent = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
)
val sessionExpiredEvent: SharedFlow<Unit> = _sessionExpiredEvent.asSharedFlow()

fun notifySessionExpired() {
_sessionExpiredEvent.tryEmit(Unit)
}
}
16 changes: 15 additions & 1 deletion app/src/main/java/com/kuit/findu/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import com.kuit.findu.BuildConfig.DEBUG
import com.kuit.findu.data.datalocal.datasource.TokenLocalDataSource
import com.kuit.findu.data.dataremote.service.ReissueService
import com.kuit.findu.data.dataremote.util.AuthAuthenticator
import com.kuit.findu.data.dataremote.util.AuthErrorInterceptor
import com.kuit.findu.data.dataremote.util.AuthInterceptor
import com.kuit.findu.data.dataremote.util.DiscordLogger
import com.kuit.findu.data.dataremote.util.ErrorTrackingInterceptor
import com.kuit.findu.data.dataremote.util.SessionExpiredEventManager
import com.kuit.findu.di.qualifier.ReissueRetrofit
import dagger.Module
import dagger.Provides
Expand Down Expand Up @@ -45,13 +47,15 @@ object NetworkModule {
loggingInterceptor: HttpLoggingInterceptor,
authInterceptor: AuthInterceptor,
authAuthenticator: AuthAuthenticator,
authErrorInterceptor: AuthErrorInterceptor,
errorTrackingInterceptor: ErrorTrackingInterceptor,
): OkHttpClient =
OkHttpClient.Builder().apply {
connectTimeout(10, TimeUnit.SECONDS)
writeTimeout(10, TimeUnit.SECONDS)
readTimeout(10, TimeUnit.SECONDS)
addInterceptor(authInterceptor)
addInterceptor(authErrorInterceptor)
if (DEBUG) addInterceptor(loggingInterceptor)
else addInterceptor(errorTrackingInterceptor)
authenticator(authAuthenticator)
Expand All @@ -75,8 +79,18 @@ object NetworkModule {
fun provideAuthAuthenticator(
tokenLocalDataSource: TokenLocalDataSource,
reissueService: ReissueService,
sessionExpiredEventManager: SessionExpiredEventManager,
): AuthAuthenticator {
return AuthAuthenticator(tokenLocalDataSource, reissueService)
return AuthAuthenticator(tokenLocalDataSource, reissueService, sessionExpiredEventManager)
}

@Provides
@Singleton
fun provideAuthErrorInterceptor(
tokenLocalDataSource: TokenLocalDataSource,
sessionExpiredEventManager: SessionExpiredEventManager,
): AuthErrorInterceptor {
return AuthErrorInterceptor(tokenLocalDataSource, sessionExpiredEventManager)
}

@Provides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import com.kuit.findu.presentation.ui.home.composeview.HomeScreen
import com.kuit.findu.presentation.ui.home.viewmodel.HomeUiEffect
import com.kuit.findu.presentation.ui.home.viewmodel.HomeUiEvent
import com.kuit.findu.presentation.ui.home.viewmodel.HomeViewModel
import com.kuit.findu.presentation.ui.login.LoginActivity
import com.kuit.findu.presentation.util.permission.LocationPermissionManager.hasLocationPermission
import dagger.hilt.android.AndroidEntryPoint

Expand Down Expand Up @@ -96,7 +95,6 @@ class HomeFragment : Fragment() {
}

is HomeUiEffect.Dial -> call120()
is HomeUiEffect.NavigateToLogin -> startLoginActivity()
}
}
}
Expand Down Expand Up @@ -176,12 +174,6 @@ class HomeFragment : Fragment() {
)
}

private fun startLoginActivity() {
val intent = Intent(requireContext(), LoginActivity::class.java)
startActivity(intent)
requireActivity().finish()
}

private fun navigateToProtectDetail(id: String, tag: String, name: String) {
when (tag) {
AnimalStateType.PROTECT.state -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,21 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import android.os.SystemClock
import android.util.Log
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.google.firebase.perf.FirebasePerformance
import com.kuit.findu.R
import com.kuit.findu.domain.model.ProtectAnimal
import com.kuit.findu.presentation.type.AnimalStateType
Expand All @@ -48,8 +54,33 @@ fun HomeProtectAnimalCard(
Box(
modifier = Modifier.size(height = 100.dp, width = 120.dp),
) {
val context = LocalContext.current
val imageRequest = remember(animal.thumbnailImageUrl) {
val trace = FirebasePerformance.getInstance().newTrace("home_protect_image_load")
var startTime = 0L
ImageRequest.Builder(context)
.data(animal.thumbnailImageUrl)
.listener(
onStart = {
startTime = SystemClock.elapsedRealtime()
trace.start()
},
onSuccess = { _, _ ->
val duration = SystemClock.elapsedRealtime() - startTime
Log.d("ImagePerf", "보호동물 이미지 로딩 완료: ${duration}ms | ${animal.thumbnailImageUrl}")
trace.stop()
},
onError = { _, _ ->
val duration = SystemClock.elapsedRealtime() - startTime
Log.e("ImagePerf", "보호동물 이미지 로딩 실패: ${duration}ms | ${animal.thumbnailImageUrl}")
trace.putAttribute("status", "error")
trace.stop()
}
)
.build()
}
Comment on lines +57 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

remember 블록 안에서 trace를 생성하면 재사용 시 문제가 될 수 있습니다.

remember(animal.thumbnailImageUrl) 블록 안에서 trace 객체가 생성됩니다. Coil이 캐시에서 이미지를 로드하면 onStartonSuccess가 빠르게 호출되어 정상 동작하지만, 만약 같은 composable이 LazyList에서 재활용되면서 동일 URL로 재구성될 경우, remember가 캐시된 ImageRequest를 반환하므로 이미 stop()된 trace에 대해 다시 start()가 호출될 수 있습니다.

대부분의 경우 문제가 없겠지만, 안전하게 하려면 trace 생성을 listener 내부로 이동하거나, 매 요청마다 새 trace를 생성하는 구조를 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/kuit/findu/presentation/ui/home/component/HomeProtectAnimalCard.kt`
around lines 57 - 81, The ImageRequest currently creates a Firebase Performance
Trace inside the remember(animal.thumbnailImageUrl) block (see imageRequest and
the Trace created via
FirebasePerformance.getInstance().newTrace("home_protect_image_load")), which
can cause start() to be called on a stopped trace when the composable is
recycled; move trace creation into the listener callbacks so each request gets a
fresh trace (e.g., create the trace in onStart or create a new Trace instance at
the beginning of onStart and call trace.start(), then stop it in
onSuccess/onError), or alternatively make the trace nullable and instantiate a
new Trace inside onStart and only call stop() if that trace was created—update
the listener in ImageRequest.Builder(...) (onStart/onSuccess/onError) and remove
trace construction from the remember block to ensure a new trace per request.

AsyncImage(
model = animal.thumbnailImageUrl,
model = imageRequest,
contentDescription = "Animal Image",
contentScale = ContentScale.Crop,
modifier = Modifier
Expand Down
Loading
Loading