Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
110 changes: 75 additions & 35 deletions app/src/main/java/com/no5ing/bbibbi/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion app/src/main/java/com/no5ing/bbibbi/di/SessionModule.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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> = _sessionState

Expand All @@ -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,
Expand Down
Loading