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,