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.