diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 713dbfc..b6d66af 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,7 +47,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 11015 - versionName = "1.4.0" + versionName = "1.4.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt b/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt index 433908a..ad7e8dd 100644 --- a/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt +++ b/app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt @@ -24,6 +24,9 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okio.Timeout +import java.io.IOException +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock import retrofit2.Retrofit import retrofit2.converter.jackson.JacksonConverterFactory import timber.log.Timber @@ -84,45 +87,82 @@ object NetworkModule { sessionModule: SessionModule, ): Authenticator { val authenticatorClient = createOkHttpClient(null, null) + val refreshLock = ReentrantLock() + var lastRefreshedAccessToken: String? = null + return Authenticator { _, response -> if (response.code == 401) { - Timber.d("[NetworkModule] Refresh tokens with Authenticator") - val currentSession = sessionModule.sessionState.value - if (!currentSession.isLoggedIn()) return@Authenticator null - - val previousApiToken = currentSession.apiToken - val headers = Headers.headersOf( - "Accept", "application/json", - "X-APP-KEY", BuildConfig.appKey, - "X-APP-VERSION", BuildConfig.VERSION_NAME, - "X-USER-PLATFORM", "AOS", - "X-USER-ID", currentSession.memberId, - ) - val refreshRequest = Request.Builder() - .url(BuildConfig.apiBaseUrl + "v1/auth/refresh") - .headers(headers) - .post( - "{\"refreshToken\": \"${previousApiToken.refreshToken}\"}" - .toRequestBody("application/json".toMediaType()) + Timber.d("[NetworkModule] 401 received, attempting token refresh") + + refreshLock.withLock { + val currentSession = sessionModule.sessionState.value + if (!currentSession.isLoggedIn()) return@Authenticator null + + val failedRequestToken = response.request.header("X-AUTH-TOKEN") + val currentAccessToken = currentSession.apiToken.accessToken + + // 다른 스레드가 이미 refresh에 성공한 경우, 새 토큰으로 재시도 + if (failedRequestToken != null + && failedRequestToken != currentAccessToken + && currentAccessToken == lastRefreshedAccessToken + ) { + Timber.d("[NetworkModule] Token already refreshed by another thread, retrying") + return@Authenticator response.request + .newBuilder() + .removeHeader("X-AUTH-TOKEN") + .addHeader("X-AUTH-TOKEN", currentAccessToken) + .build() + } + + val previousApiToken = currentSession.apiToken + val headers = Headers.headersOf( + "Accept", "application/json", + "X-APP-KEY", BuildConfig.appKey, + "X-APP-VERSION", BuildConfig.VERSION_NAME, + "X-USER-PLATFORM", "AOS", + "X-USER-ID", currentSession.memberId, ) - .build() - kotlin.runCatching { - authenticatorClient.newCall(refreshRequest).execute().use { refreshResponse -> - if (refreshResponse.isSuccessful) { - val newToken = refreshResponse.body!!.string() - val newTokenObject = Gson().fromJson(newToken, AuthResult::class.java) - - sessionModule.onRefreshToken(newTokenObject) - return@Authenticator response.request - .newBuilder() - .removeHeader("X-AUTH-TOKEN") - .addHeader("X-AUTH-TOKEN", newTokenObject.accessToken) - .build() - } else throw RuntimeException() + val refreshRequest = Request.Builder() + .url(BuildConfig.apiBaseUrl + "v1/auth/refresh") + .headers(headers) + .post( + "{\"refreshToken\": \"${previousApiToken.refreshToken}\"}" + .toRequestBody("application/json".toMediaType()) + ) + .build() + + try { + authenticatorClient.newCall(refreshRequest).execute() + .use { refreshResponse -> + if (refreshResponse.isSuccessful) { + val newToken = refreshResponse.body!!.string() + val newTokenObject = + Gson().fromJson(newToken, AuthResult::class.java) + + sessionModule.onRefreshToken(newTokenObject) + lastRefreshedAccessToken = newTokenObject.accessToken + Timber.d("[NetworkModule] Token refresh succeeded") + + return@Authenticator response.request + .newBuilder() + .removeHeader("X-AUTH-TOKEN") + .addHeader("X-AUTH-TOKEN", newTokenObject.accessToken) + .build() + } else { + // 서버가 refresh token을 명시적으로 거부 (만료/무효) + Timber.w( + "[NetworkModule] Refresh failed with HTTP %d - invalidating session", + refreshResponse.code + ) + requireTokenInvalidRestart.value = true + sessionModule.invalidateSession() + } + } + } catch (e: IOException) { + // 네트워크 오류 (타임아웃, 연결 끊김 등) + // 세션을 삭제하지 않음 — 디스크에 유효한 토큰이 남아있음 + Timber.w(e, "[NetworkModule] Network error during token refresh - NOT invalidating session") } - }.onFailure { - requireTokenInvalidRestart.value = true - sessionModule.invalidateSession() } } null diff --git a/app/src/main/java/com/no5ing/bbibbi/di/SessionModule.kt b/app/src/main/java/com/no5ing/bbibbi/di/SessionModule.kt index 455347e..5a534ee 100644 --- a/app/src/main/java/com/no5ing/bbibbi/di/SessionModule.kt +++ b/app/src/main/java/com/no5ing/bbibbi/di/SessionModule.kt @@ -1,6 +1,7 @@ package com.no5ing.bbibbi.di import android.content.Context +import com.no5ing.bbibbi.data.datasource.local.LocalDataStorage import com.no5ing.bbibbi.data.model.auth.AuthResult import com.no5ing.bbibbi.data.model.member.Member import com.no5ing.bbibbi.presentation.feature.uistate.common.SessionState @@ -11,7 +12,10 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class SessionModule @Inject constructor(val context: Context) { +class SessionModule @Inject constructor( + val context: Context, + private val localDataStorage: LocalDataStorage, +) { private val _sessionState = MutableStateFlow(SessionState(isLoggedIn = false)) val sessionState: StateFlow = _sessionState @@ -20,6 +24,9 @@ class SessionModule @Inject constructor(val context: Context) { } fun onRefreshToken(newTokenPair: AuthResult) { + localDataStorage.setAuthTokens(newTokenPair) + Timber.d("[SessionModule] Token persisted to disk after refresh") + _sessionState.value = _sessionState.value.copy( isLoggedIn = true, _apiToken = newTokenPair,