feat: auto-dismiss reply notifications when read in chat (#1195)

This commit is contained in:
M57 authored and GitHub committed 2026-09-19 22:58:43 +03:00
1 parent 03ce822119
commit dc77b306b6
30 files changed
+4070 -15

No files matched your search

@@ -0,0 +1,500 @@
package com.m57.hermescontrol.ui.chat
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.service.notification.StatusBarNotification
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.ChatNotificationService
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.JsonPrimitive
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ChatReadNotificationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var context: Context
private lateinit var notificationManager: NotificationManager
private val testChannelId = "test_chat_channel"
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
InstrumentationRegistry
.getInstrumentation()
.uiAutomation
.grantRuntimePermission(
context.packageName,
android.Manifest.permission.POST_NOTIFICATIONS,
)
}
val channel =
NotificationChannel(
testChannelId,
"Test Chat Notifications",
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(channel)
notificationManager.cancelAll()
ReplyNotificationTracker.resetForTest()
}
@After
fun tearDown() {
notificationManager.cancelAll()
ReplyNotificationTracker.resetForTest()
}
private fun awaitNotification(
timeoutMs: Long = 3000L,
predicate: (Array<StatusBarNotification>) -> Boolean,
): Boolean {
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < timeoutMs) {
if (predicate(notificationManager.activeNotifications)) return true
Thread.sleep(50)
}
return predicate(notificationManager.activeNotifications)
}
private fun postRealNotification(
kind: String,
scopeId: String = "default",
sessionId: String = "session-1",
completionId: String = "comp-1",
textSnippet: String = "Here is the reply",
serverMessageId: Int? = null,
): Long {
val gen =
if (kind == ReplyNotificationTracker.KIND_REPLY) {
ReplyNotificationTracker.registerPendingReply(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
textSnippet = textSnippet,
)
} else {
ReplyNotificationTracker.nextGeneration()
}
val notification =
NotificationCompat
.Builder(context, testChannelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Agent")
.setContentText(textSnippet)
.addExtras(
Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, kind)
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, textSnippet)
putLong(ReplyNotificationTracker.EXTRA_GENERATION, gen)
serverMessageId?.let {
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
}
},
).build()
when (kind) {
ReplyNotificationTracker.KIND_REPLY -> {
ReplyNotificationTracker.postReplyNotification(context, notification, gen)
}
ReplyNotificationTracker.KIND_ACTION -> {
ReplyNotificationTracker.postActionNotification(context, notification)
}
ReplyNotificationTracker.KIND_REPLIED -> {
ReplyNotificationTracker.postRepliedNotification(context, notification)
}
}
val active = awaitNotification { sbns -> sbns.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
assertTrue("Notification ID 2 must be active in system after posting", active)
return gen
}
@Test
fun chatLifecycleEffects_dismissesRealSystemNotification_whenAssistantMessageDisplayed() {
val sessionId = "session-read-test"
val completionId = "comp-12345"
val replyText = "Here is the completed response."
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLY,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = completionId,
textSnippet = replyText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = replyText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
val dismissed =
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
assertTrue(
"Notification ID 2 must be dismissed from real system notifications when message is viewed",
dismissed,
)
}
/**
* The mobile-produced durable row id must survive in the real posted
* notification; after a process death it is the only thing that can name the
* row whose visibility dismisses this notification.
*/
@Test
fun serverRowIdSurvivesProcessDeathThroughRealNotificationExtras() {
val sessionId = "session-exact-row-test"
val replyText = "Done"
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLY,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = "comp-exact-row",
textSnippet = replyText,
serverMessageId = 52,
)
// Simulate process death: nothing in memory, only the posted extras.
ReplyNotificationTracker.resetForTest()
val recovered = ReplyNotificationTracker.getActiveTarget(context)
assertEquals(
"Recovered target must carry the durable row id from the notification extras",
52,
recovered?.serverMessageId,
)
val history =
listOf(
SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")),
SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")),
)
val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context)
assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId)
assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
val dismissed =
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
assertTrue(
"Viewing the exact hydrated row must dismiss the real system notification",
dismissed,
)
}
@Test
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forActionRequest() {
val sessionId = "session-action-test"
val completionId = "comp-action"
val promptText = "Approval required"
postRealNotification(
kind = ReplyNotificationTracker.KIND_ACTION,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = completionId,
textSnippet = promptText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = promptText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
Thread.sleep(200)
assertTrue(
"Action notifications must NOT be auto-dismissed when viewing chat",
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
)
}
@Test
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forRepliedNotification() {
val sessionId = "session-replied-test"
val completionId = "comp-replied"
val replyStatusText = "Replied: done"
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLIED,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionId,
completionId = completionId,
textSnippet = replyStatusText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = replyStatusText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = sessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = sessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
Thread.sleep(200)
assertTrue(
"Replied status notifications must NOT be auto-dismissed when viewing chat",
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
)
}
@Test
fun chatLifecycleEffects_doesNotDismissNotification_forDifferentSession() {
val sessionIdWithNotif = "session-with-notif"
val differentSessionId = "other-session"
val completionId = "comp-12345"
val replyText = "Here is the completed response."
postRealNotification(
kind = ReplyNotificationTracker.KIND_REPLY,
scopeId = AuthManager.activeProfileId.value.orEmpty(),
sessionId = sessionIdWithNotif,
completionId = completionId,
textSnippet = replyText,
)
val assistantMessage =
ChatMessage(
id = "msg-1",
role = MessageRole.ASSISTANT,
content = replyText,
completionId = completionId,
)
val messages = listOf(assistantMessage)
val mockVm = mockk<ChatViewModel>(relaxed = true)
composeTestRule.setContent {
val listState = rememberLazyListState()
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
val scrollController = rememberChatScrollController(listState, scrollScope)
val snackbarHostState = remember { SnackbarHostState() }
LazyColumn(
state = listState,
modifier = Modifier.size(300.dp, 600.dp),
) {
items(messages, key = { "prose-${it.id}" }) {
Text(it.content)
}
}
ChatLifecycleEffects(
sessionId = differentSessionId,
connectionStatus = ConnectionStatus.CONNECTED,
currentSessionId = differentSessionId,
messages = messages,
errorMessage = null,
backgroundCompleteMessage = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
secretPrompt = null,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = mockVm,
)
}
composeTestRule.waitForIdle()
Thread.sleep(200)
assertTrue(
"Notification ID 2 must remain active for a different session",
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
)
}
}