Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2ace5d6
feat(notification): Firebase FCM 푸시 알림 연동 - 인프라 구성
Minsu-Lee Apr 20, 2026
b5e62f3
feat(notification): splash/login 화면 FCM 토큰 서버 동기화 연동
Minsu-Lee Apr 20, 2026
d8de4d5
chore(app): allowBackup 비활성화
Minsu-Lee Apr 20, 2026
a404b2a
style(notification): onDestroy 메서드를 클래스 하단으로 이동
Minsu-Lee Apr 20, 2026
2f92e18
refactor(notification): FCM 토큰 데이터소스 역할과 sync 상태 API 정리
Minsu-Lee Apr 20, 2026
5f15186
fix(notification): UpdateFcmTokenUseCase가 FCM 등록 결과를 반환하도록 수정
Minsu-Lee Apr 20, 2026
d23d351
refactor(notification): FCM 동기화 실패 로그 처리 공용 함수로 통합
Minsu-Lee Apr 20, 2026
8b8034c
refactor(notification): FCM 토큰 제공자를 DIP 기반으로 추상화
Minsu-Lee Apr 21, 2026
372cc5d
test(notification): 테스트 공용 Fake 통합 및 커버리지 보완
Minsu-Lee Apr 21, 2026
60d14f2
refactor(notification): FcmTokenProvider를 repository 패키지로 이동
Minsu-Lee Apr 21, 2026
dfbde29
refactor(notification): 채널 초기화 로직을 NotificationChannelInitializer로 캡슐화
Minsu-Lee Apr 21, 2026
09bf0e1
style(notification): FcmTokenErrorMapper 중첩 when을 if/else로 개선
Minsu-Lee Apr 21, 2026
f6f91dd
fix(notification): RegisterFcmTokenRequest에 @SerializedName 명시
Minsu-Lee Apr 21, 2026
61cba5d
test(notification): FakeTokenRepository를 testutil로 이동 및 CancellationE…
Minsu-Lee Apr 21, 2026
a7ab3db
test(notification): SyncFcmTokenUseCase provider 예외/취소 케이스 추가
Minsu-Lee Apr 21, 2026
e569eff
refactor(notification): FcmTokenRepository API를 의미 단위로 정리
Minsu-Lee Apr 21, 2026
f5b7e29
fix(notification): runCatching을 try/catch로 교체해 CancellationException 재전파
Minsu-Lee Apr 21, 2026
f772ad5
style(gradle): Firebase version catalog 키를 camelCase로 통일
Minsu-Lee Apr 21, 2026
2c6970b
fix(notification): 로그아웃/탈퇴 시 FCM 데이터 초기화 누락 수정
Minsu-Lee Apr 21, 2026
ad1cd8c
refactor(notification): FCM 데이터 초기화 책임을 UseCase 레이어로 이동
Minsu-Lee Apr 21, 2026
69d0195
style(notification): FcmTokenRepository 불필요한 주석 제거
Minsu-Lee Apr 21, 2026
44ad508
fix(notification): Splash 알림 권한 요청 및 notify 권한 검사 추가
Minsu-Lee Apr 22, 2026
c8fbaa6
refactor(notification): 미사용 saveFcmToken 제거
Minsu-Lee Apr 22, 2026
725a506
refactor(notification): remove unnecessary context property
Minsu-Lee Apr 22, 2026
ce09ee1
fix(auth): 로그아웃 후 로컬 정리 실패 무시
Minsu-Lee Apr 22, 2026
74176ec
fix(ci): GitHub CI 빌드를 위해 google-services 설정 포함
Minsu-Lee Apr 22, 2026
d2d1fbb
fix(main): 로그아웃 UseCase 디버그 인자 전달
Minsu-Lee Apr 22, 2026
b88dc31
refactor(notification): NeveraMessagingService 메서드 분리
Minsu-Lee Apr 22, 2026
47094dd
refactor(notification): NotificationType 가시성을 internal로 제한
Minsu-Lee Apr 24, 2026
82d1cdc
move(notification): NotificationRemoteDataSource를 FcmTokenRemoteDataS…
Minsu-Lee Apr 24, 2026
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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.google.services)
Comment thread
Minsu-Lee marked this conversation as resolved.
}

android {
Expand Down Expand Up @@ -66,6 +67,7 @@ dependencies {
implementation(project(":core:common"))
implementation(project(":core:designsystem"))
implementation(project(":core:network"))
implementation(project(":core:notification"))

implementation(project(":feature:splash"))
implementation(project(":feature:login"))
Expand Down
11 changes: 9 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<application
android:name=".NeveraApplication"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
Expand All @@ -18,12 +18,19 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Nevera">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Push notification deep link: nevera://{path} -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nevera" />
</intent-filter>
</activity>

</application>
Expand Down
22 changes: 17 additions & 5 deletions app/src/main/kotlin/com/anddd/nevera/NeveraApplication.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.anddd.nevera

import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import com.anddd.nevera.core.network.auth.SessionEventBus
import dagger.hilt.android.HiltAndroidApp
Expand All @@ -9,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import com.anddd.nevera.core.notification.R as NotificationR

@HiltAndroidApp
class NeveraApplication : Application() {
Expand All @@ -20,9 +23,20 @@ class NeveraApplication : Application() {

override fun onCreate() {
super.onCreate()
createNotificationChannel()
observeSessionExpired()
}

private fun createNotificationChannel() {
val channel = NotificationChannel(
getString(NotificationR.string.default_notification_channel_id),
getString(NotificationR.string.default_notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* 세션 만료 이벤트를 구독하여 앱 전체를 초기화한다.
*
Expand All @@ -33,11 +47,9 @@ class NeveraApplication : Application() {
private fun observeSessionExpired() {
applicationScope.launch {
sessionEventBus.sessionExpiredEvent.collect {
startActivity(
Intent(this@NeveraApplication, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
)
Intent(this@NeveraApplication, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}.let(::startActivity)
Comment thread
Minsu-Lee marked this conversation as resolved.
}
}
}
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.google.services) apply false
}
37 changes: 37 additions & 0 deletions core/notification/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}

android {
namespace = "com.anddd.nevera.core.notification"
compileSdk = 36

defaultConfig {
minSdk = 30
consumerProguardFiles("consumer-rules.pro")
}

buildFeatures {
// AGP 8.0부터는 BuildConfig가 기본 비활성화 상태
buildConfig = true
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

dependencies {
implementation(project(":domain"))
implementation(project(":core:common"))

implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.core.ktx)

implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
}
Empty file.
18 changes: 18 additions & 0 deletions core/notification/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<service
android:name=".NeveraMessagingService"
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_channel_id"
android:value="@string/default_notification_channel_id" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.anddd.nevera.core.notification

import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import com.anddd.nevera.domain.model.notification.logFcmSyncFailure
import com.anddd.nevera.domain.usecase.notification.UpdateFcmTokenUseCase
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
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 NeveraMessagingService : FirebaseMessagingService() {

@Inject lateinit var updateFcmTokenUseCase: UpdateFcmTokenUseCase

private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun onNewToken(token: String) {
super.onNewToken(token)
serviceScope.launch {
runCatching {
updateFcmTokenUseCase(token)
}.onSuccess { result ->
result.logFcmSyncFailure(TAG, BuildConfig.DEBUG, Log::w)
}.onFailure {
if (BuildConfig.DEBUG) {
Log.e(TAG, "FCM 토큰 업데이트 실패", it)
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
Minsu-Lee marked this conversation as resolved.
}

override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
var type = NotificationType.from(remoteMessage.data[NOTIFICATION_TYPE])
val title = remoteMessage.data[NOTIFICATION_TITLE]
val body = remoteMessage.data[NOTIFICATION_BODY]
val deepLink = remoteMessage.data[NOTIFICATION_DEEPLINK] ?: DEFAULT_DEEP_LINK

// TODO :: 현재 type과 deepLink가 전달되고 있지 않은 상황, 임시로 type을 default로 설정합니다.
type = NotificationType.DEFAULT
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

when (type) {
NotificationType.DEFAULT -> {
showNotification(
title = title,
body = body,
type = type,
deepLink = deepLink,
)
}
NotificationType.UNKNOWN -> {
if (BuildConfig.DEBUG) {
Log.e(TAG, "unknown type, $remoteMessage")
}
}
}
Comment thread
Minsu-Lee marked this conversation as resolved.
}

private fun showNotification(
title: String?,
body: String?,
type: NotificationType,
deepLink: String,
) {
val intent = Intent(Intent.ACTION_VIEW, deepLink.toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}

val pendingIntent = PendingIntent.getActivity(
this,
type.ordinal,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
Comment thread
Minsu-Lee marked this conversation as resolved.

val notificationBuilder = NotificationCompat.Builder(
this,
getString(R.string.default_notification_channel_id)
)

val notification = notificationBuilder.setContentTitle(title)
.setContentText(body)
.setSmallIcon(applicationInfo.icon)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()

val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(type.ordinal, notification)
Comment thread
Minsu-Lee marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}

companion object {
private const val TAG = "NeveraMessagingService"
private const val NOTIFICATION_TYPE = "type"
private const val NOTIFICATION_TITLE = "title"
private const val NOTIFICATION_BODY = "body"
private const val NOTIFICATION_DEEPLINK = "deepLink"
private const val DEFAULT_DEEP_LINK = "nevera://"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.anddd.nevera.core.notification

enum class NotificationType(val value: String) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
enum class NotificationType(val value: String) {
internal enum class NotificationType(val value: String) {

외부에서 참조할 이유가 없다면 internal를 쓰면 좋을 것 같아요

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@Jooman-Lee
반영하였습니다~

DEFAULT("default"),
UNKNOWN("unknown");

companion object {
fun from(value: String?): NotificationType =
entries.find { it.value == value } ?: UNKNOWN
}
}
5 changes: 5 additions & 0 deletions core/notification/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="default_notification_channel_name" translatable="false">Nevera</string>
<string name="default_notification_channel_id" translatable="false">nevera_default</string>
</resources>
4 changes: 4 additions & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ dependencies {
implementation(libs.okhttp.logging)
implementation(libs.datastore.preferences)

// Firebase Messaging
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)

Comment thread
Minsu-Lee marked this conversation as resolved.
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
Expand Down
15 changes: 15 additions & 0 deletions data/src/main/kotlin/com/anddd/nevera/data/api/NotificationApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.anddd.nevera.data.api

import com.anddd.nevera.core.network.model.ApiResponse
import com.anddd.nevera.data.model.notification.RegisterFcmTokenRequest
import com.anddd.nevera.data.model.notification.RegisterFcmTokenResponse
import retrofit2.http.Body
import retrofit2.http.POST

internal interface NotificationApi {

@POST("api/v1/notification/token")
suspend fun registerFcmToken(
@Body body: RegisterFcmTokenRequest
): ApiResponse<RegisterFcmTokenResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.anddd.nevera.data.datasource

internal interface FcmTokenLocalDataSource {
suspend fun getFcmToken(): String?
suspend fun saveFcmToken(token: String)
suspend fun isSyncNeeded(): Boolean
suspend fun setNeedsSync(value: Boolean)
suspend fun markTokenForSync(token: String)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.anddd.nevera.data.datasource

import android.content.Context
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 androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import javax.inject.Inject

private val Context.pushTokenDataStore: DataStore<Preferences> by preferencesDataStore(name = "push_token")

internal class FcmTokenLocalDataSourceImpl @Inject constructor(
@param:ApplicationContext private val context: Context,
) : FcmTokenLocalDataSource {

private val dataStore = context.pushTokenDataStore
Comment thread
coderabbitai[bot] marked this conversation as resolved.

override suspend fun getFcmToken(): String? =
dataStore.data.first()[KEY_FCM_TOKEN]

override suspend fun saveFcmToken(token: String) {
dataStore.edit { it[KEY_FCM_TOKEN] = token }
}

override suspend fun isSyncNeeded(): Boolean =
dataStore.data.first()[KEY_NEEDS_SYNC] ?: false

override suspend fun setNeedsSync(value: Boolean) {
dataStore.edit { it[KEY_NEEDS_SYNC] = value }
}

override suspend fun markTokenForSync(token: String) {
// DataStore.edit는 atomic transaction으로 동작한다.
// FCM 토큰 저장과 서버 동기화 필요 상태를 동시에 업데이트하여
// 토큰과 sync 상태 간의 불일치가 발생하지 않도록 한다.
dataStore.edit {
it[KEY_FCM_TOKEN] = token
it[KEY_NEEDS_SYNC] = true
}
}

companion object {
private val KEY_FCM_TOKEN = stringPreferencesKey("fcm_token")
private val KEY_NEEDS_SYNC = booleanPreferencesKey("fcm_token_needs_sync")
}
}
Loading
Loading