Skip to content

Commit cf4eab4

Browse files
authored
Merge pull request #216 from Team-Neki/feat/#211-fcm-push-notification
[feat] #211 FCM 푸시 알림 구현
2 parents a698d69 + cf5d795 commit cf4eab4

18 files changed

Lines changed: 232 additions & 13 deletions

File tree

app/build.gradle.kts

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

9292
implementation(platform(libs.firebase.bom))
9393
implementation(libs.firebase.crashlytics)
94+
implementation(libs.firebase.messaging)
9495

9596
implementation(libs.androidx.activity.compose)
9697
implementation(libs.androidx.navigation3.ui)

app/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@
5353
</intent-filter>
5454
</activity>
5555

56+
<meta-data
57+
android:name="com.google.firebase.messaging.default_notification_channel_id"
58+
android:value="neki_default_channel_id" />
59+
60+
<service
61+
android:name=".NekiFirebaseMessagingService"
62+
android:exported="false">
63+
<intent-filter>
64+
<action android:name="com.google.firebase.MESSAGING_EVENT" />
65+
</intent-filter>
66+
</service>
67+
5668
<activity
5769
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
5870
android:theme="@style/OssLicenses.Theme.OptOutEdgeToEdgeEnforcement" />

app/src/main/java/com/neki/android/app/NekiApplication.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.neki.android.app
22

33
import android.app.Application
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.content.Context
47
import com.kakao.sdk.common.KakaoSdk
58
import com.naver.maps.map.NaverMapSdk
69
import dagger.hilt.android.HiltAndroidApp
@@ -25,5 +28,16 @@ class NekiApplication : Application() {
2528

2629
NaverMapSdk.getInstance(this).client = NaverMapSdk.NcpKeyClient(BuildConfig.NAVER_MAP_CLIENT_ID)
2730
KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY)
31+
createNotificationChannel()
32+
}
33+
34+
private fun createNotificationChannel() {
35+
val channel = NotificationChannel(
36+
NekiFirebaseMessagingService.CHANNEL_ID,
37+
NekiFirebaseMessagingService.CHANNEL_NAME,
38+
NotificationManager.IMPORTANCE_HIGH,
39+
)
40+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
41+
manager.createNotificationChannel(channel)
2842
}
2943
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.neki.android.app
2+
3+
import android.app.NotificationManager
4+
import android.app.PendingIntent
5+
import android.content.Context
6+
import android.content.Intent
7+
import androidx.core.app.NotificationCompat
8+
import com.google.firebase.messaging.FirebaseMessagingService
9+
import com.google.firebase.messaging.RemoteMessage
10+
import com.neki.android.core.dataapi.repository.NotificationRepository
11+
import dagger.hilt.android.AndroidEntryPoint
12+
import kotlinx.coroutines.CoroutineScope
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.launch
15+
import javax.inject.Inject
16+
17+
@AndroidEntryPoint
18+
class NekiFirebaseMessagingService : FirebaseMessagingService() {
19+
20+
@Inject lateinit var notificationRepository: NotificationRepository
21+
22+
override fun onNewToken(token: String) {
23+
super.onNewToken(token)
24+
CoroutineScope(Dispatchers.IO).launch {
25+
notificationRepository.savePushToken(token)
26+
}
27+
}
28+
29+
override fun onMessageReceived(message: RemoteMessage) {
30+
super.onMessageReceived(message)
31+
val title = message.notification?.title ?: return
32+
val body = message.notification?.body ?: return
33+
showNotification(title, body)
34+
}
35+
36+
private fun showNotification(title: String, body: String) {
37+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
38+
39+
val pendingIntent = PendingIntent.getActivity(
40+
this,
41+
0,
42+
packageManager.getLaunchIntentForPackage(packageName) ?: Intent(),
43+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
44+
)
45+
46+
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
47+
.setSmallIcon(R.mipmap.ic_launcher)
48+
.setContentTitle(title)
49+
.setContentText(body)
50+
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
51+
.setAutoCancel(true)
52+
.setContentIntent(pendingIntent)
53+
.build()
54+
55+
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
56+
}
57+
58+
companion object {
59+
const val CHANNEL_ID = "neki_default_channel_id"
60+
const val CHANNEL_NAME = "Neki-Notification-Channel"
61+
}
62+
}

app/src/main/java/com/neki/android/app/main/MainContract.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ data class MainState(
1414
)
1515

1616
sealed interface MainIntent {
17+
data object EnterMainScreen : MainIntent
1718
data object ClickAddPhotoFab : MainIntent
1819
data object DismissAddPhotoBottomSheet : MainIntent
1920
data object ClickQRScan : MainIntent

app/src/main/java/com/neki/android/app/main/MainViewModel.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package com.neki.android.app.main
22

3+
import android.content.Context
34
import android.net.Uri
45
import androidx.lifecycle.ViewModel
56
import androidx.lifecycle.viewModelScope
7+
import com.neki.android.core.common.permission.NotificationPermissionManager
8+
import com.neki.android.core.dataapi.repository.NotificationRepository
69
import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase
710
import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase
811
import com.neki.android.core.ui.MviIntentStore
912
import com.neki.android.core.ui.mviIntentStore
1013
import com.neki.android.feature.select_album.api.SelectAlbumAction
1114
import dagger.hilt.android.lifecycle.HiltViewModel
15+
import dagger.hilt.android.qualifiers.ApplicationContext
1216
import kotlinx.collections.immutable.persistentListOf
1317
import kotlinx.collections.immutable.toImmutableList
1418
import kotlinx.coroutines.launch
@@ -17,6 +21,8 @@ import javax.inject.Inject
1721

1822
@HiltViewModel
1923
class MainViewModel @Inject constructor(
24+
@ApplicationContext private val context: Context,
25+
private val notificationRepository: NotificationRepository,
2026
private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase,
2127
private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase,
2228
) : ViewModel() {
@@ -27,13 +33,18 @@ class MainViewModel @Inject constructor(
2733
onIntent = ::onIntent,
2834
)
2935

36+
init {
37+
store.onIntent(MainIntent.EnterMainScreen)
38+
}
39+
3040
private fun onIntent(
3141
intent: MainIntent,
3242
state: MainState,
3343
reduce: (MainState.() -> MainState) -> Unit,
3444
postSideEffect: (MainSideEffect) -> Unit,
3545
) {
3646
when (intent) {
47+
MainIntent.EnterMainScreen -> updateNotificationToken()
3748
MainIntent.ClickAddPhotoFab -> reduce { copy(isShowAddPhotoBottomSheet = true) }
3849
MainIntent.DismissAddPhotoBottomSheet -> reduce { copy(isShowAddPhotoBottomSheet = false) }
3950
MainIntent.ClickQRScan -> {
@@ -94,6 +105,15 @@ class MainViewModel @Inject constructor(
94105
}
95106
}
96107

108+
private fun updateNotificationToken() {
109+
viewModelScope.launch {
110+
val token = notificationRepository.getPushToken() ?: return@launch
111+
val pushAgreed = NotificationPermissionManager.isGrantedNotificationPermission(context)
112+
notificationRepository.updateNotification(token, pushAgreed)
113+
.onFailure { Timber.e(it, "Failed to update notification token") }
114+
}
115+
}
116+
97117
private fun uploadSingleImage(
98118
imageUrl: String,
99119
reduce: (MainState.() -> MainState) -> Unit,

core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface AuthRepository {
1111

1212
suspend fun loginWithKakao(idToken: String): Result<Auth>
1313
suspend fun updateAccessToken(refreshToken: String): Result<Auth>
14+
suspend fun logout(): Result<Unit>
1415
suspend fun withdrawAccount(): Result<Unit>
1516

1617
fun hasCompletedOnboarding(): Flow<Boolean>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.neki.android.core.dataapi.repository
2+
3+
interface NotificationRepository {
4+
suspend fun savePushToken(token: String)
5+
suspend fun getPushToken(): String?
6+
suspend fun updateNotification(deviceToken: String, pushAgreed: Boolean): Result<Unit>
7+
}

core/data/src/main/java/com/neki/android/core/data/remote/api/AuthService.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ class AuthService @Inject constructor(
3232
return client.post("/api/auth/refresh") { setBody(requestBody) }.body()
3333
}
3434

35+
// 로그아웃
36+
suspend fun logout(): BasicNullableResponse<Unit> {
37+
return client.post("/api/users/logout").body()
38+
}
39+
3540
// 회원 탈퇴
3641
suspend fun withdrawAccount(): BasicNullableResponse<Unit> {
3742
return client.delete("/api/users/me").body()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.neki.android.core.data.remote.api
2+
3+
import com.neki.android.core.data.remote.model.request.UpdateNotificationRequest
4+
import com.neki.android.core.data.remote.model.response.BasicNullableResponse
5+
import io.ktor.client.HttpClient
6+
import io.ktor.client.call.body
7+
import io.ktor.client.request.patch
8+
import io.ktor.client.request.setBody
9+
import javax.inject.Inject
10+
11+
class NotificationService @Inject constructor(
12+
private val client: HttpClient,
13+
) {
14+
suspend fun updateNotification(request: UpdateNotificationRequest): BasicNullableResponse<Unit> =
15+
client.patch("/api/notifications") { setBody(request) }.body()
16+
}

0 commit comments

Comments
 (0)