Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
46a039f
[BOOK-364] chore: firebase-messaging 의존성 추가
seoyoon513 Oct 18, 2025
574e36c
[BOOK-364] feat: UserProfileResponse에 notificationEnabled 프로퍼티 추가
seoyoon513 Oct 23, 2025
f264a63
[BOOK-364] feat: 알람 관련 API 정의
seoyoon513 Oct 23, 2025
48835f4
[BOOK-364] feat: FirebaseMessagingService 세팅 WIP
seoyoon513 Oct 23, 2025
d612f6b
[BOOK-364] feat: FirebaseMessaging 의존성 주입
seoyoon513 Oct 23, 2025
fa6ff4a
[BOOK-364] feat: 로그인 및 스플래시에서 FCM 토큰 업데이트
seoyoon513 Oct 25, 2025
e31938e
[BOOK-364] feat: 알림 설정 상태 서버 동기화
seoyoon513 Oct 25, 2025
d858e13
[BOOK-364] feat: 알림 권한 서버 동기화 로직 개선
seoyoon513 Oct 27, 2025
3160f5d
[BOOK-364] feat: 알림 토글 변경 시 서버 동기화 상태(lastSynced) 업데이트 및 에러 핸들링 추가
seoyoon513 Oct 27, 2025
5105ac1
[BOOK-364] refactor: 알림 설정 토글에 Optimistic Update 적용
seoyoon513 Oct 27, 2025
78ec9c4
[BOOK-364] feat: FCM 토큰 동기화 로직 개선
seoyoon513 Oct 27, 2025
cebcb31
[BOOK-364] feat: notification icon 추가
seoyoon513 Oct 28, 2025
4d4f40f
[BOOK-364] feat: FirebaseMessagingService 구현 (토큰 갱신 및 푸시 수신 처리)
seoyoon513 Oct 28, 2025
4e75d7f
[BOOK-364] feat: 앱 시작 시 NotificationChannel 생성
seoyoon513 Oct 28, 2025
a407c12
[BOOK-364] refactor: onNewToken() 에서 중복 요청 방지를 위한 syncFcmToken 오버로드 추가
seoyoon513 Oct 28, 2025
2ce8b80
[BOOK-364] feat: 알림 설정 화면에서 권한 변경 시 서버 동기화 로직 추가
seoyoon513 Oct 28, 2025
3f06f9c
[BOOK-364] chore: code style check success
seoyoon513 Oct 28, 2025
a9bd7e6
[BOOK-364] fix: 사용자 설정 Off 상태를 홈에서 서버 On으로 동기화하는 문제 수정
seoyoon513 Oct 28, 2025
fc249ab
[BOOK-364] fix: syncFcmToken에서 updateFcmToken 실패가 전파되도록 수정
seoyoon513 Oct 28, 2025
7dec07a
[BOOK-364] fix: Android 13 미만에서 권한 동기화 누락 수정
seoyoon513 Oct 28, 2025
f05cad2
[BOOK-364] fix: 알림 설정 Optimistic Update 경쟁 조건 방지
seoyoon513 Oct 28, 2025
70770ed
[BOOK-364] refactor: 코드 리뷰 반영
seoyoon513 Oct 30, 2025
c2d6c92
Merge branch 'develop' into BOOK-364-feature/#193
seoyoon513 Oct 30, 2025
651c7df
[BOOK-364] feat: 회원 탈퇴 시 Notification DataStore 초기화
seoyoon513 Oct 30, 2025
1e0795b
[BOOK-364] feat: 알림 설정 화면 화면 크기 대응
seoyoon513 Oct 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".BooketApplication"
Expand Down Expand Up @@ -41,6 +41,10 @@
android:name="com.ninecraft.booket.initializer.FirebaseCrashlyticsInitializer"
android:value="androidx.startup" />

<meta-data
android:name="com.ninecraft.booket.initializer.NotificationChannelInitializer"
android:value="androidx.startup" />

</provider>

<activity
Expand Down Expand Up @@ -73,6 +77,19 @@
tools:replace="android:resource" />
</provider>

<service
android:name=".ReedFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/green_500" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown
Contributor Author

@seoyoon513 seoyoon513 Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

smallIcon 관련 문서

해당 아이콘으로 기존 런처 아이콘이 아닌 notification 전용 벡터 아이콘으로 만들어서 넣어줬는데요, 기존에 런처 아이콘과 동일한 아이콘을 사용할 경우(mipmap/ic_launcher) 기기 OS 별로 다르게 노출되기 때문입니다.

[에뮬레이터 Pixel 기종]
image

[갤럭시 S21]
image

따라서 statusBar 등에 노출되는 알림용 아이콘의 통일성을 위해, 흰색 단색 로고 'r'만 남긴 알림 전용 아이콘 (ic_notification)으로 적용했습니다.

[에뮬레이터 Pixel 기종]
image
image

[갤럭시 S21]
image

.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()
Comment thread
easyhooon marked this conversation as resolved.
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)
}
Comment thread
seoyoon513 marked this conversation as resolved.
Comment on lines +89 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

컴파일 에러: NOTIFICATION_SERVICE 미수입 사용

NOTIFICATION_SERVICE가 정규화되지 않아 컴파일 오류가 납니다. Context. 접두어를 붙이거나 정적 임포트를 추가하세요.

-            val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+            val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
🤖 Prompt for AI Agents
In app/src/main/kotlin/com/ninecraft/booket/ReedFirebaseMessagingService.kt
around lines 89 to 91, the symbol NOTIFICATION_SERVICE is unqualified causing a
compile error; update the reference to Context.NOTIFICATION_SERVICE or add an
appropriate static import so the call becomes
context.getSystemService(Context.NOTIFICATION_SERVICE) (or import
Context.NOTIFICATION_SERVICE) and then cast to NotificationManager as before,
ensuring the Context qualifier is present.

}
}
Original file line number Diff line number Diff line change
@@ -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<Unit> {

override fun create(context: Context) {
createNotificationChannel(context)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오.. 이런 방식이 가능하군여 👍

}

override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal class AndroidFirebaseConventionPlugin : Plugin<Project> {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ interface UserRepository {

suspend fun setOnboardingCompleted(isCompleted: Boolean)

val isNotificationEnabled: Flow<Boolean>
suspend fun syncFcmToken(): Result<Unit>

suspend fun setNotificationEnabled(isEnabled: Boolean)
suspend fun syncFcmToken(fcmToken: String): Result<Unit>

val isUserNotificationEnabled: Flow<Boolean>

suspend fun getUserNotificationEnabled(): Boolean

suspend fun setUserNotificationEnabled(isEnabled: Boolean)

suspend fun getLastSyncedNotificationEnabled(): Boolean?

suspend fun setLastNotificationSyncedEnabled(isEnabled: Boolean)

suspend fun updateNotificationSettings(notificationEnabled: Boolean): Result<UserProfileModel>
}
1 change: 1 addition & 0 deletions core/data/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {

platform(libs.firebase.bom),
libs.firebase.remote.config,
libs.firebase.messaging,
libs.logger,
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,4 +28,8 @@ internal object FirebaseModule {
setConfigSettingsAsync(configSettings)
}
}

@Singleton
@Provides
fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ internal fun UserProfileResponse.toModel(): UserProfileModel {
nickname = nickname,
provider = provider,
termsAgreed = termsAgreed,
notificationEnabled = notificationEnabled,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -29,6 +31,7 @@ internal class DefaultAuthRepository @Inject constructor(
override suspend fun logout() = runSuspendCatching {
service.logout()
clearTokens()
clearNotificationDataStore()
}

override suspend fun withdraw() = runSuspendCatching {
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

override suspend fun syncFcmToken(fcmToken: String): Result<Unit> = runSuspendCatching {
updateFcmToken(fcmToken)
setFcmToken(fcmToken)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ package com.ninecraft.booket.core.datastore.api.datasource
import kotlinx.coroutines.flow.Flow

interface NotificationDataSource {
val isNotificationEnabled: Flow<Boolean>
suspend fun setNotificationEnabled(isEnabled: Boolean)
val fcmToken: Flow<String>
suspend fun setFcmToken(fcmToken: String)

val isUserNotificationEnabled: Flow<Boolean>
suspend fun setUserNotificationEnabled(isEnabled: Boolean)

val lastSyncedNotificationEnabled: Flow<Boolean?>
suspend fun setLastSyncedNotificationEnabled(isEnabled: Boolean)

suspend fun clearNotificationDataStore()
}
Loading
Loading