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]