android: group push notifications by chat
This commit is contained in:
@@ -815,3 +815,14 @@
|
|||||||
- `SettingsScreen`
|
- `SettingsScreen`
|
||||||
- `SettingsFolderView`
|
- `SettingsFolderView`
|
||||||
- Updated `AppNavGraph` Settings destination call-site accordingly.
|
- 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 androidx.appcompat.app.AppCompatDelegate
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||||
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
||||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||||
@@ -26,6 +27,10 @@ import javax.inject.Inject
|
|||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var themeRepository: ThemeRepository
|
lateinit var themeRepository: ThemeRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var notificationDispatcher: NotificationDispatcher
|
||||||
|
|
||||||
private var pendingInviteToken by mutableStateOf<String?>(null)
|
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||||
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
||||||
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
||||||
@@ -52,6 +57,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val notificationPayload = intent.extractNotificationOpenPayload()
|
val notificationPayload = intent.extractNotificationOpenPayload()
|
||||||
pendingNotificationChatId = notificationPayload?.first
|
pendingNotificationChatId = notificationPayload?.first
|
||||||
pendingNotificationMessageId = notificationPayload?.second
|
pendingNotificationMessageId = notificationPayload?.second
|
||||||
|
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MessengerTheme {
|
MessengerTheme {
|
||||||
@@ -85,6 +91,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
if (notificationPayload != null) {
|
if (notificationPayload != null) {
|
||||||
pendingNotificationChatId = notificationPayload.first
|
pendingNotificationChatId = notificationPayload.first
|
||||||
pendingNotificationMessageId = notificationPayload.second
|
pendingNotificationMessageId = notificationPayload.second
|
||||||
|
notificationDispatcher.clearChatNotifications(notificationPayload.first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import kotlin.math.abs
|
|||||||
class NotificationDispatcher @Inject constructor(
|
class NotificationDispatcher @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
|
private val chatStates = linkedMapOf<Long, ChatNotificationState>()
|
||||||
|
|
||||||
fun showChatMessage(payload: ChatNotificationPayload) {
|
fun showChatMessage(payload: ChatNotificationPayload) {
|
||||||
NotificationChannels.ensureCreated(context)
|
NotificationChannels.ensureCreated(context)
|
||||||
val channelId = if (payload.isMention) {
|
val channelId = if (payload.isMention) {
|
||||||
@@ -22,34 +24,126 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
NotificationChannels.CHANNEL_MESSAGES
|
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)
|
val openIntent = Intent(context, MainActivity::class.java)
|
||||||
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
|
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
|
||||||
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
|
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
notificationId(payload.chatId, payload.messageId),
|
chatNotificationId(payload.chatId),
|
||||||
openIntent,
|
openIntent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
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)
|
val notification = NotificationCompat.Builder(context, channelId)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle(payload.title)
|
.setContentTitle(state.title)
|
||||||
.setContentText(payload.body)
|
.setContentText(contentText)
|
||||||
.setStyle(NotificationCompat.BigTextStyle().bigText(payload.body))
|
.setStyle(inboxStyle)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.setGroup("chat_${payload.chatId}")
|
.setGroup(GROUP_KEY_CHATS)
|
||||||
|
.setOnlyAlertOnce(false)
|
||||||
|
.setNumber(state.unreadCount)
|
||||||
.setPriority(
|
.setPriority(
|
||||||
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
|
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
|
||||||
)
|
)
|
||||||
.build()
|
.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 {
|
fun clearChatNotifications(chatId: Long) {
|
||||||
val raw = (chatId * 1_000_003L) + (messageId ?: 0L)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
return abs(raw.toInt())
|
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.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
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.ObserveChatUseCase
|
||||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
@@ -58,6 +59,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
private val observeChatsUseCase: ObserveChatsUseCase,
|
private val observeChatsUseCase: ObserveChatsUseCase,
|
||||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||||
private val activeChatTracker: ActiveChatTracker,
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
|
private val notificationDispatcher: NotificationDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
|
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
|
||||||
@@ -69,6 +71,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
activeChatTracker.setActiveChat(chatId)
|
activeChatTracker.setActiveChat(chatId)
|
||||||
|
notificationDispatcher.clearChatNotifications(chatId)
|
||||||
handleRealtimeEventsUseCase.start()
|
handleRealtimeEventsUseCase.start()
|
||||||
observeChatPermissions()
|
observeChatPermissions()
|
||||||
observeMessages()
|
observeMessages()
|
||||||
|
|||||||
Reference in New Issue
Block a user