android: add realtime foreground local notifications with active chat gating
Some checks failed
CI / test (push) Failing after 2m12s

This commit is contained in:
Codex
2026-03-09 14:48:17 +03:00
parent e8574252ca
commit 98492f869d
8 changed files with 73 additions and 1 deletions

View File

@@ -289,3 +289,9 @@
- 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`).
### Step 48 - Foreground local notifications from realtime
- Added `ActiveChatTracker` to suppress local notifications for currently opened chat.
- Wired realtime receive-message handling to trigger local notification via `NotificationDispatcher` when chat is not active.
- Added chat title lookup helper in `ChatDao` for notification titles.
- Added explicit realtime stop in `ChatViewModel.onCleared()` to avoid stale collectors.

View File

@@ -0,0 +1,23 @@
package ru.daemonlord.messenger.core.notifications
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ActiveChatTracker @Inject constructor() {
private val _activeChatId = MutableStateFlow<Long?>(null)
val activeChatId: StateFlow<Long?> = _activeChatId.asStateFlow()
fun setActiveChat(chatId: Long) {
_activeChatId.value = chatId
}
fun clearActiveChat(chatId: Long) {
if (_activeChatId.value == chatId) {
_activeChatId.value = null
}
}
}

View File

@@ -81,6 +81,9 @@ interface ChatDao {
)
fun observeChatById(chatId: Long): Flow<ChatListLocalModel?>
@Query("SELECT display_title FROM chats WHERE id = :chatId LIMIT 1")
suspend fun getChatDisplayTitle(chatId: Long): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChats(chats: List<ChatEntity>)

View File

@@ -7,6 +7,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
@@ -21,6 +24,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val chatRepository: ChatRepository,
private val chatDao: ChatDao,
private val messageDao: MessageDao,
private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -69,6 +74,27 @@ class HandleRealtimeEventsUseCase @Inject constructor(
updatedSortAt = event.createdAt,
)
chatDao.incrementUnread(chatId = event.chatId)
val activeChatId = activeChatTracker.activeChatId.value
if (activeChatId != event.chatId) {
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
val body = event.text?.takeIf { it.isNotBlank() }
?: when (event.type?.lowercase()) {
"image" -> "Photo"
"video" -> "Video"
"audio" -> "Audio"
"voice" -> "Voice message"
"file" -> "File"
else -> "Open chat"
}
notificationDispatcher.showChatMessage(
ChatNotificationPayload(
chatId = event.chatId,
messageId = event.messageId,
title = title,
body = body,
)
)
}
}
is RealtimeEvent.MessageUpdated -> {

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
@@ -53,6 +54,7 @@ class ChatViewModel @Inject constructor(
private val observeChatUseCase: ObserveChatUseCase,
private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val activeChatTracker: ActiveChatTracker,
) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
@@ -62,6 +64,7 @@ class ChatViewModel @Inject constructor(
private var lastReadMessageId: Long? = null
init {
activeChatTracker.setActiveChat(chatId)
handleRealtimeEventsUseCase.start()
observeChatPermissions()
observeMessages()
@@ -587,4 +590,10 @@ class ChatViewModel @Inject constructor(
is AppError.Unknown -> "Unknown error."
}
}
override fun onCleared() {
activeChatTracker.clearActiveChat(chatId)
handleRealtimeEventsUseCase.stop()
super.onCleared()
}
}

View File

@@ -137,6 +137,10 @@ class NetworkChatRepositoryTest {
return chats.map { entities -> entities.firstOrNull { it.id == chatId }?.toLocalModel() }
}
override suspend fun getChatDisplayTitle(chatId: Long): String? {
return chats.value.firstOrNull { it.id == chatId }?.displayTitle
}
override suspend fun upsertChats(chats: List<ChatEntity>) {
val merged = this.chats.value.associateBy { it.id }.toMutableMap()
chats.forEach { merged[it.id] = it }

View File

@@ -89,7 +89,7 @@
## 12. Уведомления
- [x] FCM push setup
- [ ] Локальные уведомления для foreground
- [x] Локальные уведомления для foreground
- [x] Notification channels (Android)
- [x] Deep links: open chat/message
- [ ] Mention override для muted чатов

View File

@@ -9,6 +9,7 @@
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.
9. Foreground notification gate: incoming realtime message in non-active chat shows local notification; active chat does not.
## Baseline targets (initial)
- Cold start to first interactive screen: <= 2.5s on mid device.