diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 3a44453..9e587da 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. 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 2f14412..77d4e86 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -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(null) private var pendingVerifyEmailToken by mutableStateOf(null) private var pendingResetPasswordToken by mutableStateOf(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) } } } 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 index d332e3f..3a81c67 100644 --- 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 @@ -15,6 +15,8 @@ import kotlin.math.abs class NotificationDispatcher @Inject constructor( @ApplicationContext private val context: Context, ) { + private val chatStates = linkedMapOf() + 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 = 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 } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 7203a92..6927ca7 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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()