From 98492f869dc5df72ae10d554a5ef991831c0857a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 14:48:17 +0300 Subject: [PATCH] android: add realtime foreground local notifications with active chat gating --- android/CHANGELOG.md | 6 +++++ .../core/notifications/ActiveChatTracker.kt | 23 ++++++++++++++++ .../messenger/data/chat/local/dao/ChatDao.kt | 3 +++ .../usecase/HandleRealtimeEventsUseCase.kt | 26 +++++++++++++++++++ .../messenger/ui/chat/ChatViewModel.kt | 9 +++++++ .../repository/NetworkChatRepositoryTest.kt | 4 +++ docs/android-checklist.md | 2 +- docs/android-smoke.md | 1 + 8 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index e5e671c..fdeefd7 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt new file mode 100644 index 0000000..5d63ade --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt @@ -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(null) + val activeChatId: StateFlow = _activeChatId.asStateFlow() + + fun setActiveChat(chatId: Long) { + _activeChatId.value = chatId + } + + fun clearActiveChat(chatId: Long) { + if (_activeChatId.value == chatId) { + _activeChatId.value = null + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt index 7d7f220..863a213 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -81,6 +81,9 @@ interface ChatDao { ) fun observeChatById(chatId: Long): Flow + @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) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt index 4b5a016..c0ad26d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt @@ -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 -> { 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 a236487..050b5c1 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 @@ -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() + } } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt index 50040f2..74b427f 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt @@ -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) { val merged = this.chats.value.associateBy { it.id }.toMutableMap() chats.forEach { merged[it.id] = it } diff --git a/docs/android-checklist.md b/docs/android-checklist.md index bd39816..9829e8a 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -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 чатов diff --git a/docs/android-smoke.md b/docs/android-smoke.md index 284356d..605528a 100644 --- a/docs/android-smoke.md +++ b/docs/android-smoke.md @@ -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.