diff --git a/.github/workflows/pr_checker.yml b/.github/workflows/pr_checker.yml
index 7d0af138..1014262d 100644
--- a/.github/workflows/pr_checker.yml
+++ b/.github/workflows/pr_checker.yml
@@ -42,5 +42,11 @@ jobs:
echo "base.url=\"https://temp.example.com\"" > local.properties
fi
+ - name: Create google-services.json
+ env:
+ GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
+ run: |
+ echo "$GOOGLE_SERVICES_JSON" > app/google-services.json
+
- name: Build Debug APK
run: ./gradlew :app:assembleDebug
diff --git a/.gitignore b/.gitignore
index faf530b2..32bdba73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@
.externalNativeBuild
.cxx
local.properties
+google-services.json
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 14168c62..d192ac33 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.google.services)
}
val properties = Properties().apply {
@@ -95,4 +96,9 @@ dependencies {
// Kakao
implementation(libs.kakao.user)
implementation(libs.kakao.map)
+
+ // Firebase
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.messaging)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d22a04f1..fcb49afd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
@@ -56,6 +57,23 @@
android:scheme="kakao${KAKAO_NATIVE_APP_KEY}" />
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/moive/app/core/fcm/FirebaseMessagingManager.kt b/app/src/main/java/com/moive/app/core/fcm/FirebaseMessagingManager.kt
new file mode 100644
index 00000000..e2b52e72
--- /dev/null
+++ b/app/src/main/java/com/moive/app/core/fcm/FirebaseMessagingManager.kt
@@ -0,0 +1,49 @@
+package com.moive.app.core.fcm
+
+import com.google.firebase.installations.FirebaseInstallations
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.suspendCancellableCoroutine
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.coroutines.resume
+
+class FirebaseMessagingManager @Inject constructor() {
+
+ //서버 연결
+ suspend fun getFcmToken(): String? = suspendCancellableCoroutine { continuation ->
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
+ if (continuation.isActive) {
+ if (!task.isSuccessful) {
+ Timber.tag(TAG).e("FCM Token 불러오기 실패했습니다. ${task.exception}")
+ continuation.resume(null)
+ return@addOnCompleteListener
+ }
+
+ val token = task.result
+ Timber.tag(TAG).d("토큰 : $token")
+ continuation.resume(token)
+ }
+ }
+ }
+
+ //디바이스 ID
+ suspend fun getInstallationId(): String? = suspendCancellableCoroutine { continuation ->
+ FirebaseInstallations.getInstance().id.addOnCompleteListener { task ->
+ if (continuation.isActive) {
+ if (!task.isSuccessful) {
+ Timber.tag(TAG).e("FireBaseInstallations id 불러오기 실패했습니다 : ${task.exception}")
+ continuation.resume(null)
+ return@addOnCompleteListener
+ }
+
+ val installationId = task.result
+ Timber.tag(TAG).d("device ID : $installationId")
+ continuation.resume(installationId)
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "FCM"
+ }
+}
diff --git a/app/src/main/java/com/moive/app/core/fcm/MoiveFirebaseMessagingService.kt b/app/src/main/java/com/moive/app/core/fcm/MoiveFirebaseMessagingService.kt
new file mode 100644
index 00000000..705650fa
--- /dev/null
+++ b/app/src/main/java/com/moive/app/core/fcm/MoiveFirebaseMessagingService.kt
@@ -0,0 +1,98 @@
+package com.moive.app.core.fcm
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import com.moive.app.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import timber.log.Timber
+
+class MoiveFirebaseMessagingService: FirebaseMessagingService() {
+
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onNewToken(token: String) {
+ super.onNewToken(token)
+
+ Timber.tag(TAG).d("token: $token")
+ //Todo: 서버 토큰 전달
+ }
+
+ override fun onDestroy() {
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+ super.onMessageReceived(remoteMessage)
+
+ val title = remoteMessage.data[MESSAGE_TITLE] ?: return
+ val body = remoteMessage.data[MESSAGE_BODY]
+ val meetingId = remoteMessage.data[MESSAGE_MEETING_ID]?.toLongOrNull()
+
+ showNotification(title, body, meetingId)
+ }
+
+ private fun showNotification(title: String, body: String?, meetingId: Long?) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ enableVibration(true)
+ }
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+
+ val granted = ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (!granted) return
+
+ val notificationId = System.currentTimeMillis().toInt()
+
+ val contentIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ meetingId?.let { putExtra(MESSAGE_MEETING_ID, it) }
+ } ?: return
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ notificationId,
+ contentIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.img_logo_white_bg)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .build()
+
+ NotificationManagerCompat.from(this).notify(notificationId, notification)
+ }
+
+ companion object {
+ private const val TAG = "FCM"
+ const val MESSAGE_MEETING_ID = "meetingId"
+ private const val MESSAGE_TITLE = "title"
+ private const val MESSAGE_BODY = "body"
+ private const val CHANNEL_ID = "moive_default_channel"
+ private const val CHANNEL_NAME = "일반 알림"
+ }
+}
diff --git a/app/src/main/java/com/moive/app/presentation/main/MainActivity.kt b/app/src/main/java/com/moive/app/presentation/main/MainActivity.kt
index fbe1b3c4..87df3e69 100644
--- a/app/src/main/java/com/moive/app/presentation/main/MainActivity.kt
+++ b/app/src/main/java/com/moive/app/presentation/main/MainActivity.kt
@@ -8,10 +8,12 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import com.moive.app.core.designsystem.theme.MoiveTheme
+import com.moive.app.core.fcm.MoiveFirebaseMessagingService
import com.moive.app.core.network.token.AuthManager
import com.moive.app.data.meeting.repository.MeetingRepository
import com.moive.app.presentation.login.navigation.navigateToLogin
import com.moive.app.presentation.meeting.detail.navigation.navigateToMeetingDetail
+import com.moive.app.presentation.notification.NotificationPermissionManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@@ -29,11 +31,17 @@ class MainActivity : ComponentActivity() {
private val pendingInviteCode = mutableStateOf(null)
+ private val pendingMeetingId = mutableStateOf(null)
+
+ private val notificationPermissionManager = NotificationPermissionManager(this)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
pendingInviteCode.value = intent.extractInviteCode()
- setIntent(Intent())
+ pendingMeetingId.value = intent.extractMeetingId()
+ ?: savedInstanceState?.getLong(KEY_PENDING_MEETING_ID, -1L)?.takeIf { it != -1L }
+ intent = Intent()
setContent {
MoiveTheme {
val appState = rememberMainAppState()
@@ -44,6 +52,11 @@ class MainActivity : ComponentActivity() {
}
}
+ LaunchedEffect(Unit) {
+ appState.isSignedIn.first { it }
+ notificationPermissionManager.askNotificationPermission()
+ }
+
LaunchedEffect(pendingInviteCode.value) {
val inviteCode = pendingInviteCode.value ?: return@LaunchedEffect
@@ -60,6 +73,15 @@ class MainActivity : ComponentActivity() {
pendingInviteCode.value = null
}
+ LaunchedEffect(pendingMeetingId.value) {
+ val meetingId = pendingMeetingId.value ?: return@LaunchedEffect
+
+ appState.isSignedIn.first { it }
+
+ appState.navController.navigateToMeetingDetail(meetingId)
+ pendingMeetingId.value = null
+ }
+
MainScreen(
appState = appState,
)
@@ -70,13 +92,26 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendingInviteCode.value = intent.extractInviteCode()
+ pendingMeetingId.value = intent.extractMeetingId()
setIntent(Intent())
}
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ pendingMeetingId.value?.let { outState.putLong(KEY_PENDING_MEETING_ID, it) }
+ }
+
companion object {
private const val TAG = "Invite"
+ private const val KEY_PENDING_MEETING_ID = "pendingMeetingId"
}
}
private fun Intent.extractInviteCode(): String? =
takeIf { it.action == Intent.ACTION_VIEW }?.data?.lastPathSegment
+
+private fun Intent.extractMeetingId(): Long? {
+ val meetingId = getLongExtra(MoiveFirebaseMessagingService.MESSAGE_MEETING_ID, -1L)
+ if (meetingId != -1L) removeExtra(MoiveFirebaseMessagingService.MESSAGE_MEETING_ID)
+ return meetingId.takeIf { it != -1L }
+}
diff --git a/app/src/main/java/com/moive/app/presentation/notification/NotificationPermissionManager.kt b/app/src/main/java/com/moive/app/presentation/notification/NotificationPermissionManager.kt
new file mode 100644
index 00000000..ba7dc1b2
--- /dev/null
+++ b/app/src/main/java/com/moive/app/presentation/notification/NotificationPermissionManager.kt
@@ -0,0 +1,40 @@
+package com.moive.app.presentation.notification
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.ComponentActivity
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import timber.log.Timber
+
+class NotificationPermissionManager(
+ private val activity: ComponentActivity
+) {
+
+ private val requestPermissionLauncher = activity.registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { isGranted ->
+ if (!isGranted) {
+ Timber.tag(TAG).d(NOTIFICATION_MESSAGE)
+ }
+ }
+
+ fun askNotificationPermission() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
+
+ val granted = ContextCompat.checkSelfPermission(
+ activity,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (!granted) {
+ requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ companion object {
+ private const val TAG = "Notification"
+ private const val NOTIFICATION_MESSAGE = "알림을 표시할 수 없습니다."
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 4157e5dd..4fe4b346 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,4 +6,5 @@ plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.kotlin.parcelize) apply false
+ alias(libs.plugins.google.services) apply false
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7edc6597..711c79c2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -43,6 +43,10 @@ timber = "5.0.1"
kakaoUser = "2.24.0"
kakaoMap = "2.15.1"
+# Firebase
+googleServices = "4.5.0"
+firebaseBom = "34.19.0"
+
# Test
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -96,6 +100,11 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakaoUser" }
kakao-map = { group = "com.kakao.maps.open", name = "android", version.ref = "kakaoMap" }
+# Firebase
+firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
+firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
+
# Test
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@@ -109,7 +118,7 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
-
+google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
[bundles]
androidx-core = [