Skip to content

Commit 89d1cfb

Browse files
authored
Merge pull request #141 from rbqks529/feat/#140_API_Notifications
[FEAT] 알림 센터 API 구현
2 parents 0eb78dd + 687c179 commit 89d1cfb

26 files changed

Lines changed: 982 additions & 174 deletions

app/src/main/AndroidManifest.xml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222
android:name="com.kakao.sdk.AppKey"
2323
android:value="${NATIVE_APP_KEY}" />
2424

25+
<!-- FCM 기본 설정 -->
26+
<meta-data
27+
android:name="com.google.firebase.messaging.default_notification_icon"
28+
android:resource="@mipmap/ic_launcher" />
29+
30+
<!-- FCM 기본 클릭 액션 설정 -->
31+
<meta-data
32+
android:name="com.google.firebase.messaging.default_notification_channel_id"
33+
android:value="thip_notifications" />
34+
2535
<activity
2636
android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity"
2737
android:exported="true">
@@ -39,20 +49,21 @@
3949
<activity
4050
android:name=".MainActivity"
4151
android:exported="true"
52+
android:launchMode="singleTop"
4253
android:windowSoftInputMode="adjustResize"
4354
android:label="@string/app_name"
4455
android:theme="@style/Theme.Thip">
4556
<intent-filter>
4657
<action android:name="android.intent.action.MAIN" />
47-
4858
<category android:name="android.intent.category.LAUNCHER" />
4959
</intent-filter>
5060
</activity>
5161

5262
<service
5363
android:name=".service.MyFirebaseMessagingService"
54-
android:exported="false">
55-
<intent-filter>
64+
android:exported="false"
65+
android:directBootAware="true">
66+
<intent-filter android:priority="1000">
5667
<action android:name="com.google.firebase.MESSAGING_EVENT" />
5768
</intent-filter>
5869
</service>

app/src/main/java/com/texthip/thip/MainActivity.kt

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package com.texthip.thip
22

3+
import android.content.Intent
34
import android.os.Bundle
5+
import android.util.Log
46
import androidx.activity.ComponentActivity
57
import androidx.activity.compose.setContent
68
import androidx.activity.enableEdgeToEdge
79
import androidx.activity.result.contract.ActivityResultContracts
810
import androidx.compose.runtime.Composable
911
import androidx.compose.runtime.LaunchedEffect
12+
import androidx.compose.runtime.getValue
13+
import androidx.compose.runtime.mutableStateOf
14+
import androidx.compose.runtime.setValue
1015
import androidx.navigation.compose.NavHost
1116
import androidx.navigation.compose.composable
1217
import androidx.navigation.compose.rememberNavController
@@ -32,21 +37,96 @@ class MainActivity : ComponentActivity() {
3237
ActivityResultContracts.RequestPermission()
3338
) {}
3439

40+
private var notificationData by mutableStateOf<NotificationData?>(null)
41+
42+
data class NotificationData(
43+
val notificationId: String?,
44+
val fromNotification: Boolean
45+
)
46+
3547
override fun onCreate(savedInstanceState: Bundle?) {
3648
super.onCreate(savedInstanceState)
3749
enableEdgeToEdge()
38-
50+
3951
// 앱 시작 시 알림 권한 요청
4052
requestNotificationPermissionIfNeeded()
41-
53+
54+
// 푸시 알림에서 온 데이터 처리
55+
handleNotificationIntent(intent)
56+
4257
setContent {
4358
ThipTheme {
44-
RootNavHost(authStateManager)
59+
RootNavHost(
60+
authStateManager = authStateManager,
61+
notificationData = notificationData
62+
)
4563
}
4664
}
4765
// getKakaoKeyHash(this)
4866
}
4967

68+
override fun onNewIntent(intent: Intent) {
69+
super.onNewIntent(intent)
70+
71+
// 새로운 Intent가 들어올 때 (백그라운드에서 알림 클릭 시)
72+
handleNotificationIntent(intent)
73+
}
74+
75+
private fun handleNotificationIntent(intent: Intent) {
76+
Log.d("MainActivity", "Handling notification intent with extras: ${intent.extras?.keySet()}")
77+
78+
val customNotificationId = intent.getStringExtra("notification_id")
79+
val customFromNotification = intent.getBooleanExtra("from_notification", false)
80+
81+
// FCM 백그라운드 알림에서 온 데이터 확인 (시스템이 자동 생성한 알림의 경우)
82+
val fcmNotificationId = intent.getStringExtra("gcm.notification.data.notificationId")
83+
?: intent.getStringExtra("notificationId")
84+
85+
var newNotificationData: NotificationData? = null
86+
87+
// 커스텀 알림에서 온 경우 (포그라운드에서 생성된 알림)
88+
if (customFromNotification && customNotificationId != null) {
89+
Log.d("MainActivity", "Processing custom notification: $customNotificationId")
90+
newNotificationData = NotificationData(customNotificationId, customFromNotification)
91+
92+
// Intent extras 완전 제거
93+
cleanupNotificationExtras(intent, listOf("notification_id", "from_notification"))
94+
}
95+
// FCM 백그라운드 시스템 알림에서 온 경우
96+
else if (fcmNotificationId != null) {
97+
Log.d("MainActivity", "Processing FCM notification: $fcmNotificationId")
98+
newNotificationData = NotificationData(fcmNotificationId, true)
99+
100+
// Intent extras 완전 제거
101+
cleanupNotificationExtras(intent, listOf(
102+
"gcm.notification.data.notificationId",
103+
"notificationId"
104+
))
105+
}
106+
107+
// 새로운 알림 데이터가 있고, 기존 데이터와 다른 경우에만 업데이트
108+
if (newNotificationData != null && newNotificationData != notificationData) {
109+
Log.d("MainActivity", "Setting new notification data: ${newNotificationData.notificationId}")
110+
notificationData = newNotificationData
111+
} else if (newNotificationData != null) {
112+
Log.d("MainActivity", "Notification data unchanged, skipping update")
113+
}
114+
}
115+
116+
private fun cleanupNotificationExtras(intent: Intent, keys: List<String>) {
117+
keys.forEach { key ->
118+
try {
119+
intent.removeExtra(key)
120+
Log.v("MainActivity", "Removed extra: $key")
121+
} catch (e: Exception) {
122+
Log.w("MainActivity", "Failed to remove extra: $key", e)
123+
}
124+
}
125+
126+
// Intent 플래그도 정리
127+
intent.replaceExtras(intent.extras)
128+
}
129+
50130
private fun requestNotificationPermissionIfNeeded() {
51131
if (NotificationPermissionUtils.shouldRequestNotificationPermission(this)) {
52132
notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
@@ -55,7 +135,10 @@ class MainActivity : ComponentActivity() {
55135
}
56136

57137
@Composable
58-
fun RootNavHost(authStateManager: AuthStateManager) {
138+
fun RootNavHost(
139+
authStateManager: AuthStateManager,
140+
notificationData: MainActivity.NotificationData? = null
141+
) {
59142
val navController = rememberNavController()
60143

61144
LaunchedEffect(Unit) {
@@ -66,6 +149,7 @@ fun RootNavHost(authStateManager: AuthStateManager) {
66149
}
67150
}
68151

152+
69153
NavHost(
70154
navController = navController,
71155
startDestination = CommonRoutes.Splash
@@ -104,7 +188,8 @@ fun RootNavHost(authStateManager: AuthStateManager) {
104188
inclusive = true
105189
}
106190
}
107-
}
191+
},
192+
notificationData = notificationData
108193
)
109194
}
110195
}

app/src/main/java/com/texthip/thip/MainScreen.kt

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,94 @@ package com.texthip.thip
33
import androidx.compose.foundation.layout.Box
44
import androidx.compose.foundation.layout.padding
55
import androidx.compose.material3.Scaffold
6+
import android.util.Log
67
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.LaunchedEffect
79
import androidx.compose.runtime.getValue
810
import androidx.compose.runtime.mutableStateOf
911
import androidx.compose.runtime.remember
1012
import androidx.compose.runtime.setValue
1113
import androidx.compose.ui.Modifier
1214
import androidx.compose.ui.graphics.Color
15+
import androidx.compose.ui.platform.LocalContext
1316
import androidx.navigation.compose.currentBackStackEntryAsState
1417
import androidx.navigation.compose.rememberNavController
18+
import com.texthip.thip.data.repository.NotificationRepository
1519
import com.texthip.thip.ui.navigator.BottomNavigationBar
1620
import com.texthip.thip.ui.navigator.MainNavHost
1721
import com.texthip.thip.ui.navigator.extensions.isMainTabRoute
22+
import com.texthip.thip.ui.navigator.extensions.navigateFromNotification
1823
import com.texthip.thip.ui.navigator.routes.MainTabRoutes
24+
import dagger.hilt.EntryPoint
25+
import dagger.hilt.InstallIn
26+
import dagger.hilt.android.EntryPointAccessors
27+
import dagger.hilt.components.SingletonComponent
28+
29+
@EntryPoint
30+
@InstallIn(SingletonComponent::class)
31+
interface MainScreenEntryPoint {
32+
fun notificationRepository(): NotificationRepository
33+
}
1934

2035
@Composable
2136
fun MainScreen(
22-
onNavigateToLogin: () -> Unit
37+
onNavigateToLogin: () -> Unit,
38+
notificationData: MainActivity.NotificationData? = null
2339
) {
2440
val navController = rememberNavController()
2541
val navBackStackEntry by navController.currentBackStackEntryAsState()
2642
val currentDestination = navBackStackEntry?.destination
2743
var feedReselectionTrigger by remember { mutableStateOf(0) }
44+
val context = LocalContext.current
45+
46+
// 처리된 알림 ID 추적
47+
var processedNotificationId by remember { mutableStateOf<String?>(null) }
48+
49+
// 푸시 알림에서 온 경우 알림 읽기 API 호출 및 네비게이션
50+
LaunchedEffect(notificationData?.notificationId, notificationData?.fromNotification) {
51+
val data = notificationData
52+
53+
// 중복 처리 방지
54+
if (data?.notificationId == processedNotificationId) {
55+
return@LaunchedEffect
56+
}
57+
58+
data?.let { notificationData ->
59+
if (notificationData.fromNotification && notificationData.notificationId != null) {
60+
try {
61+
val entryPoint = EntryPointAccessors.fromApplication(
62+
context.applicationContext,
63+
MainScreenEntryPoint::class.java
64+
)
65+
val notificationRepository = entryPoint.notificationRepository()
66+
67+
val notificationId = try {
68+
notificationData.notificationId.toInt()
69+
} catch (e: NumberFormatException) {
70+
Log.e("MainScreen", "Invalid notification ID format: ${notificationData.notificationId}", e)
71+
return@LaunchedEffect
72+
}
73+
74+
val result = notificationRepository.checkNotification(notificationId)
75+
76+
result.onSuccess { response ->
77+
if (response != null) {
78+
navController.navigateFromNotification(response)
79+
notificationRepository.onNotificationReceived()
80+
processedNotificationId = notificationData.notificationId
81+
} else {
82+
Log.w("MainScreen", "Notification check returned null response")
83+
}
84+
}.onFailure { exception ->
85+
Log.e("MainScreen", "Failed to check notification: ${notificationData.notificationId}", exception)
86+
}
87+
88+
} catch (e: Exception) {
89+
Log.e("MainScreen", "Unexpected error processing notification: ${notificationData.notificationId}", e)
90+
}
91+
}
92+
}
93+
}
2894

2995
val showBottomBar = currentDestination?.isMainTabRoute() ?: true
3096

app/src/main/java/com/texthip/thip/data/manager/FcmTokenManager.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
99
import com.google.firebase.messaging.FirebaseMessaging
1010
import com.texthip.thip.data.repository.NotificationRepository
1111
import com.texthip.thip.utils.auth.getAppScopeDeviceId
12-
import com.texthip.thip.utils.permission.NotificationPermissionUtils
1312
import dagger.hilt.android.qualifiers.ApplicationContext
1413
import kotlinx.coroutines.flow.first
1514
import kotlinx.coroutines.flow.map
@@ -93,12 +92,6 @@ class FcmTokenManager @Inject constructor(
9392
}
9493

9594
private suspend fun sendTokenToServer(token: String) {
96-
// 알림 권한이 없으면 토큰을 서버에 전송하지 않음
97-
if (!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
98-
Log.w("FCM", "Notification permission not granted, skipping token registration")
99-
return
100-
}
101-
10295
val deviceId = context.getAppScopeDeviceId()
10396
notificationRepository.registerFcmToken(deviceId, token)
10497
.onSuccess {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.texthip.thip.data.model.notification.request
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class NotificationCheckRequest(
8+
@SerialName("notificationId") val notificationId: Int
9+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.texthip.thip.data.model.notification.response
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
import kotlinx.serialization.json.JsonElement
6+
7+
@Serializable
8+
data class NotificationCheckResponse(
9+
@SerialName("route") val route: NotificationRoute,
10+
@SerialName("params") val params: Map<String, JsonElement>
11+
)
12+
13+
@Serializable
14+
enum class NotificationRoute {
15+
@SerialName("FEED_USER")
16+
FEED_USER,
17+
18+
@SerialName("FEED_DETAIL")
19+
FEED_DETAIL,
20+
21+
@SerialName("ROOM_MAIN")
22+
ROOM_MAIN,
23+
24+
@SerialName("ROOM_DETAIL")
25+
ROOM_DETAIL,
26+
27+
@SerialName("ROOM_POST_DETAIL")
28+
ROOM_POST_DETAIL
29+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.texthip.thip.data.model.notification.response
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class NotificationListResponse(
8+
@SerialName("notifications") val notifications: List<NotificationResponse>,
9+
@SerialName("nextCursor") val nextCursor: String?,
10+
@SerialName("isLast") val isLast: Boolean
11+
)
12+
13+
@Serializable
14+
data class NotificationResponse(
15+
@SerialName("notificationId") val notificationId: Int,
16+
@SerialName("title") val title: String,
17+
@SerialName("content") val content: String,
18+
@SerialName("isChecked") val isChecked: Boolean,
19+
@SerialName("notificationType") val notificationType: String,
20+
@SerialName("postDate") val postDate: String
21+
)

0 commit comments

Comments
 (0)