android: add notifications foundation with fcm channels and deep links
Some checks failed
CI / test (push) Failing after 2m10s

This commit is contained in:
Codex
2026-03-09 14:44:28 +03:00
parent d09300311f
commit e8574252ca
14 changed files with 307 additions and 9 deletions

View File

@@ -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`).

View File

@@ -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")

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MessengerApplication"
@@ -29,6 +30,13 @@
android:scheme="https" />
</intent-filter>
</activity>
<service
android:name=".push.MessengerFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -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<String?>(null)
private var pendingNotificationChatId by mutableStateOf<Long?>(null)
private var pendingNotificationMessageId by mutableStateOf<Long?>(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<Long, Long?>? {
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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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())
}
}

View File

@@ -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"
}

View File

@@ -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)}...)")
}
}

View File

@@ -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<String, String>,
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,
)
}
}

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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 и темы

View File

@@ -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.