From 5a3fa64c5eabd8c04461ffeb7ed9fed030df1d0a Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:09:56 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20FCM=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20=EB=B0=8F=20=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EB=93=B1=EB=A1=9D/=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FirebaseMessagingService로 푸시를 수신해 알림으로 표시하고, 세션 인증 상태와 포그라운드 진입 시점마다 알림 권한을 확인해 /api/members/me/device-tokens에 토큰을 등록/해제한다. 로그아웃 시에는 토큰 무효화 전에 해제 API를 먼저 호출한다. --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 17 +++++ .../com/gamss/android/app/GamssApplication.kt | 16 ++++ .../com/gamss/android/app/MainActivity.kt | 31 +++++++- .../gamss/android/app/main/MainViewModel.kt | 12 +++ .../app/push/GamssFirebaseMessagingService.kt | 76 +++++++++++++++++++ app/src/main/res/drawable/ic_notification.xml | 12 +++ app/src/main/res/values/strings.xml | 3 + data/build.gradle.kts | 1 + .../gamss/android/data/di/FirebaseModule.kt | 6 ++ .../gamss/android/data/di/NetworkModule.kt | 6 ++ .../com/gamss/android/data/di/PushModule.kt | 25 ++++++ .../gamss/android/data/di/RepositoryModule.kt | 7 ++ .../AndroidNotificationPermissionChecker.kt | 25 ++++++ .../push/FirebaseMessagingTokenProvider.kt | 25 ++++++ .../data/remote/push/DeviceTokenService.kt | 19 +++++ .../request/RegisterDeviceTokenRequest.kt | 8 ++ .../request/UnregisterDeviceTokenRequest.kt | 8 ++ .../repository/DeviceTokenRepositoryImpl.kt | 24 ++++++ .../android/domain/auth/LogoutUseCase.kt | 8 +- .../domain/push/DeviceTokenRepository.kt | 10 +++ .../push/NotificationPermissionChecker.kt | 6 ++ .../android/domain/push/PushTokenProvider.kt | 7 ++ .../domain/push/SyncDeviceTokenUseCase.kt | 26 +++++++ .../UnregisterCurrentDeviceTokenUseCase.kt | 21 +++++ gradle/libs.versions.toml | 1 + 26 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt create mode 100644 app/src/main/res/drawable/ic_notification.xml create mode 100644 data/src/main/java/com/gamss/android/data/di/PushModule.kt create mode 100644 data/src/main/java/com/gamss/android/data/push/AndroidNotificationPermissionChecker.kt create mode 100644 data/src/main/java/com/gamss/android/data/push/FirebaseMessagingTokenProvider.kt create mode 100644 data/src/main/java/com/gamss/android/data/remote/push/DeviceTokenService.kt create mode 100644 data/src/main/java/com/gamss/android/data/remote/push/model/request/RegisterDeviceTokenRequest.kt create mode 100644 data/src/main/java/com/gamss/android/data/remote/push/model/request/UnregisterDeviceTokenRequest.kt create mode 100644 data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/push/DeviceTokenRepository.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/push/NotificationPermissionChecker.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6141d8bc..101478ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,6 +114,9 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.crashlytics) implementation(libs.firebase.analytics) + implementation(libs.firebase.messaging) + + implementation(libs.androidx.core.ktx) testImplementation(libs.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ed11dfd..10537f9e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + @@ -55,6 +56,22 @@ + + + + + + + + + + diff --git a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt index d42ade47..71138554 100644 --- a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt +++ b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt @@ -1,7 +1,11 @@ package com.gamss.android.app import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build import android.util.Log +import androidx.core.content.getSystemService import com.gamss.android.domain.config.InitializeRemoteConfigUseCase import com.gamss.android.domain.safety.RefreshRiskLexiconUseCase import dagger.hilt.android.HiltAndroidApp @@ -32,7 +36,19 @@ class GamssApplication : Application() { override fun onCreate() { super.onCreate() + createNotificationChannel() applicationScope.launch { refreshRiskLexicon() } applicationScope.launch { initializeRemoteConfig() } } + + /** API 26+에서는 채널이 없으면 알림이 표시되지 않는다. 알림 발송 전에 미리 만들어 둔다. */ + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + getString(R.string.default_notification_channel_id), + getString(R.string.default_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ) + getSystemService()?.createNotificationChannel(channel) + } } diff --git a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt index 5b8c72db..5d8ca0dc 100644 --- a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt +++ b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt @@ -1,9 +1,16 @@ package com.gamss.android.app +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.content.ContextCompat +import com.gamss.android.app.main.MainViewModel import com.gamss.android.app.navigation.GamssRootNavHost import com.gamss.android.core.designsystem.theme.GamssTheme import dagger.hilt.android.AndroidEntryPoint @@ -11,13 +18,35 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val mainViewModel: MainViewModel by viewModels() + + private val requestNotificationPermission = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { mainViewModel.syncDeviceToken() } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + requestNotificationPermissionIfNeeded() setContent { GamssTheme { - GamssRootNavHost() + GamssRootNavHost(mainViewModel = mainViewModel) } } } + + override fun onStart() { + super.onStart() + mainViewModel.syncDeviceToken() + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val granted = ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (granted) return + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } } diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt index 651821c0..09953ac7 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt @@ -3,9 +3,11 @@ package com.gamss.android.app.main import androidx.lifecycle.ViewModel import com.gamss.android.domain.auth.ObserveSessionStateUseCase import com.gamss.android.domain.auth.RestoreSessionUseCase +import com.gamss.android.domain.auth.SessionState import com.gamss.android.domain.config.GetRemoteConfigFlagUseCase import com.gamss.android.domain.config.ObserveRemoteConfigReadyUseCase import com.gamss.android.domain.config.RemoteConfigKey +import com.gamss.android.domain.push.SyncDeviceTokenUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -23,6 +25,7 @@ class MainViewModel @Inject constructor( private val observeSessionStateUseCase: ObserveSessionStateUseCase, private val observeRemoteConfigReadyUseCase: ObserveRemoteConfigReadyUseCase, private val getRemoteConfigFlagUseCase: GetRemoteConfigFlagUseCase, + private val syncDeviceTokenUseCase: SyncDeviceTokenUseCase, ) : ViewModel(), ContainerHost { override val container = container(MainState()) @@ -41,9 +44,18 @@ class MainViewModel @Inject constructor( sessionState to useCardFeature }.collect { (sessionState, useCardFeature) -> reduce { state.copy(sessionState = sessionState, useCardFeature = useCardFeature) } + if (sessionState == SessionState.Authenticated) syncDeviceTokenUseCase() } } + /** + * 포그라운드 진입 시 호출한다. 로그인 직후는 [observeSessionState] 가 이미 처리하므로, + * 이 경로는 백그라운드에 있는 동안 OS 알림 권한이 바뀐 경우를 잡아낸다. + */ + fun syncDeviceToken() = intent { + if (state.sessionState == SessionState.Authenticated) syncDeviceTokenUseCase() + } + /** * 원격 설정이 늦어지면 콜드스타트를 붙잡지 않고 기본값으로 먼저 진행한다. * diff --git a/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt b/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt new file mode 100644 index 00000000..eaeb3665 --- /dev/null +++ b/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt @@ -0,0 +1,76 @@ +package com.gamss.android.app.push + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.gamss.android.app.MainActivity +import com.gamss.android.app.R +import com.gamss.android.domain.push.NotificationPermissionChecker +import com.gamss.android.domain.push.SyncDeviceTokenUseCase +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TAG = "GamssFcmService" + +@AndroidEntryPoint +class GamssFirebaseMessagingService : FirebaseMessagingService() { + + @Inject + lateinit var syncDeviceToken: SyncDeviceTokenUseCase + + @Inject + lateinit var notificationPermissionChecker: NotificationPermissionChecker + + private val serviceScope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + + CoroutineExceptionHandler { _, throwable -> + Log.w(TAG, "device token sync failed", throwable) + }, + ) + + override fun onNewToken(token: String) { + serviceScope.launch { syncDeviceToken() } + } + + override fun onMessageReceived(message: RemoteMessage) { + val title = message.notification?.title ?: message.data["title"] ?: return + val body = message.notification?.body ?: message.data["body"] + showNotification(title, body) + } + + // notificationPermissionChecker가 POST_NOTIFICATIONS를 이미 확인한다. lint는 모듈 경계를 + // 넘는 이 체크를 추적하지 못해 오탐(MissingPermission)을 낸다. + @SuppressLint("MissingPermission") + private fun showNotification(title: String, body: String?) { + if (!notificationPermissionChecker.isGranted()) return + + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).setFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP, + ), + PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(this, getString(R.string.default_notification_channel_id)) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .build() + + NotificationManagerCompat.from(this).notify(System.currentTimeMillis().toInt(), notification) + } +} diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..df083eab --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 774ac6e5..1ba34df8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,4 +5,7 @@ 보관함 대화 + + push_default + 알림 diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 4ebe32ea..1768a438 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(libs.firebase.auth) implementation(libs.firebase.firestore) implementation(libs.firebase.config) + implementation(libs.firebase.messaging) implementation(libs.kotlinx.coroutines.play.services) implementation(libs.androidx.paging.common) diff --git a/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt b/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt index 27091432..1709232d 100644 --- a/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt +++ b/data/src/main/java/com/gamss/android/data/di/FirebaseModule.kt @@ -5,6 +5,8 @@ import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.firestore +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.messaging import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.firebase.remoteconfig.remoteConfig import dagger.Module @@ -27,4 +29,8 @@ internal object FirebaseModule { @Provides @Singleton fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig = Firebase.remoteConfig + + @Provides + @Singleton + fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging } diff --git a/data/src/main/java/com/gamss/android/data/di/NetworkModule.kt b/data/src/main/java/com/gamss/android/data/di/NetworkModule.kt index c738019b..21c2efe1 100644 --- a/data/src/main/java/com/gamss/android/data/di/NetworkModule.kt +++ b/data/src/main/java/com/gamss/android/data/di/NetworkModule.kt @@ -8,6 +8,7 @@ import com.gamss.android.data.auth.TokenAuthenticator import com.gamss.android.data.auth.TokenInterceptor import com.gamss.android.data.remote.auth.AuthService import com.gamss.android.data.remote.gamssJson +import com.gamss.android.data.remote.push.DeviceTokenService import com.gamss.android.data.remote.user.UserService import dagger.Module import dagger.Provides @@ -116,6 +117,11 @@ internal object NetworkModule { fun provideUserService(retrofit: Retrofit): UserService = retrofit.create(UserService::class.java) + @Provides + @Singleton + fun provideDeviceTokenService(retrofit: Retrofit): DeviceTokenService = + retrofit.create(DeviceTokenService::class.java) + private fun String.redactTokenValues(): String = TOKEN_JSON_PATTERN.replace(this) { matchResult -> "${matchResult.groupValues[1]}${matchResult.groupValues[2]}" diff --git a/data/src/main/java/com/gamss/android/data/di/PushModule.kt b/data/src/main/java/com/gamss/android/data/di/PushModule.kt new file mode 100644 index 00000000..8a775689 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/di/PushModule.kt @@ -0,0 +1,25 @@ +package com.gamss.android.data.di + +import com.gamss.android.data.push.AndroidNotificationPermissionChecker +import com.gamss.android.data.push.FirebaseMessagingTokenProvider +import com.gamss.android.domain.push.NotificationPermissionChecker +import com.gamss.android.domain.push.PushTokenProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class PushModule { + + @Binds + abstract fun bindPushTokenProvider( + firebaseMessagingTokenProvider: FirebaseMessagingTokenProvider, + ): PushTokenProvider + + @Binds + abstract fun bindNotificationPermissionChecker( + androidNotificationPermissionChecker: AndroidNotificationPermissionChecker, + ): NotificationPermissionChecker +} diff --git a/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt index bf55a659..a4121ed4 100644 --- a/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt @@ -9,11 +9,13 @@ import com.gamss.android.data.local.auth.TokenProvider import com.gamss.android.data.local.auth.TokenProviderImpl import com.gamss.android.data.repository.CardRepositoryImpl import com.gamss.android.data.repository.ConversationRepositoryImpl +import com.gamss.android.data.repository.DeviceTokenRepositoryImpl import com.gamss.android.data.repository.TokenUsageRefreshNotifierImpl import com.gamss.android.data.repository.UserRepositoryImpl import com.gamss.android.domain.auth.AuthRepository import com.gamss.android.domain.card.CardRepository import com.gamss.android.domain.conversation.ConversationRepository +import com.gamss.android.domain.push.DeviceTokenRepository import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.user.UserRepository import dagger.Binds @@ -79,6 +81,11 @@ internal abstract class RepositoryModule { userRepositoryImpl: UserRepositoryImpl, ): UserRepository + @Binds + abstract fun bindDeviceTokenRepository( + deviceTokenRepositoryImpl: DeviceTokenRepositoryImpl, + ): DeviceTokenRepository + companion object { @Provides @Singleton diff --git a/data/src/main/java/com/gamss/android/data/push/AndroidNotificationPermissionChecker.kt b/data/src/main/java/com/gamss/android/data/push/AndroidNotificationPermissionChecker.kt new file mode 100644 index 00000000..65bb2f1c --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/push/AndroidNotificationPermissionChecker.kt @@ -0,0 +1,25 @@ +package com.gamss.android.data.push + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.gamss.android.domain.push.NotificationPermissionChecker +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class AndroidNotificationPermissionChecker @Inject constructor( + @ApplicationContext private val context: Context, +) : NotificationPermissionChecker { + + // POST_NOTIFICATIONS는 API 33부터 필요하다. 그 이전에는 권한 없이도 알림을 보낼 수 있다. + override fun isGranted(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/data/src/main/java/com/gamss/android/data/push/FirebaseMessagingTokenProvider.kt b/data/src/main/java/com/gamss/android/data/push/FirebaseMessagingTokenProvider.kt new file mode 100644 index 00000000..92c5e6ac --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/push/FirebaseMessagingTokenProvider.kt @@ -0,0 +1,25 @@ +package com.gamss.android.data.push + +import com.gamss.android.domain.push.PushTokenProvider +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +internal class FirebaseMessagingTokenProvider @Inject constructor( + private val firebaseMessagingProvider: Provider, +) : PushTokenProvider { + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override suspend fun getToken(): String? = + try { + firebaseMessagingProvider.get().token.await() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } +} diff --git a/data/src/main/java/com/gamss/android/data/remote/push/DeviceTokenService.kt b/data/src/main/java/com/gamss/android/data/remote/push/DeviceTokenService.kt new file mode 100644 index 00000000..a2e9b2a5 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/push/DeviceTokenService.kt @@ -0,0 +1,19 @@ +package com.gamss.android.data.remote.push + +import com.gamss.android.data.remote.model.response.ApiResponse +import com.gamss.android.data.remote.push.model.request.RegisterDeviceTokenRequest +import com.gamss.android.data.remote.push.model.request.UnregisterDeviceTokenRequest +import retrofit2.http.Body +import retrofit2.http.HTTP +import retrofit2.http.POST + +internal interface DeviceTokenService { + + @POST("/api/members/me/device-tokens") + suspend fun register(@Body request: RegisterDeviceTokenRequest): ApiResponse + + // 토큰이 접근 로그에 남지 않도록 쿼리 파라미터가 아닌 본문으로 보낸다. + // @DELETE는 본문을 지원하지 않아 hasBody를 명시하는 @HTTP를 쓴다. + @HTTP(method = "DELETE", path = "/api/members/me/device-tokens", hasBody = true) + suspend fun unregister(@Body request: UnregisterDeviceTokenRequest): ApiResponse +} diff --git a/data/src/main/java/com/gamss/android/data/remote/push/model/request/RegisterDeviceTokenRequest.kt b/data/src/main/java/com/gamss/android/data/remote/push/model/request/RegisterDeviceTokenRequest.kt new file mode 100644 index 00000000..37fbf948 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/push/model/request/RegisterDeviceTokenRequest.kt @@ -0,0 +1,8 @@ +package com.gamss.android.data.remote.push.model.request + +import kotlinx.serialization.Serializable + +@Serializable +internal data class RegisterDeviceTokenRequest( + val token: String, +) diff --git a/data/src/main/java/com/gamss/android/data/remote/push/model/request/UnregisterDeviceTokenRequest.kt b/data/src/main/java/com/gamss/android/data/remote/push/model/request/UnregisterDeviceTokenRequest.kt new file mode 100644 index 00000000..f93444da --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/push/model/request/UnregisterDeviceTokenRequest.kt @@ -0,0 +1,8 @@ +package com.gamss.android.data.remote.push.model.request + +import kotlinx.serialization.Serializable + +@Serializable +internal data class UnregisterDeviceTokenRequest( + val token: String, +) diff --git a/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt new file mode 100644 index 00000000..edd7d1b9 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt @@ -0,0 +1,24 @@ +package com.gamss.android.data.repository + +import com.gamss.android.core.common.AppResult +import com.gamss.android.data.remote.push.DeviceTokenService +import com.gamss.android.data.remote.push.model.request.RegisterDeviceTokenRequest +import com.gamss.android.data.remote.push.model.request.UnregisterDeviceTokenRequest +import com.gamss.android.data.remote.runCatchingApiCall +import com.gamss.android.domain.push.DeviceTokenRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DeviceTokenRepositoryImpl @Inject constructor( + private val deviceTokenService: DeviceTokenService, +) : DeviceTokenRepository { + + override suspend fun registerToken(token: String): AppResult = runCatchingApiCall { + deviceTokenService.register(RegisterDeviceTokenRequest(token = token)) + } + + override suspend fun unregisterToken(token: String): AppResult = runCatchingApiCall { + deviceTokenService.unregister(UnregisterDeviceTokenRequest(token = token)) + } +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt index e8c1efc8..ff782403 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt @@ -1,12 +1,18 @@ package com.gamss.android.domain.auth import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.push.UnregisterCurrentDeviceTokenUseCase import com.gamss.android.domain.usecase.NoParamUseCase import javax.inject.Inject class LogoutUseCase @Inject constructor( private val authRepository: AuthRepository, + private val unregisterCurrentDeviceToken: UnregisterCurrentDeviceTokenUseCase, ) : NoParamUseCase> { - override suspend fun invoke(): AppResult = authRepository.logout() + /** 토큰이 무효화되기 전에 먼저 해제해야 해제 API가 인증을 통과한다. */ + override suspend fun invoke(): AppResult { + unregisterCurrentDeviceToken() + return authRepository.logout() + } } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/DeviceTokenRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/DeviceTokenRepository.kt new file mode 100644 index 00000000..77700539 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/DeviceTokenRepository.kt @@ -0,0 +1,10 @@ +package com.gamss.android.domain.push + +import com.gamss.android.core.common.AppResult + +interface DeviceTokenRepository { + + suspend fun registerToken(token: String): AppResult + + suspend fun unregisterToken(token: String): AppResult +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/NotificationPermissionChecker.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/NotificationPermissionChecker.kt new file mode 100644 index 00000000..e2c2a83d --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/NotificationPermissionChecker.kt @@ -0,0 +1,6 @@ +package com.gamss.android.domain.push + +interface NotificationPermissionChecker { + + fun isGranted(): Boolean +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt new file mode 100644 index 00000000..41f73740 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt @@ -0,0 +1,7 @@ +package com.gamss.android.domain.push + +interface PushTokenProvider { + + /** SDK 조회가 실패하면 null. 예외를 던지지 않는다. */ + suspend fun getToken(): String? +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt new file mode 100644 index 00000000..3e8745d5 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt @@ -0,0 +1,26 @@ +package com.gamss.android.domain.push + +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +/** + * 현재 알림 권한 상태에 맞춰 디바이스 토큰을 등록하거나 해제한다. + * + * 앱 실행/포그라운드 진입마다 호출해도 안전하다 — 같은 토큰이면 서버에서 갱신만 된다. + * 권한이 꺼져 있는데 등록된 토큰을 그대로 두면 서버가 알림을 끈 사용자에게 계속 발송을 시도한다. + */ +class SyncDeviceTokenUseCase @Inject constructor( + private val pushTokenProvider: PushTokenProvider, + private val notificationPermissionChecker: NotificationPermissionChecker, + private val deviceTokenRepository: DeviceTokenRepository, +) : NoParamUseCase { + + override suspend fun invoke() { + val token = pushTokenProvider.getToken() ?: return + if (notificationPermissionChecker.isGranted()) { + deviceTokenRepository.registerToken(token) + } else { + deviceTokenRepository.unregisterToken(token) + } + } +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt new file mode 100644 index 00000000..3cc358a0 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt @@ -0,0 +1,21 @@ +package com.gamss.android.domain.push + +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +/** + * 로그아웃 시 이 기기로의 알림 발송을 멈추기 위해 호출한다. + * + * 해제 실패가 로그아웃을 막으면 안 되므로 결과를 확인하지 않는다 — 이미 해제된 토큰이어도 + * 서버는 200으로 응답하고, 실패해도 다음 [SyncDeviceTokenUseCase] 호출에서 다시 정리된다. + */ +class UnregisterCurrentDeviceTokenUseCase @Inject constructor( + private val pushTokenProvider: PushTokenProvider, + private val deviceTokenRepository: DeviceTokenRepository, +) : NoParamUseCase { + + override suspend fun invoke() { + val token = pushTokenProvider.getToken() ?: return + deviceTokenRepository.unregisterToken(token) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3b3c180..4f361368 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,6 +106,7 @@ firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } firebase-auth = { module = "com.google.firebase:firebase-auth" } firebase-firestore = { module = "com.google.firebase:firebase-firestore" } firebase-config = { module = "com.google.firebase:firebase-config" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging" } androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials" } From 092e5b3b4b9fb57fcf9c36c4f76b62b7b586baaa Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:30:21 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20FCM=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토큰 조회에 타임아웃을 걸어 로그아웃/세션 관찰이 멈추지 않게 하고, 알림 표시 실패가 크래시로 번지지 않도록 방어한다. MainActivity에 새던 권한 체크 중복을 제거하고 권한 요청·포그라운드 재동기화를 Compose 쪽으로 옮겨 기존 스타일과 맞추며, LogoutUseCase 등 새 도메인/데이터 로직에 대한 단위 테스트를 추가한다. --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 1 - .../com/gamss/android/app/GamssApplication.kt | 1 - .../com/gamss/android/app/MainActivity.kt | 31 +---- .../gamss/android/app/main/MainViewModel.kt | 16 ++- .../app/navigation/GamssRootNavHost.kt | 19 +++ .../app/push/GamssFirebaseMessagingService.kt | 64 ++++++---- app/src/main/res/drawable/ic_notification.xml | 1 - .../AndroidNotificationPermissionChecker.kt | 1 - .../push/FirebaseMessagingTokenProvider.kt | 9 +- .../data/remote/push/DeviceTokenService.kt | 2 - .../DeviceTokenRepositoryImplTest.kt | 95 +++++++++++++++ .../android/domain/auth/LogoutUseCase.kt | 1 - .../IsNotificationPermissionGrantedUseCase.kt | 11 ++ .../android/domain/push/PushTokenProvider.kt | 1 - .../domain/push/SyncDeviceTokenUseCase.kt | 6 - .../UnregisterCurrentDeviceTokenUseCase.kt | 6 - .../android/domain/auth/LogoutUseCaseTest.kt | 110 ++++++++++++++++++ .../android/domain/push/PushTestFakes.kt | 42 +++++++ .../domain/push/SyncDeviceTokenUseCaseTest.kt | 55 +++++++++ ...UnregisterCurrentDeviceTokenUseCaseTest.kt | 51 ++++++++ gradle/libs.versions.toml | 1 + 22 files changed, 446 insertions(+), 79 deletions(-) create mode 100644 data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/push/IsNotificationPermissionGrantedUseCase.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/auth/LogoutUseCaseTest.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/push/PushTestFakes.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCaseTest.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 101478ff..00497429 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,7 @@ dependencies { implementation(libs.compose.material.icons.core) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.hilt.navigation.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 10537f9e..c7e8ec91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,7 +65,6 @@ - diff --git a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt index 71138554..651fc1bc 100644 --- a/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt +++ b/app/src/main/kotlin/com/gamss/android/app/GamssApplication.kt @@ -41,7 +41,6 @@ class GamssApplication : Application() { applicationScope.launch { initializeRemoteConfig() } } - /** API 26+에서는 채널이 없으면 알림이 표시되지 않는다. 알림 발송 전에 미리 만들어 둔다. */ private fun createNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val channel = NotificationChannel( diff --git a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt index 5d8ca0dc..5b8c72db 100644 --- a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt +++ b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt @@ -1,16 +1,9 @@ package com.gamss.android.app -import android.Manifest -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import com.gamss.android.app.main.MainViewModel import com.gamss.android.app.navigation.GamssRootNavHost import com.gamss.android.core.designsystem.theme.GamssTheme import dagger.hilt.android.AndroidEntryPoint @@ -18,35 +11,13 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val mainViewModel: MainViewModel by viewModels() - - private val requestNotificationPermission = registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { mainViewModel.syncDeviceToken() } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - requestNotificationPermissionIfNeeded() setContent { GamssTheme { - GamssRootNavHost(mainViewModel = mainViewModel) + GamssRootNavHost() } } } - - override fun onStart() { - super.onStart() - mainViewModel.syncDeviceToken() - } - - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - val granted = ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - if (granted) return - requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - } } diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt index 09953ac7..a3f94929 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt @@ -7,6 +7,7 @@ import com.gamss.android.domain.auth.SessionState import com.gamss.android.domain.config.GetRemoteConfigFlagUseCase import com.gamss.android.domain.config.ObserveRemoteConfigReadyUseCase import com.gamss.android.domain.config.RemoteConfigKey +import com.gamss.android.domain.push.IsNotificationPermissionGrantedUseCase import com.gamss.android.domain.push.SyncDeviceTokenUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow @@ -26,6 +27,7 @@ class MainViewModel @Inject constructor( private val observeRemoteConfigReadyUseCase: ObserveRemoteConfigReadyUseCase, private val getRemoteConfigFlagUseCase: GetRemoteConfigFlagUseCase, private val syncDeviceTokenUseCase: SyncDeviceTokenUseCase, + private val isNotificationPermissionGrantedUseCase: IsNotificationPermissionGrantedUseCase, ) : ViewModel(), ContainerHost { override val container = container(MainState()) @@ -44,16 +46,18 @@ class MainViewModel @Inject constructor( sessionState to useCardFeature }.collect { (sessionState, useCardFeature) -> reduce { state.copy(sessionState = sessionState, useCardFeature = useCardFeature) } - if (sessionState == SessionState.Authenticated) syncDeviceTokenUseCase() + syncDeviceTokenIfAuthenticated(sessionState) } } - /** - * 포그라운드 진입 시 호출한다. 로그인 직후는 [observeSessionState] 가 이미 처리하므로, - * 이 경로는 백그라운드에 있는 동안 OS 알림 권한이 바뀐 경우를 잡아낸다. - */ fun syncDeviceToken() = intent { - if (state.sessionState == SessionState.Authenticated) syncDeviceTokenUseCase() + syncDeviceTokenIfAuthenticated(state.sessionState) + } + + suspend fun isNotificationPermissionGranted(): Boolean = isNotificationPermissionGrantedUseCase() + + private suspend fun syncDeviceTokenIfAuthenticated(sessionState: SessionState) { + if (sessionState == SessionState.Authenticated) syncDeviceTokenUseCase() } /** diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt index 059aaedc..53754153 100644 --- a/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/GamssRootNavHost.kt @@ -1,5 +1,8 @@ package com.gamss.android.app.navigation +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator @@ -10,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay @@ -27,6 +31,21 @@ fun GamssRootNavHost( ) { val state by mainViewModel.collectAsState() + val requestNotificationPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { mainViewModel.syncDeviceToken() } + + LaunchedEffect(Unit) { + if (!mainViewModel.isNotificationPermissionGranted()) { + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + LifecycleStartEffect(Unit) { + mainViewModel.syncDeviceToken() + onStopOrDispose { } + } + when (state.sessionState) { SessionState.Loading -> { Box( diff --git a/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt b/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt index eaeb3665..7352d851 100644 --- a/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt +++ b/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,34 +44,53 @@ class GamssFirebaseMessagingService : FirebaseMessagingService() { } override fun onMessageReceived(message: RemoteMessage) { - val title = message.notification?.title ?: message.data["title"] ?: return - val body = message.notification?.body ?: message.data["body"] - showNotification(title, body) + val content = message.toNotificationContentOrNull() ?: return + showNotification(content) } - // notificationPermissionChecker가 POST_NOTIFICATIONS를 이미 확인한다. lint는 모듈 경계를 - // 넘는 이 체크를 추적하지 못해 오탐(MissingPermission)을 낸다. + override fun onDestroy() { + serviceScope.cancel() + super.onDestroy() + } + + // 권한 확인이 모듈 경계 너머에 있어 lint 가 추적하지 못한다. @SuppressLint("MissingPermission") - private fun showNotification(title: String, body: String?) { + @Suppress("TooGenericExceptionCaught") + private fun showNotification(content: PushNotificationContent) { if (!notificationPermissionChecker.isGranted()) return - val contentIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java).setFlags( - Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP, - ), - PendingIntent.FLAG_IMMUTABLE, - ) + try { + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).setFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP, + ), + PendingIntent.FLAG_IMMUTABLE, + ) - val notification = NotificationCompat.Builder(this, getString(R.string.default_notification_channel_id)) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title) - .setContentText(body) - .setAutoCancel(true) - .setContentIntent(contentIntent) - .build() + val notification = NotificationCompat.Builder(this, getString(R.string.default_notification_channel_id)) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(content.title) + .setContentText(content.body) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .build() - NotificationManagerCompat.from(this).notify(System.currentTimeMillis().toInt(), notification) + NotificationManagerCompat.from(this).notify(System.currentTimeMillis().toInt(), notification) + } catch (e: Exception) { + Log.w(TAG, "failed to show push notification", e) + } } } + +internal data class PushNotificationContent( + val title: String, + val body: String?, +) + +internal fun RemoteMessage.toNotificationContentOrNull(): PushNotificationContent? { + val title = notification?.title ?: data["title"] ?: return null + val body = notification?.body ?: data["body"] + return PushNotificationContent(title, body) +} diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml index df083eab..dbd51e79 100644 --- a/app/src/main/res/drawable/ic_notification.xml +++ b/app/src/main/res/drawable/ic_notification.xml @@ -1,5 +1,4 @@ - - // 토큰이 접근 로그에 남지 않도록 쿼리 파라미터가 아닌 본문으로 보낸다. - // @DELETE는 본문을 지원하지 않아 hasBody를 명시하는 @HTTP를 쓴다. @HTTP(method = "DELETE", path = "/api/members/me/device-tokens", hasBody = true) suspend fun unregister(@Body request: UnregisterDeviceTokenRequest): ApiResponse } diff --git a/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt new file mode 100644 index 00000000..72c26236 --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt @@ -0,0 +1,95 @@ +package com.gamss.android.data.repository + +import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.network.ApiException +import com.gamss.android.data.remote.model.response.ApiResponse +import com.gamss.android.data.remote.push.DeviceTokenService +import com.gamss.android.data.remote.push.model.request.RegisterDeviceTokenRequest +import com.gamss.android.data.remote.push.model.request.UnregisterDeviceTokenRequest +import com.gamss.android.domain.auth.SessionExpiredException +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response + +class DeviceTokenRepositoryImplTest { + + private val deviceTokenService: DeviceTokenService = mockk() + private val repository = DeviceTokenRepositoryImpl(deviceTokenService) + + @Test + fun `토큰 등록에 성공하면 성공을 반환한다`() = runTest { + coEvery { + deviceTokenService.register(RegisterDeviceTokenRequest(token = "token-123")) + } returns ApiResponse(success = true) + + val result = repository.registerToken("token-123") + + assertTrue(result is AppResult.Success) + coVerify(exactly = 1) { + deviceTokenService.register(RegisterDeviceTokenRequest(token = "token-123")) + } + } + + @Test + fun `토큰 해제에 성공하면 성공을 반환한다`() = runTest { + coEvery { + deviceTokenService.unregister(UnregisterDeviceTokenRequest(token = "token-123")) + } returns ApiResponse(success = true) + + val result = repository.unregisterToken("token-123") + + assertTrue(result is AppResult.Success) + coVerify(exactly = 1) { + deviceTokenService.unregister(UnregisterDeviceTokenRequest(token = "token-123")) + } + } + + @Test + fun `401 응답은 세션 만료로 변환한다`() = runTest { + coEvery { deviceTokenService.register(any()) } throws httpException(UNAUTHORIZED) + + val result = repository.registerToken("token-123") + + assertTrue((result as AppResult.Failure).throwable is SessionExpiredException) + } + + @Test + fun `알 수 없는 토큰 오류는 HTTP 실패로 유지한다`() = runTest { + coEvery { deviceTokenService.register(any()) } throws httpException("INVALID_DEVICE_TOKEN") + + val result = repository.registerToken("x".repeat(600)) + + val throwable = (result as AppResult.Failure).throwable + assertTrue(throwable is ApiException.Http) + assertEquals("INVALID_DEVICE_TOKEN", (throwable as ApiException.Http).code) + } + + @Test(expected = CancellationException::class) + fun `등록 취소는 실패로 변환하지 않고 전파한다`() = runTest { + coEvery { deviceTokenService.register(any()) } throws CancellationException() + + repository.registerToken("token-123") + } + + private fun httpException(code: String): HttpException { + val errorBody = """{"success":false,"error":{"code":"$code","message":"failed"}}""" + .toResponseBody("application/json".toMediaType()) + return HttpException( + Response.error>(BAD_REQUEST, errorBody), + ) + } + + private companion object { + const val BAD_REQUEST = 400 + const val UNAUTHORIZED = 401 + } +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt index ff782403..ddb59d86 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/auth/LogoutUseCase.kt @@ -10,7 +10,6 @@ class LogoutUseCase @Inject constructor( private val unregisterCurrentDeviceToken: UnregisterCurrentDeviceTokenUseCase, ) : NoParamUseCase> { - /** 토큰이 무효화되기 전에 먼저 해제해야 해제 API가 인증을 통과한다. */ override suspend fun invoke(): AppResult { unregisterCurrentDeviceToken() return authRepository.logout() diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/IsNotificationPermissionGrantedUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/IsNotificationPermissionGrantedUseCase.kt new file mode 100644 index 00000000..cd2b0ac1 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/IsNotificationPermissionGrantedUseCase.kt @@ -0,0 +1,11 @@ +package com.gamss.android.domain.push + +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +class IsNotificationPermissionGrantedUseCase @Inject constructor( + private val notificationPermissionChecker: NotificationPermissionChecker, +) : NoParamUseCase { + + override suspend fun invoke(): Boolean = notificationPermissionChecker.isGranted() +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt index 41f73740..81b54e9f 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt @@ -2,6 +2,5 @@ package com.gamss.android.domain.push interface PushTokenProvider { - /** SDK 조회가 실패하면 null. 예외를 던지지 않는다. */ suspend fun getToken(): String? } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt index 3e8745d5..f8cc1eef 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt @@ -3,12 +3,6 @@ package com.gamss.android.domain.push import com.gamss.android.domain.usecase.NoParamUseCase import javax.inject.Inject -/** - * 현재 알림 권한 상태에 맞춰 디바이스 토큰을 등록하거나 해제한다. - * - * 앱 실행/포그라운드 진입마다 호출해도 안전하다 — 같은 토큰이면 서버에서 갱신만 된다. - * 권한이 꺼져 있는데 등록된 토큰을 그대로 두면 서버가 알림을 끈 사용자에게 계속 발송을 시도한다. - */ class SyncDeviceTokenUseCase @Inject constructor( private val pushTokenProvider: PushTokenProvider, private val notificationPermissionChecker: NotificationPermissionChecker, diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt index 3cc358a0..20964fd3 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt @@ -3,12 +3,6 @@ package com.gamss.android.domain.push import com.gamss.android.domain.usecase.NoParamUseCase import javax.inject.Inject -/** - * 로그아웃 시 이 기기로의 알림 발송을 멈추기 위해 호출한다. - * - * 해제 실패가 로그아웃을 막으면 안 되므로 결과를 확인하지 않는다 — 이미 해제된 토큰이어도 - * 서버는 200으로 응답하고, 실패해도 다음 [SyncDeviceTokenUseCase] 호출에서 다시 정리된다. - */ class UnregisterCurrentDeviceTokenUseCase @Inject constructor( private val pushTokenProvider: PushTokenProvider, private val deviceTokenRepository: DeviceTokenRepository, diff --git a/domain/src/test/kotlin/com/gamss/android/domain/auth/LogoutUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/auth/LogoutUseCaseTest.kt new file mode 100644 index 00000000..0270df41 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/auth/LogoutUseCaseTest.kt @@ -0,0 +1,110 @@ +package com.gamss.android.domain.auth + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.push.FakeDeviceTokenRepository +import com.gamss.android.domain.push.FakePushTokenProvider +import com.gamss.android.domain.push.UnregisterCurrentDeviceTokenUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogoutUseCaseTest { + + @Test + fun `로그아웃 전에 디바이스 토큰을 먼저 해제한다`() = runBlocking { + val callOrder = mutableListOf() + val authRepository = FakeAuthRepository(callOrder = callOrder) + val deviceTokenRepository = FakeCallOrderDeviceTokenRepository(callOrder = callOrder) + val useCase = LogoutUseCase( + authRepository = authRepository, + unregisterCurrentDeviceToken = UnregisterCurrentDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider("token-123"), + deviceTokenRepository = deviceTokenRepository, + ), + ) + + val result = useCase() + + assertTrue(result is AppResult.Success) + assertEquals(listOf("unregisterToken", "logout"), callOrder) + } + + @Test + fun `토큰 해제가 실패해도 로그아웃은 진행한다`() = runBlocking { + val authRepository = FakeAuthRepository() + val deviceTokenRepository = FakeDeviceTokenRepository( + unregisterResult = AppResult.Failure(IllegalStateException("unregister failed")), + ) + val useCase = LogoutUseCase( + authRepository = authRepository, + unregisterCurrentDeviceToken = UnregisterCurrentDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider("token-123"), + deviceTokenRepository = deviceTokenRepository, + ), + ) + + val result = useCase() + + assertTrue(result is AppResult.Success) + assertEquals(1, authRepository.logoutCallCount) + } + + @Test + fun `토큰이 없으면 해제 없이 로그아웃한다`() = runBlocking { + val authRepository = FakeAuthRepository() + val deviceTokenRepository = FakeDeviceTokenRepository() + val useCase = LogoutUseCase( + authRepository = authRepository, + unregisterCurrentDeviceToken = UnregisterCurrentDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider(null), + deviceTokenRepository = deviceTokenRepository, + ), + ) + + useCase() + + assertEquals(0, deviceTokenRepository.unregisterCallCount) + assertEquals(1, authRepository.logoutCallCount) + } + + private class FakeCallOrderDeviceTokenRepository( + private val callOrder: MutableList, + ) : com.gamss.android.domain.push.DeviceTokenRepository { + override suspend fun registerToken(token: String): AppResult = + error("Not needed for this test") + + override suspend fun unregisterToken(token: String): AppResult { + callOrder += "unregisterToken" + return AppResult.Success(Unit) + } + } + + private class FakeAuthRepository( + private val logoutResult: AppResult = AppResult.Success(Unit), + private val callOrder: MutableList = mutableListOf(), + ) : AuthRepository { + override val sessionState: StateFlow = + MutableStateFlow(SessionState.Authenticated) + + var logoutCallCount: Int = 0 + private set + + override suspend fun login(googleIdToken: String): AppResult = + error("Not needed for this test") + + override suspend fun reissueTokens(): AppResult = + error("Not needed for this test") + + override suspend fun restoreSession(): AppResult = + error("Not needed for this test") + + override suspend fun logout(): AppResult { + callOrder += "logout" + logoutCallCount++ + return logoutResult + } + } +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/push/PushTestFakes.kt b/domain/src/test/kotlin/com/gamss/android/domain/push/PushTestFakes.kt new file mode 100644 index 00000000..e74d9a74 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/push/PushTestFakes.kt @@ -0,0 +1,42 @@ +package com.gamss.android.domain.push + +import com.gamss.android.core.common.AppResult + +internal class FakePushTokenProvider( + private val token: String? = "token", +) : PushTokenProvider { + override suspend fun getToken(): String? = token +} + +internal class FakeNotificationPermissionChecker( + private val granted: Boolean = true, +) : NotificationPermissionChecker { + override fun isGranted(): Boolean = granted +} + +internal class FakeDeviceTokenRepository( + private val registerResult: AppResult = AppResult.Success(Unit), + private val unregisterResult: AppResult = AppResult.Success(Unit), +) : DeviceTokenRepository { + + var registerCallCount: Int = 0 + private set + var unregisterCallCount: Int = 0 + private set + var lastRegisteredToken: String? = null + private set + var lastUnregisteredToken: String? = null + private set + + override suspend fun registerToken(token: String): AppResult { + registerCallCount++ + lastRegisteredToken = token + return registerResult + } + + override suspend fun unregisterToken(token: String): AppResult { + unregisterCallCount++ + lastUnregisteredToken = token + return unregisterResult + } +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCaseTest.kt new file mode 100644 index 00000000..5ace97d3 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCaseTest.kt @@ -0,0 +1,55 @@ +package com.gamss.android.domain.push + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class SyncDeviceTokenUseCaseTest { + + @Test + fun `권한이 있으면 토큰을 등록한다`() = runBlocking { + val deviceTokenRepository = FakeDeviceTokenRepository() + val useCase = SyncDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider("token-123"), + notificationPermissionChecker = FakeNotificationPermissionChecker(granted = true), + deviceTokenRepository = deviceTokenRepository, + ) + + useCase() + + assertEquals(1, deviceTokenRepository.registerCallCount) + assertEquals("token-123", deviceTokenRepository.lastRegisteredToken) + assertEquals(0, deviceTokenRepository.unregisterCallCount) + } + + @Test + fun `권한이 없으면 토큰을 해제한다`() = runBlocking { + val deviceTokenRepository = FakeDeviceTokenRepository() + val useCase = SyncDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider("token-123"), + notificationPermissionChecker = FakeNotificationPermissionChecker(granted = false), + deviceTokenRepository = deviceTokenRepository, + ) + + useCase() + + assertEquals(1, deviceTokenRepository.unregisterCallCount) + assertEquals("token-123", deviceTokenRepository.lastUnregisteredToken) + assertEquals(0, deviceTokenRepository.registerCallCount) + } + + @Test + fun `토큰을 가져올 수 없으면 아무 것도 하지 않는다`() = runBlocking { + val deviceTokenRepository = FakeDeviceTokenRepository() + val useCase = SyncDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider(null), + notificationPermissionChecker = FakeNotificationPermissionChecker(granted = true), + deviceTokenRepository = deviceTokenRepository, + ) + + useCase() + + assertEquals(0, deviceTokenRepository.registerCallCount) + assertEquals(0, deviceTokenRepository.unregisterCallCount) + } +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCaseTest.kt new file mode 100644 index 00000000..55e466ae --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCaseTest.kt @@ -0,0 +1,51 @@ +package com.gamss.android.domain.push + +import com.gamss.android.core.common.AppResult +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class UnregisterCurrentDeviceTokenUseCaseTest { + + @Test + fun `현재 토큰을 해제한다`() = runBlocking { + val deviceTokenRepository = FakeDeviceTokenRepository() + val useCase = UnregisterCurrentDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider("token-123"), + deviceTokenRepository = deviceTokenRepository, + ) + + useCase() + + assertEquals(1, deviceTokenRepository.unregisterCallCount) + assertEquals("token-123", deviceTokenRepository.lastUnregisteredToken) + } + + @Test + fun `토큰이 없으면 해제를 호출하지 않는다`() = runBlocking { + val deviceTokenRepository = FakeDeviceTokenRepository() + val useCase = UnregisterCurrentDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider(null), + deviceTokenRepository = deviceTokenRepository, + ) + + useCase() + + assertEquals(0, deviceTokenRepository.unregisterCallCount) + } + + @Test + fun `해제가 실패해도 예외를 던지지 않는다`() = runBlocking { + val deviceTokenRepository = FakeDeviceTokenRepository( + unregisterResult = AppResult.Failure(IllegalStateException("unregister failed")), + ) + val useCase = UnregisterCurrentDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider("token-123"), + deviceTokenRepository = deviceTokenRepository, + ) + + useCase() + + assertEquals(1, deviceTokenRepository.unregisterCallCount) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f361368..7c2ba9a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ paging = "3.4.2" androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3-android", version.ref = "lifecycle-viewmodel-navigation3" } From 28b07caa1e7a09b2907d05eb9943b90ffdf95594 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:22:08 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20FCM=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EA=B2=BD=EB=A1=9C=20=EC=8B=A4=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteMessage의 data 페이로드 매핑과, 서비스와 동일한 채널·아이콘·인텐트 구성으로 실제 시스템 알림이 게시되는지를 실기기 계측 테스트로 확인한다. --- ...irebaseMessagingServiceInstrumentedTest.kt | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt diff --git a/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt b/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt new file mode 100644 index 00000000..91a47f34 --- /dev/null +++ b/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt @@ -0,0 +1,87 @@ +package com.gamss.android.app.push + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.gamss.android.app.MainActivity +import com.gamss.android.app.R +import com.google.firebase.messaging.RemoteMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GamssFirebaseMessagingServiceInstrumentedTest { + + @Test + fun data_페이로드에서_제목과_본문을_매핑한다() { + val message = RemoteMessage.Builder("dummy@fcm.googleapis.com") + .addData("title", "데이터 제목") + .addData("body", "데이터 본문") + .build() + + val content = message.toNotificationContentOrNull() + + assertEquals("데이터 제목", content?.title) + assertEquals("데이터 본문", content?.body) + } + + @Test + fun title이_전혀_없으면_null이다() { + val message = RemoteMessage.Builder("dummy@fcm.googleapis.com").build() + + assertNull(message.toNotificationContentOrNull()) + } + + /** + * GamssFirebaseMessagingService.showNotification()과 동일한 채널 id·아이콘·인텐트 구성으로 + * 실제 알림을 올려, 매니페스트의 채널/아이콘 리소스 설정이 이 기기의 OS에서 유효한지 확인한다. + */ + @Test + fun 서비스와_동일한_구성으로_알림을_올리면_시스템에_표시된다() { + val context = ApplicationProvider.getApplicationContext() + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.activeNotifications.forEach { notificationManager.cancel(it.id) } + + val contentIntent = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java).setFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP, + ), + PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(context, context.getString(R.string.default_notification_channel_id)) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("온디바이스_검증_알림") + .setContentText("instrumented test 로 보낸 메시지") + .setAutoCancel(true) + .setContentIntent(contentIntent) + .build() + + val id = System.currentTimeMillis().toInt() + NotificationManagerCompat.from(context).notify(id, notification) + + val posted = waitUntil(timeoutMillis = 3_000) { + notificationManager.activeNotifications.any { it.id == id } + } + + assertTrue("알림이 표시되지 않았습니다", posted) + } + + private fun waitUntil(timeoutMillis: Long, condition: () -> Boolean): Boolean { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + if (condition()) return true + Thread.sleep(200) + } + return condition() + } +} From 6b81646cd26e593e328cf0ec569663026546546b Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:04:00 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=EC=9D=84=20GrantPermissionRule=EB=A1=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B6=80=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새로 설치할 때마다 POST_NOTIFICATIONS가 초기화되는 문제로 신규 설치 환경에서 알림 표시 테스트가 실패할 수 있었다. 테스트가 실행 전에 직접 권한을 부여하도록 해 수동 조작 없이도 재현 가능하게 만든다. --- app/build.gradle.kts | 1 + .../GamssFirebaseMessagingServiceInstrumentedTest.kt | 12 ++++++++++++ gradle/libs.versions.toml | 1 + 3 files changed, 14 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00497429..50fc20e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -124,4 +124,5 @@ dependencies { androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) } diff --git a/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt b/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt index 91a47f34..a08960a1 100644 --- a/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt @@ -1,25 +1,37 @@ package com.gamss.android.app.push +import android.Manifest import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule import com.gamss.android.app.MainActivity import com.gamss.android.app.R import com.google.firebase.messaging.RemoteMessage import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class GamssFirebaseMessagingServiceInstrumentedTest { + /** API 33 미만은 이 권한 자체가 없어 GrantPermissionRule이 조용히 no-op 처리한다. */ + @get:Rule + val notificationPermissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + @Test fun data_페이로드에서_제목과_본문을_매핑한다() { val message = RemoteMessage.Builder("dummy@fcm.googleapis.com") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c2ba9a0..b395eb11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -114,6 +114,7 @@ androidx-credentials-play-services-auth = { module = "androidx.credentials:crede androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-runner" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } From b8dae3a59a3d8e30a7076202555ea12974fad9c6 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:37:21 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=A4=91=EB=B3=B5=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B4=89=ED=88=AC=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션 인증과 포그라운드 진입이 거의 동시에 동기화를 트리거해 콜드스타트마다 같은 요청이 두 번 나가던 것을 이미 반영된 상태는 건너뛰도록 정리한다. 실패는 기억하지 않아 첫 요청이 401을 받아도 다음 트리거가 복구한다. 응답 본문을 쓰지 않아 200 + success:false 가 성공으로 보고되던 것도 함께 고친다. --- .../repository/DeviceTokenRepositoryImpl.kt | 42 +++++++++++-- .../DeviceTokenRepositoryImplTest.kt | 63 +++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt index edd7d1b9..0a38c57b 100644 --- a/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt @@ -1,24 +1,56 @@ package com.gamss.android.data.repository import com.gamss.android.core.common.AppResult +import com.gamss.android.data.remote.model.response.ApiResponse import com.gamss.android.data.remote.push.DeviceTokenService import com.gamss.android.data.remote.push.model.request.RegisterDeviceTokenRequest import com.gamss.android.data.remote.push.model.request.UnregisterDeviceTokenRequest import com.gamss.android.data.remote.runCatchingApiCall +import com.gamss.android.data.remote.throwIfFailed import com.gamss.android.domain.push.DeviceTokenRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton +/** + * 세션 인증과 포그라운드 진입이 거의 동시에 동기화를 트리거해 콜드스타트마다 같은 요청이 두 번 + * 나간다. 서버가 멱등이라 깨지지는 않지만 불필요한 호출이라 이미 반영된 상태는 건너뛴다. + * + * 실패는 기억하지 않는다. 액세스 토큰 재발급 전에 나간 첫 요청이 401을 받는 경우가 있어, + * 실패를 반영된 상태로 남기면 다음 트리거가 복구하지 못한다. + */ @Singleton internal class DeviceTokenRepositoryImpl @Inject constructor( private val deviceTokenService: DeviceTokenService, ) : DeviceTokenRepository { - override suspend fun registerToken(token: String): AppResult = runCatchingApiCall { - deviceTokenService.register(RegisterDeviceTokenRequest(token = token)) - } + private val mutex = Mutex() + + @Volatile + private var syncedState: SyncedState? = null + + override suspend fun registerToken(token: String): AppResult = + syncOnce(SyncedState(token = token, registered = true)) { + deviceTokenService.register(RegisterDeviceTokenRequest(token = token)) + } - override suspend fun unregisterToken(token: String): AppResult = runCatchingApiCall { - deviceTokenService.unregister(UnregisterDeviceTokenRequest(token = token)) + override suspend fun unregisterToken(token: String): AppResult = + syncOnce(SyncedState(token = token, registered = false)) { + deviceTokenService.unregister(UnregisterDeviceTokenRequest(token = token)) + } + + private suspend fun syncOnce( + target: SyncedState, + call: suspend () -> ApiResponse, + ): AppResult { + mutex.withLock { + if (syncedState == target) return AppResult.Success(Unit) + val result = runCatchingApiCall { call().throwIfFailed() } + if (result is AppResult.Success) syncedState = target + return result + } } + + private data class SyncedState(val token: String, val registered: Boolean) } diff --git a/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt index 72c26236..f629eb50 100644 --- a/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt @@ -2,6 +2,7 @@ package com.gamss.android.data.repository import com.gamss.android.core.common.AppResult import com.gamss.android.core.common.network.ApiException +import com.gamss.android.data.remote.model.response.ApiError import com.gamss.android.data.remote.model.response.ApiResponse import com.gamss.android.data.remote.push.DeviceTokenService import com.gamss.android.data.remote.push.model.request.RegisterDeviceTokenRequest @@ -53,6 +54,68 @@ class DeviceTokenRepositoryImplTest { } } + @Test + fun `같은 상태로 다시 동기화하면 서버를 다시 호출하지 않는다`() = runTest { + coEvery { deviceTokenService.register(any()) } returns ApiResponse(success = true) + + repository.registerToken("token-123") + repository.registerToken("token-123") + + coVerify(exactly = 1) { deviceTokenService.register(any()) } + } + + @Test + fun `등록이 실패하면 다음 동기화에서 다시 호출한다`() = runTest { + coEvery { deviceTokenService.register(any()) } throws httpException(UNAUTHORIZED) + + repository.registerToken("token-123") + + coEvery { deviceTokenService.register(any()) } returns ApiResponse(success = true) + val retried = repository.registerToken("token-123") + + assertTrue(retried is AppResult.Success) + coVerify(exactly = 2) { deviceTokenService.register(any()) } + } + + @Test + fun `등록 뒤 해제는 건너뛰지 않는다`() = runTest { + coEvery { deviceTokenService.register(any()) } returns ApiResponse(success = true) + coEvery { deviceTokenService.unregister(any()) } returns ApiResponse(success = true) + + repository.registerToken("token-123") + repository.unregisterToken("token-123") + + coVerify(exactly = 1) { deviceTokenService.unregister(any()) } + } + + @Test + fun `토큰이 바뀌면 다시 등록한다`() = runTest { + coEvery { deviceTokenService.register(any()) } returns ApiResponse(success = true) + + repository.registerToken("token-old") + repository.registerToken("token-new") + + coVerify(exactly = 1) { deviceTokenService.register(RegisterDeviceTokenRequest("token-old")) } + coVerify(exactly = 1) { deviceTokenService.register(RegisterDeviceTokenRequest("token-new")) } + } + + @Test + fun `200 응답이라도 success가 false면 실패로 돌려준다`() = runTest { + coEvery { deviceTokenService.register(any()) } returns ApiResponse( + success = false, + error = ApiError( + code = "INVALID_DEVICE_TOKEN", + message = "too long", + ), + ) + + val result = repository.registerToken("token-123") + + val throwable = (result as AppResult.Failure).throwable + assertTrue(throwable is ApiException.Http) + assertEquals("INVALID_DEVICE_TOKEN", (throwable as ApiException.Http).code) + } + @Test fun `401 응답은 세션 만료로 변환한다`() = runTest { coEvery { deviceTokenService.register(any()) } throws httpException(UNAUTHORIZED) From fa776264f7ec7edb8b57f359c805fe5d8288feb8 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:39:44 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=ED=95=B4=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃과 달리 탈퇴 경로에는 토큰 해제가 없어 서버가 무효화하지 않으면 삭제된 계정의 토큰이 남는다. 탈퇴가 끝나면 인증이 무효해져 해제 요청을 보낼 수 없으므로 탈퇴 요청 전에 해제한다. --- .../domain/user/DeleteUserAccountUseCase.kt | 10 ++ .../user/DeleteUserAccountUseCaseTest.kt | 99 ++++++++++++++++--- 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCase.kt index 687d18a0..31f769e2 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCase.kt @@ -2,14 +2,24 @@ package com.gamss.android.domain.user import com.gamss.android.core.common.AppResult import com.gamss.android.domain.auth.AuthRepository +import com.gamss.android.domain.push.UnregisterCurrentDeviceTokenUseCase import com.gamss.android.domain.usecase.NoParamUseCase import javax.inject.Inject class DeleteUserAccountUseCase @Inject constructor( private val userRepository: UserRepository, private val authRepository: AuthRepository, + private val unregisterCurrentDeviceToken: UnregisterCurrentDeviceTokenUseCase, ) : NoParamUseCase> { + + /** + * 탈퇴가 끝나면 인증이 무효해져 해제 요청을 보낼 수 없으므로 토큰을 먼저 해제한다. + * + * 탈퇴가 실패하면 세션은 그대로 남지만 토큰은 해제된 상태가 된다. 이 경우 포그라운드 복귀나 + * 세션 변화 때 도는 동기화가 다시 등록하므로 복구를 따로 처리하지 않는다. + */ override suspend fun invoke(): AppResult { + unregisterCurrentDeviceToken() val deleteResult = userRepository.deleteUserAccount() if (deleteResult is AppResult.Failure) return deleteResult authRepository.logout() diff --git a/domain/src/test/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCaseTest.kt index 22622537..dd63da19 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/user/DeleteUserAccountUseCaseTest.kt @@ -5,6 +5,10 @@ import com.gamss.android.domain.auth.AuthRepository import com.gamss.android.domain.auth.LoginResult import com.gamss.android.domain.auth.SessionState import com.gamss.android.domain.model.DailyTokenUsage +import com.gamss.android.domain.push.DeviceTokenRepository +import com.gamss.android.domain.push.FakeDeviceTokenRepository +import com.gamss.android.domain.push.FakePushTokenProvider +import com.gamss.android.domain.push.UnregisterCurrentDeviceTokenUseCase import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,27 +21,66 @@ import org.junit.Test class DeleteUserAccountUseCaseTest { @Test - fun `회원 탈퇴 성공 후 로그아웃을 호출한다`() = runBlocking { + fun `탈퇴 전에 디바이스 토큰을 먼저 해제하고 탈퇴 후 로그아웃한다`() = runBlocking { val callOrder = mutableListOf() val userRepository = FakeUserRepository(callOrder = callOrder) val authRepository = FakeAuthRepository(callOrder = callOrder) - val useCase = DeleteUserAccountUseCase(userRepository, authRepository) + val useCase = createUseCase( + userRepository = userRepository, + authRepository = authRepository, + deviceTokenRepository = FakeCallOrderDeviceTokenRepository(callOrder = callOrder), + ) + + val result = useCase() + + assertTrue(result is AppResult.Success) + assertEquals(1, authRepository.logoutCallCount) + assertEquals(listOf("unregisterToken", "deleteUserAccount", "logout"), callOrder) + } + + @Test + fun `토큰 해제가 실패해도 탈퇴는 진행한다`() = runBlocking { + val authRepository = FakeAuthRepository() + val useCase = createUseCase( + authRepository = authRepository, + deviceTokenRepository = FakeDeviceTokenRepository( + unregisterResult = AppResult.Failure(IllegalStateException("unregister failed")), + ), + ) + + val result = useCase() + + assertTrue(result is AppResult.Success) + assertEquals(1, authRepository.logoutCallCount) + } + + @Test + fun `푸시 토큰이 없으면 해제 없이 탈퇴한다`() = runBlocking { + val deviceTokenRepository = FakeDeviceTokenRepository() + val authRepository = FakeAuthRepository() + val useCase = createUseCase( + authRepository = authRepository, + deviceTokenRepository = deviceTokenRepository, + pushToken = null, + ) val result = useCase() assertTrue(result is AppResult.Success) + assertEquals(0, deviceTokenRepository.unregisterCallCount) assertEquals(1, authRepository.logoutCallCount) - assertEquals(listOf("deleteUserAccount", "logout"), callOrder) } @Test fun `회원 탈퇴 실패 시 로그아웃하지 않고 탈퇴 실패를 반환한다`() = runBlocking { val failure = IllegalStateException("deleteUserAccount failed") - val userRepository = FakeUserRepository( - deleteUserAccountResult = AppResult.Failure(failure), - ) val authRepository = FakeAuthRepository() - val useCase = DeleteUserAccountUseCase(userRepository, authRepository) + val useCase = createUseCase( + userRepository = FakeUserRepository( + deleteUserAccountResult = AppResult.Failure(failure), + ), + authRepository = authRepository, + ) val result = useCase() @@ -48,11 +91,8 @@ class DeleteUserAccountUseCaseTest { @Test fun `회원 탈퇴 성공 후 로그아웃 실패해도 탈퇴 성공을 반환한다`() = runBlocking { val failure = IllegalStateException("logout failed") - val userRepository = FakeUserRepository() - val authRepository = FakeAuthRepository( - logoutResult = AppResult.Failure(failure), - ) - val useCase = DeleteUserAccountUseCase(userRepository, authRepository) + val authRepository = FakeAuthRepository(logoutResult = AppResult.Failure(failure)) + val useCase = createUseCase(authRepository = authRepository) val result = useCase() @@ -62,15 +102,42 @@ class DeleteUserAccountUseCaseTest { @Test(expected = CancellationException::class) fun `회원 탈퇴 취소는 실패로 변환하지 않고 전파한다`() = runBlocking { - val userRepository = FakeUserRepository( - deleteUserAccountFailure = CancellationException(), + val useCase = createUseCase( + userRepository = FakeUserRepository( + deleteUserAccountFailure = CancellationException(), + ), ) - val authRepository = FakeAuthRepository() - DeleteUserAccountUseCase(userRepository, authRepository)() + useCase() Unit } + private fun createUseCase( + userRepository: UserRepository = FakeUserRepository(), + authRepository: AuthRepository = FakeAuthRepository(), + deviceTokenRepository: DeviceTokenRepository = FakeDeviceTokenRepository(), + pushToken: String? = "token-123", + ) = DeleteUserAccountUseCase( + userRepository = userRepository, + authRepository = authRepository, + unregisterCurrentDeviceToken = UnregisterCurrentDeviceTokenUseCase( + pushTokenProvider = FakePushTokenProvider(pushToken), + deviceTokenRepository = deviceTokenRepository, + ), + ) + + private class FakeCallOrderDeviceTokenRepository( + private val callOrder: MutableList, + ) : DeviceTokenRepository { + override suspend fun registerToken(token: String): AppResult = + error("Not needed for this test") + + override suspend fun unregisterToken(token: String): AppResult { + callOrder += "unregisterToken" + return AppResult.Success(Unit) + } + } + private class FakeUserRepository( private val deleteUserAccountResult: AppResult = AppResult.Success(Unit), private val deleteUserAccountFailure: Throwable? = null,