diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef5b6ece..3fcf9b2e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,6 +112,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) @@ -129,10 +130,14 @@ 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) 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 new file mode 100644 index 00000000..a08960a1 --- /dev/null +++ b/app/src/androidTest/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingServiceInstrumentedTest.kt @@ -0,0 +1,99 @@ +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") + .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() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 286c3b05..face70ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -57,6 +57,21 @@ + + + + + + + + + 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..651fc1bc 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,18 @@ class GamssApplication : Application() { override fun onCreate() { super.onCreate() + createNotificationChannel() applicationScope.launch { refreshRiskLexicon() } applicationScope.launch { initializeRemoteConfig() } } + + 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/main/MainViewModel.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainViewModel.kt index 651821c0..8776349a 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,13 @@ 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.MarkNotificationPermissionPromptedUseCase +import com.gamss.android.domain.push.ShouldPromptNotificationPermissionUseCase +import com.gamss.android.domain.push.SyncDeviceTokenUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -18,11 +22,15 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @HiltViewModel +@Suppress("LongParameterList") class MainViewModel @Inject constructor( private val restoreSessionUseCase: RestoreSessionUseCase, private val observeSessionStateUseCase: ObserveSessionStateUseCase, private val observeRemoteConfigReadyUseCase: ObserveRemoteConfigReadyUseCase, private val getRemoteConfigFlagUseCase: GetRemoteConfigFlagUseCase, + private val syncDeviceTokenUseCase: SyncDeviceTokenUseCase, + private val shouldPromptNotificationPermissionUseCase: ShouldPromptNotificationPermissionUseCase, + private val markNotificationPermissionPromptedUseCase: MarkNotificationPermissionPromptedUseCase, ) : ViewModel(), ContainerHost { override val container = container(MainState()) @@ -41,9 +49,24 @@ class MainViewModel @Inject constructor( sessionState to useCardFeature }.collect { (sessionState, useCardFeature) -> reduce { state.copy(sessionState = sessionState, useCardFeature = useCardFeature) } + syncDeviceTokenIfAuthenticated(sessionState) } } + fun syncDeviceToken() = intent { + syncDeviceTokenIfAuthenticated(state.sessionState) + } + + suspend fun shouldPromptNotificationPermission(): Boolean = shouldPromptNotificationPermissionUseCase() + + fun markNotificationPermissionPrompted() = intent { + markNotificationPermissionPromptedUseCase() + } + + 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 d4dd7018..68937c7b 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 @@ -14,10 +14,14 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack @@ -38,6 +42,11 @@ fun GamssRootNavHost( ) { val state by mainViewModel.collectAsState() + LifecycleStartEffect(Unit) { + mainViewModel.syncDeviceToken() + onStopOrDispose { } + } + when (state.sessionState) { SessionState.Loading -> { Box( @@ -51,6 +60,9 @@ fun GamssRootNavHost( else -> RootNavDisplay( sessionState = state.sessionState, useCardFeature = state.useCardFeature, + shouldPromptNotificationPermission = mainViewModel::shouldPromptNotificationPermission, + onNotificationPermissionPrompted = mainViewModel::markNotificationPermissionPrompted, + onNotificationPermissionResult = mainViewModel::syncDeviceToken, ) } } @@ -59,14 +71,32 @@ fun GamssRootNavHost( private fun RootNavDisplay( sessionState: SessionState, useCardFeature: Boolean, + shouldPromptNotificationPermission: suspend () -> Boolean, + onNotificationPermissionPrompted: () -> Unit, + onNotificationPermissionResult: () -> Unit, ) { val initialKey = if (sessionState == SessionState.Authenticated) MainKey else LoginKey val backStack = rememberNavBackStack(initialKey) val notificationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), - onResult = {}, + onResult = { onNotificationPermissionResult() }, ) + // 저장된 안내 이력은 비동기로 반영되므로, 온보딩에서 메인으로 넘어가는 동안에는 이 상태로 중복 요청을 막는다. + var promptedInSession by remember { mutableStateOf(false) } + + fun markNotificationPermissionPrompted() { + promptedInSession = true + onNotificationPermissionPrompted() + } + + fun requestNotificationPermission() { + markNotificationPermissionPrompted() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + fun replaceRoot(destination: NavKey) { if (backStack.lastOrNull() != destination) { backStack.clear() @@ -95,15 +125,23 @@ private fun RootNavDisplay( } entry { OnboardingScreen( - onComplete = { replaceRoot(MainKey) }, - onNotificationPermissionRequest = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } + onComplete = { + // 권한 안내는 동의/거절 모두 온보딩에서 끝난다. 거절도 결정으로 남겨 메인에서 다시 묻지 않는다. + markNotificationPermissionPrompted() + replaceRoot(MainKey) }, + onNotificationPermissionRequest = { requestNotificationPermission() }, ) } - entry { MainScreen(useCardFeature = useCardFeature) } + entry { + // 온보딩을 거치지 않고 들어오는 기존 사용자에게만 한 번 요청한다. + LaunchedEffect(Unit) { + if (!promptedInSession && shouldPromptNotificationPermission()) { + requestNotificationPermission() + } + } + MainScreen(useCardFeature = useCardFeature) + } }, ), 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..7352d851 --- /dev/null +++ b/app/src/main/kotlin/com/gamss/android/app/push/GamssFirebaseMessagingService.kt @@ -0,0 +1,96 @@ +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.cancel +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 content = message.toNotificationContentOrNull() ?: return + showNotification(content) + } + + override fun onDestroy() { + serviceScope.cancel() + super.onDestroy() + } + + // 권한 확인이 모듈 경계 너머에 있어 lint 가 추적하지 못한다. + @SuppressLint("MissingPermission") + @Suppress("TooGenericExceptionCaught") + private fun showNotification(content: PushNotificationContent) { + if (!notificationPermissionChecker.isGranted()) return + + 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(content.title) + .setContentText(content.body) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .build() + + 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 new file mode 100644 index 00000000..dbd51e79 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + 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 855cdc18..16edbcd9 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -86,6 +86,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..305f439a --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/di/PushModule.kt @@ -0,0 +1,54 @@ +package com.gamss.android.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.gamss.android.data.push.AndroidNotificationPermissionChecker +import com.gamss.android.data.push.DataStoreNotificationPermissionPromptHistory +import com.gamss.android.data.push.FirebaseMessagingTokenProvider +import com.gamss.android.data.push.NotificationPermissionPromptDataStore +import com.gamss.android.domain.push.NotificationPermissionChecker +import com.gamss.android.domain.push.NotificationPermissionPromptHistory +import com.gamss.android.domain.push.PushTokenProvider +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class PushModule { + + @Binds + abstract fun bindPushTokenProvider( + firebaseMessagingTokenProvider: FirebaseMessagingTokenProvider, + ): PushTokenProvider + + @Binds + abstract fun bindNotificationPermissionChecker( + androidNotificationPermissionChecker: AndroidNotificationPermissionChecker, + ): NotificationPermissionChecker + + @Binds + abstract fun bindNotificationPermissionPromptHistory( + dataStoreNotificationPermissionPromptHistory: DataStoreNotificationPermissionPromptHistory, + ): NotificationPermissionPromptHistory + + companion object { + @Provides + @Singleton + @NotificationPermissionPromptDataStore + fun provideNotificationPermissionPromptDataStore( + @ApplicationContext context: Context, + ): DataStore = PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(NOTIFICATION_PERMISSION_DATA_STORE_NAME) }, + ) + + private const val NOTIFICATION_PERMISSION_DATA_STORE_NAME = "notification_permission" + } +} 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..18ed625b --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/push/AndroidNotificationPermissionChecker.kt @@ -0,0 +1,24 @@ +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 { + + 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/DataStoreNotificationPermissionPromptHistory.kt b/data/src/main/java/com/gamss/android/data/push/DataStoreNotificationPermissionPromptHistory.kt new file mode 100644 index 00000000..29d4dd01 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/push/DataStoreNotificationPermissionPromptHistory.kt @@ -0,0 +1,45 @@ +package com.gamss.android.data.push + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.gamss.android.domain.push.NotificationPermissionPromptHistory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 온보딩에서 권한을 결정한 직후 메인 화면이 같은 값을 읽는다. 디스크 쓰기가 끝나기를 기다리면 + * 그 사이에 요청이 한 번 더 나가므로 메모리에 먼저 반영한다. + */ +@Singleton +internal class DataStoreNotificationPermissionPromptHistory @Inject constructor( + @param:NotificationPermissionPromptDataStore private val dataStore: DataStore, +) : NotificationPermissionPromptHistory { + + @Volatile + private var prompted: Boolean = false + + override suspend fun hasPrompted(): Boolean { + if (prompted) return true + val stored = withContext(Dispatchers.IO) { + dataStore.data.first()[PROMPTED_KEY] == true + } + if (stored) prompted = true + return stored + } + + override suspend fun markPrompted() { + prompted = true + withContext(Dispatchers.IO) { + dataStore.edit { preferences -> preferences[PROMPTED_KEY] = true } + } + } + + private companion object { + val PROMPTED_KEY = booleanPreferencesKey("notification_permission_prompted") + } +} 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..29569a58 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/push/FirebaseMessagingTokenProvider.kt @@ -0,0 +1,32 @@ +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 kotlinx.coroutines.withTimeoutOrNull +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 { + withTimeoutOrNull(TOKEN_FETCH_TIMEOUT_MILLIS) { + firebaseMessagingProvider.get().token.await() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + private companion object { + const val TOKEN_FETCH_TIMEOUT_MILLIS = 10_000L + } +} diff --git a/data/src/main/java/com/gamss/android/data/push/NotificationPermissionPromptDataStore.kt b/data/src/main/java/com/gamss/android/data/push/NotificationPermissionPromptDataStore.kt new file mode 100644 index 00000000..39104a52 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/push/NotificationPermissionPromptDataStore.kt @@ -0,0 +1,7 @@ +package com.gamss.android.data.push + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class NotificationPermissionPromptDataStore 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..94317d31 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/push/DeviceTokenService.kt @@ -0,0 +1,17 @@ +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 + + @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..0a38c57b --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/repository/DeviceTokenRepositoryImpl.kt @@ -0,0 +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 { + + 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 = + 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 new file mode 100644 index 00000000..f629eb50 --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/DeviceTokenRepositoryImplTest.kt @@ -0,0 +1,158 @@ +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 +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 `같은 상태로 다시 동기화하면 서버를 다시 호출하지 않는다`() = 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) + + 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 e8c1efc8..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 @@ -1,12 +1,17 @@ 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() + 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/MarkNotificationPermissionPromptedUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/MarkNotificationPermissionPromptedUseCase.kt new file mode 100644 index 00000000..6ff35ee3 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/MarkNotificationPermissionPromptedUseCase.kt @@ -0,0 +1,11 @@ +package com.gamss.android.domain.push + +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +class MarkNotificationPermissionPromptedUseCase @Inject constructor( + private val promptHistory: NotificationPermissionPromptHistory, +) : NoParamUseCase { + + override suspend fun invoke() = promptHistory.markPrompted() +} 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/NotificationPermissionPromptHistory.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/NotificationPermissionPromptHistory.kt new file mode 100644 index 00000000..6aa33b82 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/NotificationPermissionPromptHistory.kt @@ -0,0 +1,14 @@ +package com.gamss.android.domain.push + +/** + * 알림 권한 안내를 이미 노출했는지 기록한다. + * + * 시스템은 한 번 거절된 권한에 대해 다이얼로그를 다시 띄우지 않는다. 이력을 남기지 않으면 + * 실행마다 요청을 반복해 사용자의 거절을 무시하는 셈이 되므로 결정 시점을 기억한다. + */ +interface NotificationPermissionPromptHistory { + + suspend fun hasPrompted(): Boolean + + suspend fun markPrompted() +} 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..81b54e9f --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/PushTokenProvider.kt @@ -0,0 +1,6 @@ +package com.gamss.android.domain.push + +interface PushTokenProvider { + + suspend fun getToken(): String? +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/push/ShouldPromptNotificationPermissionUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/push/ShouldPromptNotificationPermissionUseCase.kt new file mode 100644 index 00000000..45441f92 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/ShouldPromptNotificationPermissionUseCase.kt @@ -0,0 +1,13 @@ +package com.gamss.android.domain.push + +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +class ShouldPromptNotificationPermissionUseCase @Inject constructor( + private val notificationPermissionChecker: NotificationPermissionChecker, + private val promptHistory: NotificationPermissionPromptHistory, +) : NoParamUseCase { + + override suspend fun invoke(): Boolean = + !notificationPermissionChecker.isGranted() && !promptHistory.hasPrompted() +} 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..f8cc1eef --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/SyncDeviceTokenUseCase.kt @@ -0,0 +1,20 @@ +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..20964fd3 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/push/UnregisterCurrentDeviceTokenUseCase.kt @@ -0,0 +1,15 @@ +package com.gamss.android.domain.push + +import com.gamss.android.domain.usecase.NoParamUseCase +import javax.inject.Inject + +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/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/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/MarkNotificationPermissionPromptedUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/push/MarkNotificationPermissionPromptedUseCaseTest.kt new file mode 100644 index 00000000..6a8ecd5a --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/push/MarkNotificationPermissionPromptedUseCaseTest.kt @@ -0,0 +1,20 @@ +package com.gamss.android.domain.push + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkNotificationPermissionPromptedUseCaseTest { + + @Test + fun `안내 이력을 기록한다`() = runBlocking { + val promptHistory = FakeNotificationPermissionPromptHistory() + val useCase = MarkNotificationPermissionPromptedUseCase(promptHistory) + + useCase() + + assertEquals(1, promptHistory.markPromptedCallCount) + assertTrue(promptHistory.hasPrompted()) + } +} 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..f02832bc --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/push/PushTestFakes.kt @@ -0,0 +1,57 @@ +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 + } +} + +internal class FakeNotificationPermissionPromptHistory( + private var prompted: Boolean = false, +) : NotificationPermissionPromptHistory { + + var markPromptedCallCount: Int = 0 + private set + + override suspend fun hasPrompted(): Boolean = prompted + + override suspend fun markPrompted() { + markPromptedCallCount++ + prompted = true + } +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/push/ShouldPromptNotificationPermissionUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/push/ShouldPromptNotificationPermissionUseCaseTest.kt new file mode 100644 index 00000000..0476a010 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/push/ShouldPromptNotificationPermissionUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.gamss.android.domain.push + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShouldPromptNotificationPermissionUseCaseTest { + + @Test + fun `권한이 없고 안내 이력도 없으면 요청한다`() = runBlocking { + val useCase = ShouldPromptNotificationPermissionUseCase( + notificationPermissionChecker = FakeNotificationPermissionChecker(granted = false), + promptHistory = FakeNotificationPermissionPromptHistory(prompted = false), + ) + + assertTrue(useCase()) + } + + @Test + fun `이미 안내한 적이 있으면 요청하지 않는다`() = runBlocking { + val useCase = ShouldPromptNotificationPermissionUseCase( + notificationPermissionChecker = FakeNotificationPermissionChecker(granted = false), + promptHistory = FakeNotificationPermissionPromptHistory(prompted = true), + ) + + assertFalse(useCase()) + } + + @Test + fun `권한이 이미 허용되어 있으면 요청하지 않는다`() = runBlocking { + val useCase = ShouldPromptNotificationPermissionUseCase( + notificationPermissionChecker = FakeNotificationPermissionChecker(granted = true), + promptHistory = FakeNotificationPermissionPromptHistory(prompted = false), + ) + + assertFalse(useCase()) + } +} 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/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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3b3c180..b395eb11 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" } @@ -106,12 +107,14 @@ 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" } 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" }