android: group push notifications by chat
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Failing after 4m53s
CI / test (push) Failing after 2m56s

This commit is contained in:
Codex
2026-03-10 00:43:20 +03:00
parent e21a54e2bf
commit 1099efc8c0
4 changed files with 124 additions and 9 deletions

View File

@@ -815,3 +815,14 @@
- `SettingsScreen`
- `SettingsFolderView`
- Updated `AppNavGraph` Settings destination call-site accordingly.
### Step 118 - Android push notifications grouped by chat
- Reworked `NotificationDispatcher` to aggregate incoming messages into one notification per chat:
- stable notification id per `chatId`,
- per-chat unread counter,
- multi-line inbox preview of recent messages.
- Added app-level summary notification that groups all active chat notifications.
- Added deduplication guard for repeated push deliveries of the same `messageId`.
- Added notification cleanup on chat open:
- when push-open intent targets a chat in `MainActivity`,
- when `ChatViewModel` enters a chat directly from app UI.

View File

@@ -15,6 +15,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
@@ -26,6 +27,10 @@ import javax.inject.Inject
class MainActivity : AppCompatActivity() {
@Inject
lateinit var themeRepository: ThemeRepository
@Inject
lateinit var notificationDispatcher: NotificationDispatcher
private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -52,6 +57,7 @@ class MainActivity : AppCompatActivity() {
val notificationPayload = intent.extractNotificationOpenPayload()
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
enableEdgeToEdge()
setContent {
MessengerTheme {
@@ -85,6 +91,7 @@ class MainActivity : AppCompatActivity() {
if (notificationPayload != null) {
pendingNotificationChatId = notificationPayload.first
pendingNotificationMessageId = notificationPayload.second
notificationDispatcher.clearChatNotifications(notificationPayload.first)
}
}
}

View File

@@ -15,6 +15,8 @@ import kotlin.math.abs
class NotificationDispatcher @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val chatStates = linkedMapOf<Long, ChatNotificationState>()
fun showChatMessage(payload: ChatNotificationPayload) {
NotificationChannels.ensureCreated(context)
val channelId = if (payload.isMention) {
@@ -22,34 +24,126 @@ class NotificationDispatcher @Inject constructor(
} else {
NotificationChannels.CHANNEL_MESSAGES
}
val state = synchronized(chatStates) {
val existing = chatStates[payload.chatId]
if (existing != null && payload.messageId != null && existing.lastMessageId == payload.messageId) {
return
}
val updated = (existing ?: ChatNotificationState(title = payload.title))
.copy(title = payload.title)
.appendMessage(payload.body, payload.messageId)
chatStates[payload.chatId] = updated
updated
}
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),
chatNotificationId(payload.chatId),
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentText = when {
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
else -> "${state.unreadCount} new messages"
}
val inboxStyle = NotificationCompat.InboxStyle()
.setBigContentTitle(state.title)
.setSummaryText("${state.unreadCount} messages")
state.lines.reversed().forEach { inboxStyle.addLine(it) }
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))
.setContentTitle(state.title)
.setContentText(contentText)
.setStyle(inboxStyle)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setGroup("chat_${payload.chatId}")
.setGroup(GROUP_KEY_CHATS)
.setOnlyAlertOnce(false)
.setNumber(state.unreadCount)
.setPriority(
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
)
.build()
NotificationManagerCompat.from(context).notify(notificationId(payload.chatId, payload.messageId), notification)
val manager = NotificationManagerCompat.from(context)
manager.notify(chatNotificationId(payload.chatId), notification)
showSummaryNotification(manager)
}
private fun notificationId(chatId: Long, messageId: Long?): Int {
val raw = (chatId * 1_000_003L) + (messageId ?: 0L)
return abs(raw.toInt())
fun clearChatNotifications(chatId: Long) {
val manager = NotificationManagerCompat.from(context)
synchronized(chatStates) {
chatStates.remove(chatId)
}
manager.cancel(chatNotificationId(chatId))
showSummaryNotification(manager)
}
private fun showSummaryNotification(manager: NotificationManagerCompat) {
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
if (snapshot.isEmpty()) {
manager.cancel(SUMMARY_NOTIFICATION_ID)
return
}
val totalUnread = snapshot.sumOf { it.unreadCount }
val inboxStyle = NotificationCompat.InboxStyle()
.setBigContentTitle("Benya Messenger")
.setSummaryText("$totalUnread messages")
snapshot.take(6).forEach { state ->
val preview = state.lines.firstOrNull().orEmpty()
val line = if (preview.isBlank()) {
"${state.title} (${state.unreadCount})"
} else {
"${state.title}: $preview (${state.unreadCount})"
}
inboxStyle.addLine(line)
}
val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Benya Messenger")
.setContentText("$totalUnread new messages in ${snapshot.size} chats")
.setStyle(inboxStyle)
.setGroup(GROUP_KEY_CHATS)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
manager.notify(SUMMARY_NOTIFICATION_ID, summary)
}
private fun chatNotificationId(chatId: Long): Int {
return abs((chatId * 1_000_003L).toInt())
}
private data class ChatNotificationState(
val title: String,
val unreadCount: Int = 0,
val lines: List<String> = emptyList(),
val lastMessageId: Long? = null,
) {
fun appendMessage(body: String, messageId: Long?): ChatNotificationState {
val normalized = body.trim().ifBlank { "New message" }
val updatedLines = buildList {
add(normalized)
lines.forEach { add(it) }
}.distinct().take(MAX_LINES)
return copy(
unreadCount = unreadCount + 1,
lines = updatedLines,
lastMessageId = messageId ?: lastMessageId,
)
}
}
private companion object {
private const val GROUP_KEY_CHATS = "messenger_chats_group"
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
private const val MAX_LINES = 5
}
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
@@ -58,6 +59,7 @@ class ChatViewModel @Inject constructor(
private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val activeChatTracker: ActiveChatTracker,
private val notificationDispatcher: NotificationDispatcher,
) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
@@ -69,6 +71,7 @@ class ChatViewModel @Inject constructor(
init {
activeChatTracker.setActiveChat(chatId)
notificationDispatcher.clearChatNotifications(chatId)
handleRealtimeEventsUseCase.start()
observeChatPermissions()
observeMessages()