Skip to content

Commit c846285

Browse files
authored
Merge pull request #204 from YAPP-Github/BOOK-364-feature/#193
feat: 푸시 알림 클라이언트 로직 구현 및 서버 API 연동
2 parents 6914f44 + 1e0795b commit c846285

File tree

29 files changed

+489
-35
lines changed

29 files changed

+489
-35
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
<uses-permission android:name="android.permission.INTERNET" />
1010
<uses-permission android:name="android.permission.CAMERA" />
11-
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
11+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1212

1313
<application
1414
android:name=".BooketApplication"
@@ -41,6 +41,10 @@
4141
android:name="com.ninecraft.booket.initializer.FirebaseCrashlyticsInitializer"
4242
android:value="androidx.startup" />
4343

44+
<meta-data
45+
android:name="com.ninecraft.booket.initializer.NotificationChannelInitializer"
46+
android:value="androidx.startup" />
47+
4448
</provider>
4549

4650
<activity
@@ -73,6 +77,19 @@
7377
tools:replace="android:resource" />
7478
</provider>
7579

80+
<service
81+
android:name=".ReedFirebaseMessagingService"
82+
android:exported="false">
83+
<intent-filter>
84+
<action android:name="com.google.firebase.MESSAGING_EVENT" />
85+
</intent-filter>
86+
</service>
87+
88+
<meta-data
89+
android:name="com.google.firebase.messaging.default_notification_icon"
90+
android:resource="@drawable/ic_notification" />
91+
<meta-data
92+
android:name="com.google.firebase.messaging.default_notification_color"
93+
android:resource="@color/green_500" />
7694
</application>
77-
7895
</manifest>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.ninecraft.booket
2+
3+
import android.app.NotificationChannel
4+
import android.app.NotificationManager
5+
import android.app.PendingIntent
6+
import android.content.Context
7+
import android.content.Intent
8+
import androidx.core.app.NotificationCompat
9+
import androidx.core.content.ContextCompat
10+
import com.google.firebase.messaging.FirebaseMessagingService
11+
import com.google.firebase.messaging.RemoteMessage
12+
import com.ninecraft.booket.core.data.api.repository.UserRepository
13+
import com.ninecraft.booket.core.designsystem.R
14+
import com.ninecraft.booket.feature.main.MainActivity
15+
import dagger.hilt.android.AndroidEntryPoint
16+
import kotlinx.coroutines.CoroutineScope
17+
import kotlinx.coroutines.Dispatchers
18+
import kotlinx.coroutines.SupervisorJob
19+
import kotlinx.coroutines.cancel
20+
import kotlinx.coroutines.launch
21+
import javax.inject.Inject
22+
23+
@AndroidEntryPoint
24+
class ReedFirebaseMessagingService : FirebaseMessagingService() {
25+
26+
@Inject
27+
lateinit var userRepository: UserRepository
28+
29+
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
30+
31+
override fun onNewToken(token: String) {
32+
super.onNewToken(token)
33+
34+
scope.launch {
35+
userRepository.syncFcmToken(token)
36+
}
37+
}
38+
39+
override fun onMessageReceived(message: RemoteMessage) {
40+
super.onMessageReceived(message)
41+
42+
val title = message.notification?.title ?: "Reed"
43+
val body = message.notification?.body ?: ""
44+
45+
val intent = Intent(this, MainActivity::class.java).apply {
46+
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
47+
}
48+
49+
val pendingIntent = PendingIntent.getActivity(
50+
this,
51+
0,
52+
intent,
53+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
54+
)
55+
56+
val builder = NotificationCompat.Builder(this, REED_CHANNEL_ID)
57+
.setSmallIcon(R.drawable.ic_notification)
58+
.setColor(ContextCompat.getColor(this, R.color.green_500))
59+
.setContentTitle(title)
60+
.setContentText(body)
61+
.setContentIntent(pendingIntent)
62+
.setAutoCancel(true)
63+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
64+
65+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
66+
manager.notify(System.currentTimeMillis().toInt(), builder.build())
67+
}
68+
69+
override fun onDestroy() {
70+
scope.cancel()
71+
super.onDestroy()
72+
}
73+
74+
companion object {
75+
private const val REED_CHANNEL_ID = "REED_PUSH_CHANNEL"
76+
private const val REED_CHANNEL_NAME = "리드 푸시 알림"
77+
private const val REED_CHANNEL_DESC = "리드 앱에서 보내는 푸시 알림을 관리합니다."
78+
79+
// Android 8.0 이상 필수 채널 생성
80+
fun createNotificationChannel(context: Context) {
81+
val channel = NotificationChannel(
82+
REED_CHANNEL_ID,
83+
REED_CHANNEL_NAME,
84+
NotificationManager.IMPORTANCE_DEFAULT,
85+
).apply {
86+
description = REED_CHANNEL_DESC
87+
}
88+
89+
val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
90+
manager.createNotificationChannel(channel)
91+
}
92+
}
93+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.ninecraft.booket.initializer
2+
3+
import android.content.Context
4+
import androidx.startup.Initializer
5+
import com.ninecraft.booket.ReedFirebaseMessagingService.Companion.createNotificationChannel
6+
7+
class NotificationChannelInitializer : Initializer<Unit> {
8+
9+
override fun create(context: Context) {
10+
createNotificationChannel(context)
11+
}
12+
13+
override fun dependencies(): List<Class<out Initializer<*>>> {
14+
return emptyList()
15+
}
16+
}

build-logic/src/main/kotlin/AndroidFirebaseConventionPlugin.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ internal class AndroidFirebaseConventionPlugin : Plugin<Project> {
1919
implementation(platform(libs.firebase.bom))
2020
implementation(libs.firebase.analytics)
2121
implementation(libs.firebase.crashlytics)
22+
implementation(libs.firebase.messaging)
2223
}
2324
}
2425
}

core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/UserRepository.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ interface UserRepository {
1414

1515
suspend fun setOnboardingCompleted(isCompleted: Boolean)
1616

17-
val isNotificationEnabled: Flow<Boolean>
17+
suspend fun syncFcmToken(): Result<Unit>
1818

19-
suspend fun setNotificationEnabled(isEnabled: Boolean)
19+
suspend fun syncFcmToken(fcmToken: String): Result<Unit>
20+
21+
val isUserNotificationEnabled: Flow<Boolean>
22+
23+
suspend fun getUserNotificationEnabled(): Boolean
24+
25+
suspend fun setUserNotificationEnabled(isEnabled: Boolean)
26+
27+
suspend fun getLastSyncedNotificationEnabled(): Boolean?
28+
29+
suspend fun setLastNotificationSyncedEnabled(isEnabled: Boolean)
30+
31+
suspend fun updateNotificationSettings(notificationEnabled: Boolean): Result<UserProfileModel>
2032
}

core/data/impl/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies {
2828

2929
platform(libs.firebase.bom),
3030
libs.firebase.remote.config,
31+
libs.firebase.messaging,
3132
libs.logger,
3233
)
3334
}

core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.ninecraft.booket.core.data.impl.di
22

33
import com.google.firebase.Firebase
4+
import com.google.firebase.messaging.FirebaseMessaging
5+
import com.google.firebase.messaging.messaging
46
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
57
import com.google.firebase.remoteconfig.remoteConfig
68
import com.google.firebase.remoteconfig.remoteConfigSettings
@@ -26,4 +28,8 @@ internal object FirebaseModule {
2628
setConfigSettingsAsync(configSettings)
2729
}
2830
}
31+
32+
@Singleton
33+
@Provides
34+
fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging
2935
}

core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ internal fun UserProfileResponse.toModel(): UserProfileModel {
4949
nickname = nickname,
5050
provider = provider,
5151
termsAgreed = termsAgreed,
52+
notificationEnabled = notificationEnabled,
5253
)
5354
}
5455

core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultAuthRepository.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.ninecraft.booket.core.data.impl.repository
22

33
import com.ninecraft.booket.core.common.utils.runSuspendCatching
44
import com.ninecraft.booket.core.data.api.repository.AuthRepository
5+
import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource
56
import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource
67
import com.ninecraft.booket.core.model.AutoLoginState
78
import com.ninecraft.booket.core.model.UserState
@@ -15,6 +16,7 @@ private const val KAKAO_PROVIDER_TYPE = "KAKAO"
1516
internal class DefaultAuthRepository @Inject constructor(
1617
private val service: ReedService,
1718
private val tokenDataSource: TokenDataSource,
19+
private val notificationDataSource: NotificationDataSource,
1820
) : AuthRepository {
1921
override suspend fun login(accessToken: String) = runSuspendCatching {
2022
val response = service.login(
@@ -29,6 +31,7 @@ internal class DefaultAuthRepository @Inject constructor(
2931
override suspend fun logout() = runSuspendCatching {
3032
service.logout()
3133
clearTokens()
34+
clearNotificationDataStore()
3235
}
3336

3437
override suspend fun withdraw() = runSuspendCatching {
@@ -61,4 +64,8 @@ internal class DefaultAuthRepository @Inject constructor(
6164
val accessToken = tokenDataSource.getAccessToken()
6265
return if (accessToken.isBlank()) UserState.Guest else UserState.LoggedIn
6366
}
67+
68+
private suspend fun clearNotificationDataStore() {
69+
notificationDataSource.clearNotificationDataStore()
70+
}
6471
}

core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultUserRepository.kt

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
package com.ninecraft.booket.core.data.impl.repository
22

3+
import com.google.firebase.messaging.FirebaseMessaging
34
import com.ninecraft.booket.core.common.utils.runSuspendCatching
45
import com.ninecraft.booket.core.data.api.repository.UserRepository
56
import com.ninecraft.booket.core.data.impl.mapper.toModel
67
import com.ninecraft.booket.core.datastore.api.datasource.NotificationDataSource
78
import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource
9+
import com.ninecraft.booket.core.network.request.FcmTokenRequest
10+
import com.ninecraft.booket.core.network.request.NotificationSettingsRequest
811
import com.ninecraft.booket.core.network.request.TermsAgreementRequest
912
import com.ninecraft.booket.core.network.service.ReedService
13+
import com.orhanobut.logger.Logger
14+
import kotlinx.coroutines.flow.first
15+
import kotlinx.coroutines.flow.firstOrNull
16+
import kotlinx.coroutines.tasks.await
1017
import javax.inject.Inject
1118

1219
internal class DefaultUserRepository @Inject constructor(
1320
private val service: ReedService,
1421
private val onboardingDataSource: OnboardingDataSource,
1522
private val notificationDataSource: NotificationDataSource,
23+
private val firebaseMessaging: FirebaseMessaging,
1624
) : UserRepository {
1725
override suspend fun agreeTerms(termsAgreed: Boolean) = runSuspendCatching {
1826
service.agreeTerms(TermsAgreementRequest(termsAgreed)).toModel()
@@ -28,9 +36,59 @@ internal class DefaultUserRepository @Inject constructor(
2836
onboardingDataSource.setOnboardingCompleted(isCompleted)
2937
}
3038

31-
override val isNotificationEnabled = notificationDataSource.isNotificationEnabled
39+
override suspend fun syncFcmToken() = runSuspendCatching {
40+
val newToken = getRemoteFcmToken()
41+
val localToken = getLocalFcmToken()
3242

33-
override suspend fun setNotificationEnabled(isEnabled: Boolean) {
34-
notificationDataSource.setNotificationEnabled(isEnabled)
43+
if (newToken == localToken) {
44+
Logger.d("Skip FCM token sync (already up-to-date)")
45+
return@runSuspendCatching
46+
}
47+
48+
updateFcmToken(newToken)
49+
setFcmToken(newToken)
50+
}
51+
52+
override suspend fun syncFcmToken(fcmToken: String): Result<Unit> = runSuspendCatching {
53+
updateFcmToken(fcmToken)
54+
setFcmToken(fcmToken)
55+
}
56+
57+
override val isUserNotificationEnabled = notificationDataSource.isUserNotificationEnabled
58+
59+
override suspend fun getUserNotificationEnabled(): Boolean = isUserNotificationEnabled.first()
60+
61+
override suspend fun setUserNotificationEnabled(isEnabled: Boolean) {
62+
notificationDataSource.setUserNotificationEnabled(isEnabled)
63+
}
64+
65+
override suspend fun getLastSyncedNotificationEnabled(): Boolean? =
66+
notificationDataSource.lastSyncedNotificationEnabled.firstOrNull()
67+
68+
override suspend fun setLastNotificationSyncedEnabled(isEnabled: Boolean) {
69+
notificationDataSource.setLastSyncedNotificationEnabled(isEnabled)
70+
}
71+
72+
override suspend fun updateNotificationSettings(notificationEnabled: Boolean) = runSuspendCatching {
73+
service.updateNotificationSettings(NotificationSettingsRequest(notificationEnabled)).toModel()
74+
}
75+
76+
private suspend fun getRemoteFcmToken(): String {
77+
return try {
78+
firebaseMessaging.token.await()
79+
} catch (e: Exception) {
80+
Logger.e("Failed to fetch FCM token: ${e.message}")
81+
throw e
82+
}
83+
}
84+
85+
private suspend fun getLocalFcmToken(): String = notificationDataSource.fcmToken.first()
86+
87+
private suspend fun setFcmToken(fcmToken: String) {
88+
notificationDataSource.setFcmToken(fcmToken)
89+
}
90+
91+
private suspend fun updateFcmToken(fcmToken: String) {
92+
service.updateFcmToken(FcmTokenRequest(fcmToken))
3593
}
3694
}

0 commit comments

Comments
 (0)