From b2d69a1a8b01acc53ffeb49ad96a25503e3048a5 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sat, 15 Aug 2026 18:41:30 +0000 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20WorkManager=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20Hilt=20Worker=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=B6=94=EA=B0=80=20#565?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 5 +++++ app/src/main/AndroidManifest.xml | 15 ++++++++++++++- .../com/project200/undabang/ApplicationClass.kt | 15 ++++++++++++++- gradle/libs.versions.toml | 7 +++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fabcbf1b..2ff19d2c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,11 @@ dependencies { implementation(libs.androidx.navigation.fragment.ktx) implementation(libs.androidx.navigation.ui.ktx) + // WorkManager (FCM 토큰 전송 예약) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + "ksp"(libs.androidx.hilt.compiler) + implementation(libs.appauth) // Kakao Map diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 86b9abff..ce261c91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -44,6 +45,18 @@ android:name="com.kakao.sdk.AppKey" android:value="${KAKAO_NATIVE_APP_KEY}" /> + + + + + Date: Sat, 15 Aug 2026 18:42:05 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20FCM=20=ED=86=A0=ED=81=B0=EC=9D=84=20?= =?UTF-8?q?=EB=A7=A4=EB=B2=88=20=EC=A0=80=EC=9E=A5=EC=86=8C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=BD=EB=8F=84=EB=A1=9D=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20=EC=BA=90=EC=8B=9C=20=EC=A0=9C=EA=B1=B0=20#565?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/data/utils/FcmTokenProvider.kt | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt b/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt index 8f0afb0c..d82b7dca 100644 --- a/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt +++ b/data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt @@ -3,35 +3,19 @@ package com.project200.data.utils import android.content.SharedPreferences import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.utils.EncryptedPrefs -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * FCM 토큰을 읽는 단일 창구입니다 + * 토큰은 FcmService.onNewToken()이 저장소에 직접 씁니다 + * 메모리에 캐시하면 갱신 이후에도 옛 토큰이 헤더에 실리므로 호출할 때마다 저장소를 읽습니다 + */ @Singleton class FcmTokenProvider @Inject constructor( @EncryptedPrefs private val prefs: SharedPreferences, ) { - private var fcmToken: String? = null - - init { - loadTokenFromPrefs() - } - - private fun loadTokenFromPrefs() { - fcmToken = prefs.getString(KEY_FCM_TOKEN, null) - Timber.tag("FcmTokenProvider").d("FCM 토큰 : $fcmToken") - } - - fun getFcmToken(): String? { - if (fcmToken == null) { - loadTokenFromPrefs() - } - return fcmToken - } - - fun refreshToken() { - loadTokenFromPrefs() - } + fun getFcmToken(): String? = prefs.getString(KEY_FCM_TOKEN, null) } From 272f0eca1688a9c83284bf0322c11325d175b272 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sat, 15 Aug 2026 19:01:06 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EC=8B=9C=20WorkManager=EB=A1=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=B0=98=EC=98=81=20#565?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/undabang/fcm/FcmService.kt | 4 +- .../undabang/fcm/FcmTokenSyncPolicy.kt | 10 ++ .../undabang/fcm/FcmTokenSyncWorker.kt | 76 +++++++++++ .../undabang/fcm/FcmTokenSyncPolicyTest.kt | 30 +++++ .../undabang/main/MainViewModelTest.kt | 21 +++ .../data/impl/AuthRepositoryImpl.kt | 18 +++ .../domain/model/FcmTokenSyncResult.kt | 8 ++ .../domain/usecase/SyncFcmTokenUseCase.kt | 32 +++++ .../domain/usecase/SyncFcmTokenUseCaseTest.kt | 123 ++++++++++++++++++ 9 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncPolicy.kt create mode 100644 app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt create mode 100644 app/src/test/java/com/project200/undabang/fcm/FcmTokenSyncPolicyTest.kt create mode 100644 domain/src/main/java/com/project200/domain/model/FcmTokenSyncResult.kt create mode 100644 domain/src/main/java/com/project200/domain/usecase/SyncFcmTokenUseCase.kt create mode 100644 domain/src/test/java/com/project200/domain/usecase/SyncFcmTokenUseCaseTest.kt diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt index b8945885..d0fedca6 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt @@ -30,13 +30,15 @@ class FcmService : FirebaseMessagingService() { /** * 새로운 FCM 토큰이 발급되거나 갱신될 때 호출됩니다. - * 이 토큰은 백엔드 서버로 전송되어 특정 기기에 알림을 보내는 데 사용됩니다. + * 기기에 저장한 뒤 서버 반영은 워커에 맡깁니다. + * 이 콜백은 백그라운드에서도 불려 네트워크와 로그인 상태를 확신할 수 없습니다. */ override fun onNewToken(token: String) { super.onNewToken(token) Timber.tag(TAG).d("Refreshed FCM token: $token") saveFcmToken(token) + FcmTokenSyncWorker.enqueue(applicationContext) } /** diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncPolicy.kt b/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncPolicy.kt new file mode 100644 index 00000000..4767586c --- /dev/null +++ b/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncPolicy.kt @@ -0,0 +1,10 @@ +package com.project200.undabang.fcm + +/** FcmTokenSyncWorker의 재시도 판단. 안드로이드 의존성이 없어 단위 테스트로 검증합니다 */ +object FcmTokenSyncPolicy { + /** 전송 실패 시 총 시도 횟수 상한 */ + const val MAX_ATTEMPT_COUNT = 5 + + /** runAttemptCount는 WorkManager가 주는 값으로 첫 실행이 0입니다 */ + fun shouldRetry(runAttemptCount: Int): Boolean = runAttemptCount + 1 < MAX_ATTEMPT_COUNT +} diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt b/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt new file mode 100644 index 00000000..746b20ae --- /dev/null +++ b/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt @@ -0,0 +1,76 @@ +package com.project200.undabang.fcm + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.project200.domain.model.FcmTokenSyncResult +import com.project200.domain.usecase.SyncFcmTokenUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * 갱신된 FCM 토큰을 서버에 반영하는 워커입니다 + * onNewToken()은 백그라운드나 프로세스 기동 직후에도 불려 네트워크와 로그인 상태를 확신할 수 없습니다 + * 전송을 워커로 미뤄 연결이 잡힌 뒤에 보내고 실패하면 다시 시도합니다 + */ +@HiltWorker +class FcmTokenSyncWorker + @AssistedInject + constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val syncFcmTokenUseCase: SyncFcmTokenUseCase, + ) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val syncResult = syncFcmTokenUseCase() + Timber.tag(TAG).d("FCM 토큰 전송 결과: $syncResult (시도 ${runAttemptCount + 1}회)") + + return when (syncResult) { + FcmTokenSyncResult.SUCCESS -> Result.success() + // 로그인 전이거나 보낼 토큰이 없는 상태 - 재시도해도 같으므로 성공 처리 + FcmTokenSyncResult.SKIPPED -> Result.success() + FcmTokenSyncResult.FAILURE -> + if (FcmTokenSyncPolicy.shouldRetry(runAttemptCount)) Result.retry() else Result.failure() + } + } + + companion object { + private const val TAG = "FcmTokenSyncWorker" + const val WORK_NAME = "fcm_token_sync" + private const val BACKOFF_DELAY_SECONDS = 30L + + /** + * 토큰 등록을 예약합니다 + * 대기 중에 토큰이 다시 갱신되면 새 요청으로 바꿔 백오프를 처음부터 다시 셉니다 + * 워커는 실행 시점에 저장소에서 토큰을 읽으므로 마지막 값이 올라갑니다 + */ + fun enqueue(context: Context) { + val constraints = + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BACKOFF_DELAY_SECONDS, + TimeUnit.SECONDS, + ) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + } + } diff --git a/app/src/test/java/com/project200/undabang/fcm/FcmTokenSyncPolicyTest.kt b/app/src/test/java/com/project200/undabang/fcm/FcmTokenSyncPolicyTest.kt new file mode 100644 index 00000000..9147cc3a --- /dev/null +++ b/app/src/test/java/com/project200/undabang/fcm/FcmTokenSyncPolicyTest.kt @@ -0,0 +1,30 @@ +package com.project200.undabang.fcm + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class FcmTokenSyncPolicyTest { + @Test + fun `첫 실행에서 실패하면 재시도한다`() { + assertThat(FcmTokenSyncPolicy.shouldRetry(0)).isTrue() + } + + @Test + fun `상한 직전까지는 재시도한다`() { + val lastRetryableAttempt = FcmTokenSyncPolicy.MAX_ATTEMPT_COUNT - 2 + + assertThat(FcmTokenSyncPolicy.shouldRetry(lastRetryableAttempt)).isTrue() + } + + @Test + fun `상한에 닿으면 재시도하지 않는다`() { + val lastAttempt = FcmTokenSyncPolicy.MAX_ATTEMPT_COUNT - 1 + + assertThat(FcmTokenSyncPolicy.shouldRetry(lastAttempt)).isFalse() + } + + @Test + fun `상한을 넘긴 뒤에도 재시도하지 않는다`() { + assertThat(FcmTokenSyncPolicy.shouldRetry(FcmTokenSyncPolicy.MAX_ATTEMPT_COUNT)).isFalse() + } +} diff --git a/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt b/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt index 7c752c2c..23f7b790 100644 --- a/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt +++ b/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt @@ -495,6 +495,27 @@ class MainViewModelTest { coVerify(exactly = 1) { mockLoginUseCase() } } + @Test + fun `FCM 등록 - 토큰이 없어 실패하면 대기가 닫히지 않고 재연결 때 재시도한다`() = + runTest { + // Given: FCM 토큰이 없어 서버 로그인이 막힌다 + stubNoUpdate() + stubAuthState(isAuthorized = true) + coEvery { mockLoginUseCase() } returns + BaseResult.Error("NO_FCM_TOKEN", "FCM 토큰이 없어 서버 로그인을 보내지 않았습니다.") andThen + BaseResult.Success(Unit) + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + // When: offline → online 재연결 + networkStateFlow.emit(false) + networkStateFlow.emit(true) + testDispatcher.scheduler.advanceUntilIdle() + + // Then: 성공으로 닫히지 않았으므로 재시도가 한 번 더 나간다 + coVerify(exactly = 2) { mockLoginUseCase() } + } + @Test fun `FCM 등록 - 요청이 진행 중이면 재연결이 겹쳐도 중복 발행하지 않는다`() = runTest { diff --git a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt index e2dd2e70..001f7aae 100644 --- a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.project200.data.dto.GetIsNicknameDuplicated import com.project200.data.dto.PostLoginRequest import com.project200.data.dto.PostSignUpRequest import com.project200.data.local.PreferenceManager +import com.project200.data.utils.FcmTokenProvider import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult import com.project200.domain.model.RegistrationStatus @@ -23,6 +24,7 @@ class AuthRepositoryImpl private val apiService: ApiService, private val spManager: PreferenceManager, private val authStateManager: AuthStateManager, + private val fcmTokenProvider: FcmTokenProvider, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : AuthRepository { override suspend fun checkIsRegistered(): RegistrationStatus { @@ -56,7 +58,21 @@ class AuthRepositoryImpl } } + /** + * POST /login은 X-Fcm-Token 헤더로 기기 토큰을 등록합니다. + * 토큰이 없으면 서버는 헤더 없는 요청을 LOGIN_SUCCESS로 돌려주고 등록은 건너뜁니다. + * 그 성공을 그대로 올리면 재시도 경로가 닫히므로 보내기 전에 막습니다. + */ override suspend fun login(): BaseResult { + val fcmToken = withContext(ioDispatcher) { fcmTokenProvider.getFcmToken() } + if (fcmToken.isNullOrBlank()) { + Timber.tag(TAG).w("FCM 토큰이 없어 서버 로그인을 보내지 않습니다.") + return BaseResult.Error( + errorCode = NO_FCM_TOKEN_ERROR_CODE, + message = NO_FCM_TOKEN_ERROR_MESSAGE, + ) + } + return apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { apiService.postLogin(PostLoginRequest("ANDROID", "APP")) }, @@ -114,5 +130,7 @@ class AuthRepositoryImpl companion object { const val TAG = "AuthRepositoryImpl" + const val NO_FCM_TOKEN_ERROR_CODE = "NO_FCM_TOKEN" + private const val NO_FCM_TOKEN_ERROR_MESSAGE = "FCM 토큰이 없어 서버 로그인을 보내지 않았습니다." } } diff --git a/domain/src/main/java/com/project200/domain/model/FcmTokenSyncResult.kt b/domain/src/main/java/com/project200/domain/model/FcmTokenSyncResult.kt new file mode 100644 index 00000000..b51d8d0b --- /dev/null +++ b/domain/src/main/java/com/project200/domain/model/FcmTokenSyncResult.kt @@ -0,0 +1,8 @@ +package com.project200.domain.model + +/** FCM 토큰 서버 반영 결과 */ +enum class FcmTokenSyncResult { + SUCCESS, // 서버에 반영됨 + SKIPPED, // 로그인 전이거나 보낼 토큰이 없음. 재시도해도 같음 + FAILURE, // 전송을 시도했으나 실패. 재시도 대상 +} diff --git a/domain/src/main/java/com/project200/domain/usecase/SyncFcmTokenUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/SyncFcmTokenUseCase.kt new file mode 100644 index 00000000..21cb48ea --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/SyncFcmTokenUseCase.kt @@ -0,0 +1,32 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.FcmTokenSyncResult +import com.project200.domain.repository.AuthRepository +import com.project200.domain.repository.FcmRepository +import javax.inject.Inject + +/** + * 저장된 FCM 토큰을 서버에 반영합니다 + * 토큰 전용 엔드포인트가 없어 당분간 POST /login을 씁니다 + * 토큰은 호출 시점에 저장소에서 읽습니다. 예약과 실행 사이에 토큰이 다시 갱신될 수 있습니다 + */ +class SyncFcmTokenUseCase @Inject constructor( + private val authRepository: AuthRepository, + private val fcmRepository: FcmRepository +) { + suspend operator fun invoke(): FcmTokenSyncResult { + if (authRepository.getMemberId().isNullOrBlank()) { + return FcmTokenSyncResult.SKIPPED + } + + if (fcmRepository.getFcmTokenFromPrefs().isNullOrBlank()) { + return FcmTokenSyncResult.SKIPPED + } + + return when (authRepository.login()) { + is BaseResult.Success -> FcmTokenSyncResult.SUCCESS + is BaseResult.Error -> FcmTokenSyncResult.FAILURE + } + } +} diff --git a/domain/src/test/java/com/project200/domain/usecase/SyncFcmTokenUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/SyncFcmTokenUseCaseTest.kt new file mode 100644 index 00000000..356039ba --- /dev/null +++ b/domain/src/test/java/com/project200/domain/usecase/SyncFcmTokenUseCaseTest.kt @@ -0,0 +1,123 @@ +package com.project200.domain.usecase + +import com.google.common.truth.Truth.assertThat +import com.project200.domain.model.BaseResult +import com.project200.domain.model.FcmTokenSyncResult +import com.project200.domain.repository.AuthRepository +import com.project200.domain.repository.FcmRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class SyncFcmTokenUseCaseTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var mockAuthRepository: AuthRepository + + @MockK + private lateinit var mockFcmRepository: FcmRepository + + private lateinit var useCase: SyncFcmTokenUseCase + + @Before + fun setUp() { + useCase = SyncFcmTokenUseCase(mockAuthRepository, mockFcmRepository) + } + + @Test + fun `로그인 전이면 전송하지 않고 SKIPPED를 반환한다`() = runTest { + // Given: 회원ID가 없다 = 로그인 전 + coEvery { mockAuthRepository.getMemberId() } returns null + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.SKIPPED) + coVerify(exactly = 0) { mockAuthRepository.login() } + } + + @Test + fun `저장된 토큰이 없으면 전송하지 않고 SKIPPED를 반환한다`() = runTest { + // Given + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getFcmTokenFromPrefs() } returns null + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.SKIPPED) + coVerify(exactly = 0) { mockAuthRepository.login() } + } + + @Test + fun `전송에 성공하면 SUCCESS를 반환한다`() = runTest { + // Given + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getFcmTokenFromPrefs() } returns "token-1" + coEvery { mockAuthRepository.login() } returns BaseResult.Success(Unit) + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.SUCCESS) + coVerify(exactly = 1) { mockAuthRepository.login() } + } + + @Test + fun `전송에 실패하면 FAILURE를 반환한다`() = runTest { + // Given + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getFcmTokenFromPrefs() } returns "token-1" + coEvery { mockAuthRepository.login() } returns BaseResult.Error("NETWORK_ERROR", "네트워크 오류") + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.FAILURE) + } + + @Test + fun `토큰이 없어 로그인이 막히면 FAILURE를 반환한다`() = runTest { + // Given: 조회 직후 토큰이 지워져 login()이 NO_FCM_TOKEN으로 막힌 경우 + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getFcmTokenFromPrefs() } returns "token-1" + coEvery { mockAuthRepository.login() } returns + BaseResult.Error("NO_FCM_TOKEN", "FCM 토큰이 없어 서버 로그인을 보내지 않았습니다.") + + // When + val result = useCase() + + // Then + assertThat(result).isEqualTo(FcmTokenSyncResult.FAILURE) + } + + @Test + fun `대기 중 토큰이 갱신되면 호출 시점의 값을 읽는다`() = runTest { + // Given: 예약 시점의 token-1이 아니라 실행 시점의 token-2가 저장소에 있다 + coEvery { mockAuthRepository.getMemberId() } returns "member-1" + coEvery { mockFcmRepository.getFcmTokenFromPrefs() } returns "token-1" andThen "token-2" + coEvery { mockAuthRepository.login() } returns BaseResult.Success(Unit) + useCase() // 첫 실행이 token-1을 읽는다 + + // When: 두 번째 실행 + val result = useCase() + + // Then: 저장소를 매번 다시 읽는다 + assertThat(result).isEqualTo(FcmTokenSyncResult.SUCCESS) + coVerify(exactly = 2) { mockFcmRepository.getFcmTokenFromPrefs() } + } +} From 30a7f982cb1e5c66f655d42de925a66b1be8373f Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 16 Aug 2026 12:06:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20FCM=20=ED=86=A0=ED=81=B0=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=9B=8C=EC=BB=A4=EC=97=90=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=ED=86=A0=ED=81=B0=20=EC=84=A0=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#565?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../undabang/fcm/FcmTokenSyncWorker.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt b/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt index 746b20ae..653c90b5 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt @@ -12,8 +12,12 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.project200.domain.model.FcmTokenSyncResult import com.project200.domain.usecase.SyncFcmTokenUseCase +import com.project200.undabang.oauth.AuthManager +import com.project200.undabang.oauth.AuthStateManager +import com.project200.undabang.oauth.TokenRefreshResult import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import net.openid.appauth.AuthorizationException import timber.log.Timber import java.util.concurrent.TimeUnit @@ -29,8 +33,34 @@ class FcmTokenSyncWorker @Assisted appContext: Context, @Assisted params: WorkerParameters, private val syncFcmTokenUseCase: SyncFcmTokenUseCase, + private val authStateManager: AuthStateManager, + private val authManager: AuthManager, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + // POST /login은 @AccessTokenWithFcmApi라 401 자동 갱신이 없음 - 만료면 먼저 갱신 + if (authStateManager.getCurrent().needsTokenRefresh) { + when (val refresh = authManager.refreshAccessToken()) { + is TokenRefreshResult.Success -> Unit + is TokenRefreshResult.Error -> { + val ex = refresh.exception + val invalidGrant = + ex?.type == AuthorizationException.TYPE_OAUTH_TOKEN_ERROR && + ex.error == "invalid_grant" + // invalid_grant는 재시도해도 같음. 세션 정리는 refreshAccessToken() 내부가 함 + return if (invalidGrant) { + Result.failure() + } else if (FcmTokenSyncPolicy.shouldRetry(runAttemptCount)) { + Result.retry() + } else { + Result.failure() + } + } + is TokenRefreshResult.NoRefreshToken, + is TokenRefreshResult.ConfigError, + -> return Result.failure() + } + } + val syncResult = syncFcmTokenUseCase() Timber.tag(TAG).d("FCM 토큰 전송 결과: $syncResult (시도 ${runAttemptCount + 1}회)")