android: group push notifications by chat
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user