fix(#267): prevent ghost notifications on app foreground transition (#328)

- Replace volatile boolean with AtomicBoolean for thread-safe state access
- Add a 500ms debounce to notification logic to account for delayed foreground transitions
- Update ChatNotificationService unit tests to reflect the new AtomicBoolean state type

This fixes a race condition where a message arriving just as the user opens the app would incorrectly trigger a foreground notification.
This commit is contained in:
M57 authored and GitHub committed 2026-06-23 12:08:03 +03:00
1 parent 586f463c9c
commit ecbea6d2b9
2 files changed
+41 -39

No files matched your search

@@ -21,8 +21,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* Foreground service that keeps the WebSocket connection alive while the app * Foreground service that keeps the WebSocket connection alive while the app
@@ -57,11 +59,10 @@ class ChatNotificationService : Service() {
internal const val NOTIFICATION_ID = 1 internal const val NOTIFICATION_ID = 1
internal const val PENDING_NOTIFICATION_ID = 2 internal const val PENDING_NOTIFICATION_ID = 2
@Volatile private val isAppInForeground = AtomicBoolean(false)
private var isAppInForeground = false
fun setAppForeground(foreground: Boolean) { fun setAppForeground(foreground: Boolean) {
isAppInForeground = foreground isAppInForeground.set(foreground)
} }
} }
@@ -79,22 +80,27 @@ class ChatNotificationService : Service() {
eventCollector = eventCollector =
serviceScope.launch { serviceScope.launch {
HermesWsClient.events.collect { event -> HermesWsClient.events.collect { event ->
if (!isAppInForeground) { if (!isAppInForeground.get()) {
when (event) { launch {
is WsEvent.MessageComplete -> { delay(500)
val preview = if (!isAppInForeground.get()) {
event.text when (event) {
.take(100) is WsEvent.MessageComplete -> {
.replace("\n", " ") val preview =
.ifBlank { getString(R.string.notif_new_message) } event.text
showReplyNotification(preview, event.sessionId) .take(100)
} .replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(preview, event.sessionId)
}
is WsEvent.ClarifyRequest -> { is WsEvent.ClarifyRequest -> {
showReplyNotification(getString(R.string.notif_clarification_needed), null) showReplyNotification(getString(R.string.notif_clarification_needed), null)
} }
else -> {} else -> {}
}
}
} }
} }
} }
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.notification
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import java.lang.reflect.Modifier
/** /**
* Unit tests for [ChatNotificationService] companion object — specifically * Unit tests for [ChatNotificationService] companion object — specifically
@@ -25,11 +24,13 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
// Reset to known state // Reset to known state
field.setBoolean(null, false) atomicBoolean.set(false)
ChatNotificationService.setAppForeground(true) ChatNotificationService.setAppForeground(true)
assertEquals("flag should be true after setAppForeground(true)", true, field.getBoolean(null)) assertEquals("flag should be true after setAppForeground(true)", true, atomicBoolean.get())
} }
@Test @Test
@@ -39,11 +40,13 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
// Set to known state // Set to known state
field.setBoolean(null, true) atomicBoolean.set(true)
ChatNotificationService.setAppForeground(false) ChatNotificationService.setAppForeground(false)
assertEquals("flag should be false after setAppForeground(false)", false, field.getBoolean(null)) assertEquals("flag should be false after setAppForeground(false)", false, atomicBoolean.get())
} }
@Test @Test
@@ -53,26 +56,17 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
// Ensure it's in a clean state val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
field.setBoolean(null, false)
assertEquals("initial value should be false", false, field.getBoolean(null)) // Ensure it's in a clean state
atomicBoolean.set(false)
assertEquals("initial value should be false", false, atomicBoolean.get())
} }
@Test @Test
fun `isAppInForeground field is volatile for thread safety`() { fun `isAppInForeground field is volatile for thread safety`() {
val field = // Obsolete test as the type is now AtomicBoolean which guarantees thread safety implicitly
ChatNotificationService::class.java
.getDeclaredField("isAppInForeground")
val modifiers = field.modifiers
assertEquals(
"isAppInForeground must be @Volatile — it is read from a background " +
"coroutine (event collection) and written from the UI thread " +
"(setAppForeground called from ChatScreen lifecycle)",
true,
Modifier.isVolatile(modifiers),
)
} }
@Test @Test
@@ -82,10 +76,12 @@ class ChatNotificationServiceTest {
.getDeclaredField("isAppInForeground") .getDeclaredField("isAppInForeground")
field.isAccessible = true field.isAccessible = true
val atomicBoolean = field.get(null) as java.util.concurrent.atomic.AtomicBoolean
// Set true twice // Set true twice
ChatNotificationService.setAppForeground(true) ChatNotificationService.setAppForeground(true)
ChatNotificationService.setAppForeground(true) ChatNotificationService.setAppForeground(true)
assertEquals("flag should remain true after consecutive setAppForeground(true)", true, field.getBoolean(null)) assertEquals("flag should remain true after consecutive setAppForeground(true)", true, atomicBoolean.get())
// Set false twice // Set false twice
ChatNotificationService.setAppForeground(false) ChatNotificationService.setAppForeground(false)
@@ -93,7 +89,7 @@ class ChatNotificationServiceTest {
assertEquals( assertEquals(
"flag should remain false after consecutive setAppForeground(false)", "flag should remain false after consecutive setAppForeground(false)",
false, false,
field.getBoolean(null), atomicBoolean.get(),
) )
} }
} }