- 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:
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 -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-22
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user