From e8574252ca5d0e540fff71cad65187d043f461d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 14:44:28 +0300 Subject: [PATCH] android: add notifications foundation with fcm channels and deep links --- android/CHANGELOG.md | 8 +++ android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 8 +++ .../ru/daemonlord/messenger/MainActivity.kt | 32 +++++++++++ .../messenger/MessengerApplication.kt | 8 ++- .../notifications/NotificationChannels.kt | 41 ++++++++++++++ .../notifications/NotificationDispatcher.kt | 55 +++++++++++++++++++ .../core/notifications/NotificationModels.kt | 14 +++++ .../push/MessengerFirebaseMessagingService.kt | 24 ++++++++ .../messenger/push/PushPayloadParser.kt | 42 ++++++++++++++ .../messenger/ui/navigation/AppNavGraph.kt | 45 +++++++++++++-- .../messenger/push/PushPayloadParserTest.kt | 31 +++++++++++ docs/android-checklist.md | 6 +- docs/android-smoke.md | 1 + 14 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationChannels.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt create mode 100644 android/app/src/test/java/ru/daemonlord/messenger/push/PushPayloadParserTest.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index bb3a154..e5e671c 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -281,3 +281,11 @@ - Added fullscreen viewer header with close, index, share placeholder, and delete placeholder actions. - Added image navigation controls (`Prev`/`Next`) for gallery traversal. - Updated Telegram UI batch-2 checklist for fullscreen media header support. + +### Step 47 - Notifications foundation (FCM + channels + deep links) +- Added Firebase Messaging dependency and Android manifest wiring for `POST_NOTIFICATIONS`. +- Added notification channels (`messages`, `mentions`, `system`) with startup initialization in `MessengerApplication`. +- Added push service (`MessengerFirebaseMessagingService`) + payload parser + notification dispatcher. +- Added notification tap deep-link handling to open target chat from `MainActivity` via nav host. +- Added runtime notification permission request flow (Android 13+) in `MessengerNavHost`. +- Added parser unit test (`PushPayloadParserTest`). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1a9d650..4c7cbbc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -89,6 +89,7 @@ dependencies { implementation("com.google.dagger:hilt-android:2.52") kapt("com.google.dagger:hilt-compiler:2.52") implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + implementation("com.google.firebase:firebase-messaging-ktx:24.0.1") testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7f6906b..31e4493 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + + + + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt index 10cbda3..bac7a15 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -14,15 +14,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.fillMaxSize import dagger.hilt.android.AndroidEntryPoint +import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras import ru.daemonlord.messenger.ui.navigation.MessengerNavHost @AndroidEntryPoint class MainActivity : ComponentActivity() { private var pendingInviteToken by mutableStateOf(null) + private var pendingNotificationChatId by mutableStateOf(null) + private var pendingNotificationMessageId by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pendingInviteToken = intent.extractInviteToken() + val notificationPayload = intent.extractNotificationOpenPayload() + pendingNotificationChatId = notificationPayload?.first + pendingNotificationMessageId = notificationPayload?.second enableEdgeToEdge() setContent { MaterialTheme { @@ -30,6 +36,12 @@ class MainActivity : ComponentActivity() { AppRoot( inviteToken = pendingInviteToken, onInviteTokenConsumed = { pendingInviteToken = null }, + notificationChatId = pendingNotificationChatId, + notificationMessageId = pendingNotificationMessageId, + onNotificationConsumed = { + pendingNotificationChatId = null + pendingNotificationMessageId = null + }, ) } } @@ -40,6 +52,11 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken + val notificationPayload = intent.extractNotificationOpenPayload() + if (notificationPayload != null) { + pendingNotificationChatId = notificationPayload.first + pendingNotificationMessageId = notificationPayload.second + } } } @@ -47,10 +64,16 @@ class MainActivity : ComponentActivity() { private fun AppRoot( inviteToken: String?, onInviteTokenConsumed: () -> Unit, + notificationChatId: Long?, + notificationMessageId: Long?, + onNotificationConsumed: () -> Unit, ) { MessengerNavHost( inviteToken = inviteToken, onInviteTokenConsumed = onInviteTokenConsumed, + notificationChatId = notificationChatId, + notificationMessageId = notificationMessageId, + onNotificationConsumed = onNotificationConsumed, ) } @@ -67,3 +90,12 @@ private fun Intent?.extractInviteToken(): String? { } return null } + +private fun Intent?.extractNotificationOpenPayload(): Pair? { + val source = this ?: return null + val chatId = source.getLongExtra(NotificationIntentExtras.EXTRA_CHAT_ID, -1L) + if (chatId <= 0L) return null + val rawMessageId = source.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L) + val messageId = rawMessageId.takeIf { it > 0L } + return chatId to messageId +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt b/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt index 4039667..0b8a7c6 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt @@ -2,6 +2,12 @@ package ru.daemonlord.messenger import android.app.Application import dagger.hilt.android.HiltAndroidApp +import ru.daemonlord.messenger.core.notifications.NotificationChannels @HiltAndroidApp -class MessengerApplication : Application() +class MessengerApplication : Application() { + override fun onCreate() { + super.onCreate() + NotificationChannels.ensureCreated(this) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationChannels.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationChannels.kt new file mode 100644 index 0000000..6eda306 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationChannels.kt @@ -0,0 +1,41 @@ +package ru.daemonlord.messenger.core.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build + +object NotificationChannels { + const val CHANNEL_MESSAGES = "messages" + const val CHANNEL_MENTIONS = "mentions" + const val CHANNEL_SYSTEM = "system" + + fun ensureCreated(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channels = listOf( + NotificationChannel( + CHANNEL_MESSAGES, + "Messages", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "New chat messages" + }, + NotificationChannel( + CHANNEL_MENTIONS, + "Mentions", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Mentions in chats" + }, + NotificationChannel( + CHANNEL_SYSTEM, + "System", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Service and system updates" + }, + ) + manager.createNotificationChannels(channels) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt new file mode 100644 index 0000000..d332e3f --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt @@ -0,0 +1,55 @@ +package ru.daemonlord.messenger.core.notifications + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import ru.daemonlord.messenger.MainActivity +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs + +@Singleton +class NotificationDispatcher @Inject constructor( + @ApplicationContext private val context: Context, +) { + fun showChatMessage(payload: ChatNotificationPayload) { + NotificationChannels.ensureCreated(context) + val channelId = if (payload.isMention) { + NotificationChannels.CHANNEL_MENTIONS + } else { + NotificationChannels.CHANNEL_MESSAGES + } + val openIntent = Intent(context, MainActivity::class.java) + .putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId) + .putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val pendingIntent = PendingIntent.getActivity( + context, + notificationId(payload.chatId, payload.messageId), + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(payload.title) + .setContentText(payload.body) + .setStyle(NotificationCompat.BigTextStyle().bigText(payload.body)) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setGroup("chat_${payload.chatId}") + .setPriority( + if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT + ) + .build() + + NotificationManagerCompat.from(context).notify(notificationId(payload.chatId, payload.messageId), notification) + } + + private fun notificationId(chatId: Long, messageId: Long?): Int { + val raw = (chatId * 1_000_003L) + (messageId ?: 0L) + return abs(raw.toInt()) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt new file mode 100644 index 0000000..6ab04e3 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationModels.kt @@ -0,0 +1,14 @@ +package ru.daemonlord.messenger.core.notifications + +data class ChatNotificationPayload( + val chatId: Long, + val messageId: Long?, + val title: String, + val body: String, + val isMention: Boolean = false, +) + +object NotificationIntentExtras { + const val EXTRA_CHAT_ID = "extra_chat_id" + const val EXTRA_MESSAGE_ID = "extra_message_id" +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt new file mode 100644 index 0000000..d035ce0 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt @@ -0,0 +1,24 @@ +package ru.daemonlord.messenger.push + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import ru.daemonlord.messenger.core.notifications.NotificationDispatcher +import javax.inject.Inject + +@AndroidEntryPoint +class MessengerFirebaseMessagingService : FirebaseMessagingService() { + + @Inject + lateinit var notificationDispatcher: NotificationDispatcher + + override fun onMessageReceived(message: RemoteMessage) { + val payload = PushPayloadParser.parse(message) ?: return + notificationDispatcher.showChatMessage(payload) + } + + override fun onNewToken(token: String) { + Log.i("MessengerPush", "FCM token refreshed (${token.take(10)}...)") + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt new file mode 100644 index 0000000..a07cda7 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt @@ -0,0 +1,42 @@ +package ru.daemonlord.messenger.push + +import com.google.firebase.messaging.RemoteMessage +import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload + +object PushPayloadParser { + fun parse(remoteMessage: RemoteMessage): ChatNotificationPayload? { + return parseData( + data = remoteMessage.data, + notificationTitle = remoteMessage.notification?.title, + notificationBody = remoteMessage.notification?.body, + ) + } + + fun parseData( + data: Map, + notificationTitle: String?, + notificationBody: String?, + ): ChatNotificationPayload? { + val chatId = data["chat_id"]?.toLongOrNull() + ?: data["chatId"]?.toLongOrNull() + ?: return null + val messageId = data["message_id"]?.toLongOrNull() ?: data["messageId"]?.toLongOrNull() + val title = data["title"] + ?: notificationTitle + ?: "New message" + val body = data["body"] + ?: notificationBody + ?: data["text"] + ?: "Open chat" + val isMention = data["is_mention"]?.equals("true", ignoreCase = true) == true || + data["mention"]?.equals("true", ignoreCase = true) == true + + return ChatNotificationPayload( + chatId = chatId, + messageId = messageId, + title = title, + body = body, + isMention = isMention, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index e4172e9..7faff66 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -1,5 +1,10 @@ package ru.daemonlord.messenger.ui.navigation +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -11,6 +16,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -40,19 +47,47 @@ fun MessengerNavHost( viewModel: AuthViewModel = hiltViewModel(), inviteToken: String? = null, onInviteTokenConsumed: () -> Unit = {}, + notificationChatId: Long? = null, + notificationMessageId: Long? = null, + onNotificationConsumed: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) {} - LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated) { + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, notificationChatId, notificationMessageId) { if (uiState.isCheckingSession) { return@LaunchedEffect } if (uiState.isAuthenticated) { - navController.navigate(Routes.Chats) { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = true + if (notificationChatId != null) { + navController.navigate("${Routes.Chat}/$notificationChatId") { + popUpTo(navController.graph.findStartDestination().id) { + inclusive = true + } + launchSingleTop = true + } + onNotificationConsumed() + } else { + navController.navigate(Routes.Chats) { + popUpTo(navController.graph.findStartDestination().id) { + inclusive = true + } + launchSingleTop = true } - launchSingleTop = true } } else { navController.navigate(Routes.Login) { diff --git a/android/app/src/test/java/ru/daemonlord/messenger/push/PushPayloadParserTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/push/PushPayloadParserTest.kt new file mode 100644 index 0000000..7184d3d --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/push/PushPayloadParserTest.kt @@ -0,0 +1,31 @@ +package ru.daemonlord.messenger.push + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PushPayloadParserTest { + + @Test + fun parse_readsChatPayloadFromDataMap() { + val payload = PushPayloadParser.parseData( + data = mapOf( + "chat_id" to "42", + "message_id" to "99", + "title" to "Alice", + "body" to "Hello", + "is_mention" to "true", + ), + notificationTitle = null, + notificationBody = null, + ) + + assertNotNull(payload) + assertEquals(42L, payload?.chatId) + assertEquals(99L, payload?.messageId) + assertEquals("Alice", payload?.title) + assertEquals("Hello", payload?.body) + assertTrue(payload?.isMention == true) + } +} diff --git a/docs/android-checklist.md b/docs/android-checklist.md index cd6648a..bd39816 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -88,10 +88,10 @@ - [ ] Jump to message из результатов ## 12. Уведомления -- [ ] FCM push setup +- [x] FCM push setup - [ ] Локальные уведомления для foreground -- [ ] Notification channels (Android) -- [ ] Deep links: open chat/message +- [x] Notification channels (Android) +- [x] Deep links: open chat/message - [ ] Mention override для muted чатов ## 13. UI/UX и темы diff --git a/docs/android-smoke.md b/docs/android-smoke.md index b8576fc..284356d 100644 --- a/docs/android-smoke.md +++ b/docs/android-smoke.md @@ -8,6 +8,7 @@ 5. Media: send image/file/audio, image opens in viewer, audio play/pause works. 6. Invite flow: open invite deep link (`chat.daemonlord.ru/join...`) and verify joined chat auto-opens. 7. Session safety: expired access token refreshes transparently for API calls. +8. Notification deep link: tap push/local notification and verify chat opens via extras. ## Baseline targets (initial) - Cold start to first interactive screen: <= 2.5s on mid device.