feat(notifications): optional alert when any session completes (#1401)
Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
0c336ecc4d
commit
5bf43e4a21
14 files changed
+474
-40
No files matched your search
+4
@@ -287,6 +287,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns true
|
||||
every { AuthManager.getToken() } returns null
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
@@ -320,6 +321,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns false
|
||||
every { AuthManager.getToken() } returns null
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
@@ -352,6 +354,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns false
|
||||
every { AuthManager.getToken() } returns "some-token"
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
@@ -385,6 +388,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns false
|
||||
every { AuthManager.getToken() } returns "some-token"
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
|
||||
+42
@@ -250,4 +250,46 @@ class BackgroundConnectionPolicyTest {
|
||||
assertTrue(decision.shouldHoldPersistentLease)
|
||||
assertEquals(BackgroundNotificationState.Connecting, decision.notificationState)
|
||||
}
|
||||
|
||||
// ── Session-completion notify opt-in (implies the keep-connected lease) ──
|
||||
|
||||
@Test
|
||||
fun testBackground_notifyCompletionsOn_alone_holdsServiceAndLease() {
|
||||
val state =
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
isDeparting = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = false,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
isAutoReconnect = true,
|
||||
hasActiveNetwork = true,
|
||||
notifyCompletionsOptIn = true,
|
||||
)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(state)
|
||||
assertTrue(decision.shouldHoldService)
|
||||
assertTrue(decision.shouldHoldPersistentLease)
|
||||
assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBackground_notifyCompletionsOff_pendingReplyOnly_holdsServiceWithoutLease() {
|
||||
val state =
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
isDeparting = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = true,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
isAutoReconnect = true,
|
||||
hasActiveNetwork = true,
|
||||
notifyCompletionsOptIn = false,
|
||||
)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(state)
|
||||
assertTrue(decision.shouldHoldService)
|
||||
assertFalse(decision.shouldHoldPersistentLease)
|
||||
assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.content.Context
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
@@ -164,3 +165,116 @@ class ChatNotificationServiceTest {
|
||||
assertEquals("Answer\ncontinued", plan.correlationText)
|
||||
}
|
||||
}
|
||||
|
||||
class ChatNotificationServiceActiveListTest {
|
||||
@Test
|
||||
fun `parseActiveSessionLookup decodes the typed JSON result and maps ids plus title`() {
|
||||
val raw =
|
||||
"""
|
||||
{"sessions":[
|
||||
{"id":"rt1","session_key":"stored-1","title":"Nightly report","status":"idle","last_active":1780000000.0},
|
||||
{"id":"rt2","session_key":"stored-2","status":"idle"}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
val found = parseActiveSessionLookup(raw, "rt1")
|
||||
assertEquals("stored-1", found?.storedId)
|
||||
assertEquals("Nightly report", found?.title)
|
||||
|
||||
// Row without a title: notification falls back to the app name.
|
||||
val untitled = parseActiveSessionLookup(raw, "rt2")
|
||||
assertEquals("stored-2", untitled?.storedId)
|
||||
assertNull(untitled?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseActiveSessionLookup accepts the JsonElement a typed RPC call returns`() {
|
||||
val raw = """{"sessions":[{"id":"rt9","session_key":"stored-9","title":"T"}]}"""
|
||||
val element =
|
||||
kotlinx.serialization.json.Json
|
||||
.parseToJsonElement(raw)
|
||||
|
||||
val found = parseActiveSessionLookup(element, "rt9")
|
||||
assertEquals("stored-9", found?.storedId)
|
||||
assertEquals("T", found?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseActiveSessionLookup rejects unknown runtime ids and empty keys`() {
|
||||
val raw = """{"sessions":[{"id":"rt1","session_key":"","status":"idle"}]}"""
|
||||
|
||||
assertNull(parseActiveSessionLookup(raw, "rt1"))
|
||||
assertNull(parseActiveSessionLookup(raw, "missing"))
|
||||
assertNull(parseActiveSessionLookup(null, "rt1"))
|
||||
assertNull(parseActiveSessionLookup(raw, ""))
|
||||
assertNull(parseActiveSessionLookup("not json", "rt1"))
|
||||
}
|
||||
}
|
||||
|
||||
class MessageCompleteRouteTest {
|
||||
@Test
|
||||
fun `active session routes to the reply path`() {
|
||||
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "rt1")
|
||||
assertEquals(MessageCompleteRoute.Reply, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle off keeps legacy behavior for foreign sessions`() {
|
||||
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = false, eventSessionId = "other")
|
||||
assertEquals(MessageCompleteRoute.Reply, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on routes foreign sessions to the plain alert`() {
|
||||
assertTrue(
|
||||
messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "other") is
|
||||
MessageCompleteRoute.ForeignSession,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on with a cleared holder routes the pinned alert`() {
|
||||
// After a background reconnect the holder is cleared: the user's own
|
||||
// reply lands in the plain alert (resolved stored id keeps the tap
|
||||
// working) instead of being dropped.
|
||||
assertTrue(
|
||||
messageCompleteRoute(holderSessionId = null, toggleOn = true, eventSessionId = "rt1") is
|
||||
MessageCompleteRoute.ForeignSession,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle off with a cleared holder still uses the reply path`() {
|
||||
val route = messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1")
|
||||
assertEquals(MessageCompleteRoute.Reply, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy reply route retires the service when the wait is over`() {
|
||||
// Toggle off + holder cleared by a reconnect: the route is Reply, and the
|
||||
// Reply branch always ends in onReplyCompleted(generation).
|
||||
assertEquals(
|
||||
MessageCompleteRoute.Reply,
|
||||
messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1"),
|
||||
)
|
||||
var completedGeneration: Long? = null
|
||||
val controller =
|
||||
BackgroundConnectionController(
|
||||
snapshotProvider = {
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
isDeparting = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = true,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
isAutoReconnect = true,
|
||||
hasActiveNetwork = true,
|
||||
)
|
||||
},
|
||||
requestServiceComplete = { gen -> completedGeneration = gen },
|
||||
)
|
||||
controller.onReplyCompleted(7L)
|
||||
assertEquals(7L, completedGeneration)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ class SettingsViewModelTest {
|
||||
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||
every { AuthManager.isModelProviderShown() } returns false
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns false
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.setNotifySessionCompletions(any()) } returns Unit
|
||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
||||
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
||||
|
||||
Reference in new issue
Block a user