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}" /> + + + + + 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}회)") + + 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/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) } 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() } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ec70c78..f8912edd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,8 @@ androidxLifecycle = "2.7.0" androidxNavigation = "2.7.7" androidxRoom = "2.6.1" androidxDatastore = "1.0.0" +androidxWork = "2.9.1" +androidxHilt = "1.2.0" androidxArchCoreTesting = "2.2.0" # 테스트용 androidxMedia = "1.7.0" lottie = "6.6.6" @@ -164,6 +166,11 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers # AndroidX DataStore (Preferences Example) androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" } +# AndroidX WorkManager +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" } # KSP + # Hilt (DI) hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } # KSP/Kapt