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
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- 인터넷 권한 -->
<uses-permission android:name="android.permission.INTERNET" />
Expand Down Expand Up @@ -44,6 +45,18 @@
android:name="com.kakao.sdk.AppKey"
android:value="${KAKAO_NATIVE_APP_KEY}" />

<!-- WorkManager 기본 초기화를 끄고 Configuration.Provider로 직접 초기화합니다 -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/java/com/project200/undabang/ApplicationClass.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.kakao.vectormap.KakaoMapSdk
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject

@HiltAndroidApp
class ApplicationClass : Application() {
class ApplicationClass : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory

/** WorkManager가 Hilt로 Worker를 생성하도록 팩토리를 넘깁니다 */
override val workManagerConfiguration: Configuration
get() =
Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()

override fun onCreate() {
super.onCreate()

Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/com/project200/undabang/fcm/FcmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions app/src/main/java/com/project200/undabang/fcm/FcmTokenSyncWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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 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

/**
* 갱신된 FCM 토큰을 서버에 반영하는 워커입니다
* onNewToken()은 백그라운드나 프로세스 기동 직후에도 불려 네트워크와 로그인 상태를 확신할 수 없습니다
* 전송을 워커로 미뤄 연결이 잡힌 뒤에 보내고 실패하면 다시 시도합니다
*/
@HiltWorker
class FcmTokenSyncWorker
@AssistedInject
constructor(
@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}회)")

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<FcmTokenSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
BACKOFF_DELAY_SECONDS,
TimeUnit.SECONDS,
)
.build()

WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -56,7 +58,21 @@ class AuthRepositoryImpl
}
}

/**
* POST /login은 X-Fcm-Token 헤더로 기기 토큰을 등록합니다.
* 토큰이 없으면 서버는 헤더 없는 요청을 LOGIN_SUCCESS로 돌려주고 등록은 건너뜁니다.
* 그 성공을 그대로 올리면 재시도 경로가 닫히므로 보내기 전에 막습니다.
*/
override suspend fun login(): BaseResult<Unit> {
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")) },
Expand Down Expand Up @@ -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 토큰이 없어 서버 로그인을 보내지 않았습니다."
}
}
28 changes: 6 additions & 22 deletions data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.project200.domain.model

/** FCM 토큰 서버 반영 결과 */
enum class FcmTokenSyncResult {
SUCCESS, // 서버에 반영됨
SKIPPED, // 로그인 전이거나 보낼 토큰이 없음. 재시도해도 같음
FAILURE, // 전송을 시도했으나 실패. 재시도 대상
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading