android: add realtime foreground local notifications with active chat gating
Some checks failed
CI / test (push) Failing after 2m12s
Some checks failed
CI / test (push) Failing after 2m12s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 чатов
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user