android: add notifications foundation with fcm channels and deep links
Some checks failed
CI / test (push) Failing after 2m10s
Some checks failed
CI / test (push) Failing after 2m10s
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)}...)")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 и темы
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user