diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5e60c23b..f9074ce9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ - + + + + + + + + + + + - diff --git a/app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt b/app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt new file mode 100644 index 00000000..5efc4928 --- /dev/null +++ b/app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt @@ -0,0 +1,93 @@ +package com.ninecraft.booket + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.ninecraft.booket.core.data.api.repository.UserRepository +import com.ninecraft.booket.core.designsystem.R +import com.ninecraft.booket.feature.main.MainActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class ReedFirebaseMessagingService : FirebaseMessagingService() { + + @Inject + lateinit var userRepository: UserRepository + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onNewToken(token: String) { + super.onNewToken(token) + + scope.launch { + userRepository.syncFcmToken(token) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + val title = message.notification?.title ?: "Reed" + val body = message.notification?.body ?: "" + + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val builder = NotificationCompat.Builder(this, REED_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(ContextCompat.getColor(this, R.color.green_500)) + .setContentTitle(title) + .setContentText(body) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(System.currentTimeMillis().toInt(), builder.build()) + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + companion object { + private const val REED_CHANNEL_ID = "REED_PUSH_CHANNEL" + private const val REED_CHANNEL_NAME = "리드 푸시 알림" + private const val REED_CHANNEL_DESC = "리드 앱에서 보내는 푸시 알림을 관리합니다." + + // Android 8.0 이상 필수 채널 생성 + fun createNotificationChannel(context: Context) { + val channel = NotificationChannel( + REED_CHANNEL_ID, + REED_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = REED_CHANNEL_DESC + } + + val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } +} diff --git a/app/src/main/kotlin/com/ninecraft/booket/initializer/NotificationChannelInitializer.kt b/app/src/main/kotlin/com/ninecraft/booket/initializer/NotificationChannelInitializer.kt new file mode 100644 index 00000000..60aee8a8 --- /dev/null +++ b/app/src/main/kotlin/com/ninecraft/booket/initializer/NotificationChannelInitializer.kt @@ -0,0 +1,16 @@ +package com.ninecraft.booket.initializer + +import android.content.Context +import androidx.startup.Initializer +import com.ninecraft.booket.ReedFirebaseMessagingService.Companion.createNotificationChannel + +class NotificationChannelInitializer : Initializer { + + override fun create(context: Context) { + createNotificationChannel(context) + } + + override fun dependencies(): List>> { + return emptyList() + } +} diff --git a/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt index fa0b0972..dc9423db 100644 --- a/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt @@ -19,6 +19,7 @@ internal class AndroidFirebaseConventionPlugin : Plugin { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) + implementation(libs.firebase.messaging) } } } diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt index 00a34c9a..f2040ee7 100644 --- a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt @@ -14,7 +14,19 @@ interface UserRepository { suspend fun setOnboardingCompleted(isCompleted: Boolean) - val isNotificationEnabled: Flow + suspend fun syncFcmToken(): Result - suspend fun setNotificationEnabled(isEnabled: Boolean) + suspend fun syncFcmToken(fcmToken: String): Result + + val isUserNotificationEnabled: Flow + + suspend fun getUserNotificationEnabled(): Boolean + + suspend fun setUserNotificationEnabled(isEnabled: Boolean) + + suspend fun getLastSyncedNotificationEnabled(): Boolean? + + suspend fun setLastNotificationSyncedEnabled(isEnabled: Boolean) + + suspend fun updateNotificationSettings(notificationEnabled: Boolean): Result } diff --git a/core/data/impl/build.gradle.kts b/core/data/impl/build.gradle.kts index f894b8ed..c24e3f2e 100644 --- a/core/data/impl/build.gradle.kts +++ b/core/data/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { platform(libs.firebase.bom), libs.firebase.remote.config, + libs.firebase.messaging, libs.logger, ) } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt index 2a6f28a5..525dce21 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt @@ -1,6 +1,8 @@ package com.ninecraft.booket.core.data.impl.di import com.google.firebase.Firebase +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 com.google.firebase.remoteconfig.remoteConfigSettings @@ -26,4 +28,8 @@ internal object FirebaseModule { setConfigSettingsAsync(configSettings) } } + + @Singleton + @Provides + fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt index 9ed28eb9..66a1c893 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt @@ -49,6 +49,7 @@ internal fun UserProfileResponse.toModel(): UserProfileModel { nickname = nickname, provider = provider, termsAgreed = termsAgreed, + notificationEnabled = notificationEnabled, ) } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt index 9e560650..08cce48c 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt @@ -2,6 +2,7 @@ package com.ninecraft.booket.core.data.impl.repository import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.AuthRepository +import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource import com.ninecraft.booket.core.model.AutoLoginState import com.ninecraft.booket.core.model.UserState @@ -15,6 +16,7 @@ private const val KAKAO_PROVIDER_TYPE = "KAKAO" internal class DefaultAuthRepository @Inject constructor( private val service: ReedService, private val tokenDataSource: TokenDataSource, + private val notificationDataSource: NotificationDataSource, ) : AuthRepository { override suspend fun login(accessToken: String) = runSuspendCatching { val response = service.login( @@ -29,6 +31,7 @@ internal class DefaultAuthRepository @Inject constructor( override suspend fun logout() = runSuspendCatching { service.logout() clearTokens() + clearNotificationDataStore() } override suspend fun withdraw() = runSuspendCatching { @@ -61,4 +64,8 @@ internal class DefaultAuthRepository @Inject constructor( val accessToken = tokenDataSource.getAccessToken() return if (accessToken.isBlank()) UserState.Guest else UserState.LoggedIn } + + private suspend fun clearNotificationDataStore() { + notificationDataSource.clearNotificationDataStore() + } } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt index 14531cfe..3d915d61 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt @@ -1,18 +1,26 @@ package com.ninecraft.booket.core.data.impl.repository +import com.google.firebase.messaging.FirebaseMessaging import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.data.impl.mapper.toModel import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource +import com.ninecraft.booket.core.network.request.FcmTokenRequest +import com.ninecraft.booket.core.network.request.NotificationSettingsRequest import com.ninecraft.booket.core.network.request.TermsAgreementRequest import com.ninecraft.booket.core.network.service.ReedService +import com.orhanobut.logger.Logger +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.tasks.await import javax.inject.Inject internal class DefaultUserRepository @Inject constructor( private val service: ReedService, private val onboardingDataSource: OnboardingDataSource, private val notificationDataSource: NotificationDataSource, + private val firebaseMessaging: FirebaseMessaging, ) : UserRepository { override suspend fun agreeTerms(termsAgreed: Boolean) = runSuspendCatching { service.agreeTerms(TermsAgreementRequest(termsAgreed)).toModel() @@ -28,9 +36,59 @@ internal class DefaultUserRepository @Inject constructor( onboardingDataSource.setOnboardingCompleted(isCompleted) } - override val isNotificationEnabled = notificationDataSource.isNotificationEnabled + override suspend fun syncFcmToken() = runSuspendCatching { + val newToken = getRemoteFcmToken() + val localToken = getLocalFcmToken() - override suspend fun setNotificationEnabled(isEnabled: Boolean) { - notificationDataSource.setNotificationEnabled(isEnabled) + if (newToken == localToken) { + Logger.d("Skip FCM token sync (already up-to-date)") + return@runSuspendCatching + } + + updateFcmToken(newToken) + setFcmToken(newToken) + } + + override suspend fun syncFcmToken(fcmToken: String): Result = runSuspendCatching { + updateFcmToken(fcmToken) + setFcmToken(fcmToken) + } + + override val isUserNotificationEnabled = notificationDataSource.isUserNotificationEnabled + + override suspend fun getUserNotificationEnabled(): Boolean = isUserNotificationEnabled.first() + + override suspend fun setUserNotificationEnabled(isEnabled: Boolean) { + notificationDataSource.setUserNotificationEnabled(isEnabled) + } + + override suspend fun getLastSyncedNotificationEnabled(): Boolean? = + notificationDataSource.lastSyncedNotificationEnabled.firstOrNull() + + override suspend fun setLastNotificationSyncedEnabled(isEnabled: Boolean) { + notificationDataSource.setLastSyncedNotificationEnabled(isEnabled) + } + + override suspend fun updateNotificationSettings(notificationEnabled: Boolean) = runSuspendCatching { + service.updateNotificationSettings(NotificationSettingsRequest(notificationEnabled)).toModel() + } + + private suspend fun getRemoteFcmToken(): String { + return try { + firebaseMessaging.token.await() + } catch (e: Exception) { + Logger.e("Failed to fetch FCM token: ${e.message}") + throw e + } + } + + private suspend fun getLocalFcmToken(): String = notificationDataSource.fcmToken.first() + + private suspend fun setFcmToken(fcmToken: String) { + notificationDataSource.setFcmToken(fcmToken) + } + + private suspend fun updateFcmToken(fcmToken: String) { + service.updateFcmToken(FcmTokenRequest(fcmToken)) } } diff --git a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/NotificationDataSource.kt b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/NotificationDataSource.kt index 6b0d1483..c659aaca 100644 --- a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/NotificationDataSource.kt +++ b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/NotificationDataSource.kt @@ -3,6 +3,14 @@ package com.ninecraft.booket.core.datastore.api.datasource import kotlinx.coroutines.flow.Flow interface NotificationDataSource { - val isNotificationEnabled: Flow - suspend fun setNotificationEnabled(isEnabled: Boolean) + val fcmToken: Flow + suspend fun setFcmToken(fcmToken: String) + + val isUserNotificationEnabled: Flow + suspend fun setUserNotificationEnabled(isEnabled: Boolean) + + val lastSyncedNotificationEnabled: Flow + suspend fun setLastSyncedNotificationEnabled(isEnabled: Boolean) + + suspend fun clearNotificationDataStore() } diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt index 24db08c0..c314e19b 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultNotificationDataSource.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource import com.ninecraft.booket.core.datastore.impl.di.NotificationDataStore import com.ninecraft.booket.core.datastore.impl.util.handleIOException @@ -14,19 +15,51 @@ import javax.inject.Inject class DefaultNotificationDataSource @Inject constructor( @NotificationDataStore private val dataStore: DataStore, ) : NotificationDataSource { - override val isNotificationEnabled: Flow = dataStore.data + override val fcmToken: Flow = dataStore.data .handleIOException() .map { prefs -> - prefs[NOTIFICATION_ENABLED] ?: true + prefs[FCM_TOKEN] ?: "" } - override suspend fun setNotificationEnabled(isEnabled: Boolean) { + override suspend fun setFcmToken(fcmToken: String) { dataStore.edit { prefs -> - prefs[NOTIFICATION_ENABLED] = isEnabled + prefs[FCM_TOKEN] = fcmToken + } + } + + override val isUserNotificationEnabled: Flow = dataStore.data + .handleIOException() + .map { prefs -> + prefs[USER_NOTIFICATION_ENABLED] ?: true + } + + override suspend fun setUserNotificationEnabled(isEnabled: Boolean) { + dataStore.edit { prefs -> + prefs[USER_NOTIFICATION_ENABLED] = isEnabled + } + } + + override val lastSyncedNotificationEnabled: Flow = dataStore.data + .handleIOException() + .map { prefs -> + prefs[LAST_SYNCED_NOTIFICATION_ENABLED] + } + + override suspend fun setLastSyncedNotificationEnabled(isEnabled: Boolean) { + dataStore.edit { prefs -> + prefs[LAST_SYNCED_NOTIFICATION_ENABLED] = isEnabled + } + } + + override suspend fun clearNotificationDataStore() { + dataStore.edit { prefs -> + prefs.clear() } } companion object Companion { - private val NOTIFICATION_ENABLED = booleanPreferencesKey("NOTIFICATION_ENABLED") + private val FCM_TOKEN = stringPreferencesKey("FCM_TOKEN") + private val USER_NOTIFICATION_ENABLED = booleanPreferencesKey("USER_NOTIFICATION_ENABLED") + private val LAST_SYNCED_NOTIFICATION_ENABLED = booleanPreferencesKey("LAST_SYNCED_NOTIFICATION_ENABLED") } } diff --git a/core/designsystem/src/main/res/drawable/ic_notification.xml b/core/designsystem/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..01204e1b --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserProfileModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserProfileModel.kt index b7e712d0..f972caf8 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserProfileModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/UserProfileModel.kt @@ -9,4 +9,5 @@ data class UserProfileModel( val nickname: String, val provider: String, val termsAgreed: Boolean, + val notificationEnabled: Boolean, ) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/FcmTokenRequest.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/FcmTokenRequest.kt new file mode 100644 index 00000000..7ad8220a --- /dev/null +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/FcmTokenRequest.kt @@ -0,0 +1,10 @@ +package com.ninecraft.booket.core.network.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FcmTokenRequest( + @SerialName("fcmToken") + val fcmToken: String, +) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/NotificationSettingsRequest.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/NotificationSettingsRequest.kt new file mode 100644 index 00000000..b2dc87cc --- /dev/null +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/request/NotificationSettingsRequest.kt @@ -0,0 +1,10 @@ +package com.ninecraft.booket.core.network.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationSettingsRequest( + @SerialName("notificationEnabled") + val notificationEnabled: Boolean, +) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/UserProfileResponse.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/UserProfileResponse.kt index a60374d3..3b871550 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/UserProfileResponse.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/UserProfileResponse.kt @@ -15,4 +15,6 @@ data class UserProfileResponse( val provider: String, @SerialName("termsAgreed") val termsAgreed: Boolean, + @SerialName("notificationEnabled") + val notificationEnabled: Boolean, ) diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt index ab543432..b6247a64 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt @@ -1,7 +1,9 @@ package com.ninecraft.booket.core.network.service import com.ninecraft.booket.core.network.request.BookUpsertRequest +import com.ninecraft.booket.core.network.request.FcmTokenRequest import com.ninecraft.booket.core.network.request.LoginRequest +import com.ninecraft.booket.core.network.request.NotificationSettingsRequest import com.ninecraft.booket.core.network.request.RecordRegisterRequest import com.ninecraft.booket.core.network.request.RefreshTokenRequest import com.ninecraft.booket.core.network.request.TermsAgreementRequest @@ -51,6 +53,12 @@ interface ReedService { @GET("api/v1/users/me") suspend fun getUserProfile(): UserProfileResponse + @PUT("api/v1/users/me/fcm-token") + suspend fun updateFcmToken(@Body fcmTokenRequest: FcmTokenRequest): UserProfileResponse + + @PUT("api/v1/users/me/notification-settings") + suspend fun updateNotificationSettings(@Body notificationSettingsRequest: NotificationSettingsRequest): UserProfileResponse + // Book endpoints (no auth required) @GET("api/v1/books/guest/search") suspend fun searchBookAsGuest( diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt index f587d82c..62bb118f 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt @@ -9,6 +9,7 @@ import com.ninecraft.booket.core.common.analytics.AnalyticsHelper import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.BookRepository +import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.model.RecentBookModel import com.ninecraft.booket.core.model.UserState import com.ninecraft.booket.feature.screens.BookDetailScreen @@ -17,6 +18,7 @@ import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.RecordScreen import com.ninecraft.booket.feature.screens.SettingsScreen +import com.orhanobut.logger.Logger import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.collectAsRetainedState @@ -36,6 +38,7 @@ class HomePresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val bookRepository: BookRepository, private val authRepository: AuthRepository, + private val userRepository: UserRepository, private val analyticsHelper: AnalyticsHelper, ) : Presenter { @@ -70,6 +73,15 @@ class HomePresenter @AssistedInject constructor( } } + suspend fun syncNotificationSettings(isGranted: Boolean) { + userRepository.updateNotificationSettings(isGranted) + .onSuccess { + userRepository.setLastNotificationSyncedEnabled(isGranted) + }.onFailure { exception -> + Logger.e("Failed to update notification settings: $exception") + } + } + fun handleEvent(event: HomeUiEvent) { when (event) { is HomeUiEvent.OnSettingsClick -> { @@ -101,7 +113,18 @@ class HomePresenter @AssistedInject constructor( } is HomeUiEvent.OnNotificationPermissionResult -> { - // TODO: 서버 동기화, FCM 토큰 전송 + scope.launch { + val isPermissionGranted = event.granted + val userEnabled = userRepository.getUserNotificationEnabled() + val lastSyncedServerEnabled = userRepository.getLastSyncedNotificationEnabled() + + val shouldSync = (!isPermissionGranted && lastSyncedServerEnabled != false) || + (userEnabled && (lastSyncedServerEnabled == null || lastSyncedServerEnabled != isPermissionGranted)) + + if (shouldSync) { + syncNotificationSettings(isPermissionGranted) + } + } } } } diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt index a89ecb7d..72624840 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt @@ -1,5 +1,7 @@ package com.ninecraft.booket.feature.home +import android.Manifest +import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult @@ -29,6 +31,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.ninecraft.booket.core.common.extensions.toErrorType import com.ninecraft.booket.core.designsystem.DevicePreview @@ -65,15 +68,17 @@ internal fun HomeUi( if (!state.isGuestMode) { LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val permission = android.Manifest.permission.POST_NOTIFICATIONS - val isGranted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + val isGranted = checkSystemNotificationEnabled(context) - if (!isGranted) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (isGranted) { + state.eventSink(HomeUiEvent.OnNotificationPermissionResult(isGranted)) + } else { + val permission = Manifest.permission.POST_NOTIFICATIONS permissionLauncher.launch(permission) } } else { - state.eventSink(HomeUiEvent.OnNotificationPermissionResult(true)) + state.eventSink(HomeUiEvent.OnNotificationPermissionResult(isGranted)) } } } @@ -233,6 +238,14 @@ internal fun HomeContent( } } +private fun checkSystemNotificationEnabled(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } +} + @DevicePreview @Composable private fun HomePreview() { diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt index 39377b53..2f7b61a3 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt @@ -43,5 +43,5 @@ sealed interface HomeUiEvent : CircuitUiEvent { ) : HomeUiEvent data object OnRetryClick : HomeUiEvent data class OnTabSelected(val tab: MainTab) : HomeUiEvent - data class OnNotificationPermissionResult(val isGranted: Boolean) : HomeUiEvent + data class OnNotificationPermissionResult(val granted: Boolean) : HomeUiEvent } diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt index 8eb0bfc2..30fbfb8b 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginPresenter.kt @@ -90,6 +90,7 @@ class LoginPresenter @AssistedInject constructor( isLoading = true authRepository.login(event.accessToken) .onSuccess { + userRepository.syncFcmToken() navigateAfterLogin() }.onFailure { exception -> Logger.e(exception.message ?: "Login failed") diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ToggleItem.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ToggleItem.kt index 6eb4e449..00046359 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ToggleItem.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/ToggleItem.kt @@ -1,6 +1,5 @@ package com.ninecraft.booket.feature.settings.component -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -28,9 +27,8 @@ internal fun ToggleItem( horizontal = ReedTheme.spacing.spacing5, ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = title, color = ReedTheme.colors.contentPrimary, diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/HandleNotificationSideEffects.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/HandleNotificationSideEffects.kt new file mode 100644 index 00000000..53523b3f --- /dev/null +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/HandleNotificationSideEffects.kt @@ -0,0 +1,28 @@ +package com.ninecraft.booket.feature.settings.notification + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandleNotificationSideEffects( + state: NotificationUiState, + eventSink: (NotificationUiEvent) -> Unit, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is NotificationSideEffect.ShowToast -> { + Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + } + + else -> {} + } + + if (state.sideEffect != null) { + eventSink(NotificationUiEvent.InitSideEffect) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt index 706b8488..aa8fd1a9 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationPresenter.kt @@ -2,11 +2,17 @@ package com.ninecraft.booket.feature.settings.notification import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.UserRepository +import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.NotificationScreen +import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.collectAsRetainedState +import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import dagger.assisted.Assisted @@ -22,23 +28,90 @@ class NotificationPresenter @AssistedInject constructor( @Composable override fun present(): NotificationUiState { val scope = rememberCoroutineScope() - val isNotificationEnabled by userRepository.isNotificationEnabled.collectAsRetainedState(initial = false) + val isNotificationEnabled by userRepository.isUserNotificationEnabled.collectAsRetainedState(initial = false) + var sideEffect by rememberRetained { mutableStateOf(null) } + + fun updateNotificationSettings(enabled: Boolean) { + scope.launch { + val prevNotificationEnabled = userRepository.getUserNotificationEnabled() + userRepository.setUserNotificationEnabled(enabled) + + userRepository.updateNotificationSettings(enabled) + .onSuccess { + userRepository.setLastNotificationSyncedEnabled(enabled) + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = NotificationSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen()) + }, + ) + userRepository.setUserNotificationEnabled(prevNotificationEnabled) + } + } + } + + suspend fun syncNotificationSettings(enabled: Boolean) { + userRepository.updateNotificationSettings(enabled) + .onSuccess { + userRepository.setLastNotificationSyncedEnabled(enabled) + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = NotificationSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen()) + }, + ) + } + } fun handleEvent(event: NotificationUiEvent) { when (event) { + is NotificationUiEvent.InitSideEffect -> { + sideEffect = null + } + is NotificationUiEvent.OnBackClick -> { navigator.pop() } - is NotificationUiEvent.OnNotificationToggle -> { + is NotificationUiEvent.OnNotificationPermissionResult -> { scope.launch { - userRepository.setNotificationEnabled(event.enabled) + val isPermissionGranted = event.granted + val userEnabled = userRepository.getUserNotificationEnabled() + val lastSyncedServerEnabled = userRepository.getLastSyncedNotificationEnabled() + + val shouldSync = (!isPermissionGranted && lastSyncedServerEnabled != false) || + (userEnabled && (lastSyncedServerEnabled == null || lastSyncedServerEnabled != isPermissionGranted)) + + if (shouldSync) { + syncNotificationSettings(isPermissionGranted) + } } } + + is NotificationUiEvent.OnNotificationToggle -> { + updateNotificationSettings(event.enabled) + } } } return NotificationUiState( isNotificationEnabled = isNotificationEnabled, + sideEffect = sideEffect, eventSink = ::handleEvent, ) } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt index 39377518..f704f7e1 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUi.kt @@ -1,14 +1,15 @@ package com.ninecraft.booket.feature.settings.notification +import android.Manifest import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -29,6 +30,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -51,17 +53,23 @@ internal fun NotificationUi( state: NotificationUiState, modifier: Modifier = Modifier, ) { + HandleNotificationSideEffects( + state = state, + eventSink = state.eventSink, + ) + val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val isGranted by produceState( - initialValue = checkNotificationPermission(context), + initialValue = checkSystemNotificationEnabled(context), key1 = lifecycleOwner, ) { // 포그라운드 복귀 시 OS 권한 동기화 val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - value = checkNotificationPermission(context) + value = checkSystemNotificationEnabled(context) + state.eventSink(NotificationUiEvent.OnNotificationPermissionResult(value)) } } lifecycleOwner.lifecycle.addObserver(observer) @@ -138,9 +146,8 @@ internal fun NotificationGuideItem( horizontal = ReedTheme.spacing.spacing5, ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.notification_guide_title), color = ReedTheme.colors.contentBrand, @@ -160,9 +167,12 @@ internal fun NotificationGuideItem( } } -private fun checkNotificationPermission(context: Context): Boolean { - val notificationManager = NotificationManagerCompat.from(context) - return notificationManager.areNotificationsEnabled() +private fun checkSystemNotificationEnabled(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } } @RequiresApi(Build.VERSION_CODES.TIRAMISU) diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUiState.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUiState.kt index db521d29..7956e7e1 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUiState.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/notification/NotificationUiState.kt @@ -1,14 +1,27 @@ package com.ninecraft.booket.feature.settings.notification +import androidx.compose.runtime.Immutable import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState +import java.util.UUID data class NotificationUiState( val isNotificationEnabled: Boolean = false, + val sideEffect: NotificationSideEffect? = null, val eventSink: (NotificationUiEvent) -> Unit, ) : CircuitUiState +@Immutable +sealed interface NotificationSideEffect { + data class ShowToast( + val message: String, + private val key: String = UUID.randomUUID().toString(), + ) : NotificationSideEffect +} + sealed interface NotificationUiEvent : CircuitUiEvent { + data object InitSideEffect : NotificationUiEvent data object OnBackClick : NotificationUiEvent + data class OnNotificationPermissionResult(val granted: Boolean) : NotificationUiEvent data class OnNotificationToggle(val enabled: Boolean) : NotificationUiEvent } diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt index 6af98e78..a27d3d08 100644 --- a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt @@ -54,6 +54,7 @@ class SplashPresenter @AssistedInject constructor( userRepository.getUserProfile() .onSuccess { userProfile -> if (userProfile.termsAgreed) { + userRepository.syncFcmToken() navigator.resetRoot(HomeScreen) } else { navigator.resetRoot(LoginScreen()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b516895e..81fefff0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -150,6 +150,7 @@ androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } firebase-remote-config = { group = "com.google.firebase", name = "firebase-config" } [plugins]