From 5bf43e4a21a74d713f26d5ea6980c984a9274922 Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:31:08 -0400 Subject: [PATCH] feat(notifications): optional alert when any session completes (#1401) Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com> --- .../data/config/ServerStoreState.kt | 3 + .../hermescontrol/data/local/AuthManager.kt | 6 + .../data/model/SessionLiveStatus.kt | 1 + .../BackgroundConnectionController.kt | 1 + .../BackgroundConnectionPolicy.kt | 15 +- .../notification/ChatNotificationService.kt | 251 +++++++++++++++--- .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 8 + .../ui/settings/components/BehaviorSection.kt | 61 +++++ app/src/main/res/values/strings.xml | 4 + .../BackgroundConnectionControllerTest.kt | 4 + .../BackgroundConnectionPolicyTest.kt | 42 +++ .../ChatNotificationServiceTest.kt | 114 ++++++++ .../ui/settings/SettingsViewModelTest.kt | 2 + 14 files changed, 474 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index e81a38cb..8e4360b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -32,6 +32,9 @@ data class ServerStoreState( val showTokensPerSecond: Boolean = true, val showModelProvider: Boolean = false, val keepConnectedInBackground: Boolean = false, + // Notify when ANY session finishes while the app is backgrounded. Needs a + // live background connection, so it implies the keep-connected lease. + val notifySessionCompletions: Boolean = false, // Opt-in update channel: when true the in-app update check also considers // pre-release release-candidate GitHub releases, not just stable ones. val checkReleaseCandidateUpdates: Boolean = false, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 464b130c..d621cbb4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -871,6 +871,12 @@ object AuthManager { serverStore.update { it.copy(keepConnectedInBackground = enabled) } } + fun isNotifySessionCompletions(): Boolean = serverStore.getLatestState().notifySessionCompletions + + fun setNotifySessionCompletions(enabled: Boolean) { + serverStore.update { it.copy(notifySessionCompletions = enabled) } + } + // ── Chat Font Scale (issue #1004) ─────────────────────────────────── fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt index 3f201350..bf827456 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt @@ -30,6 +30,7 @@ data class ActiveSessionItem( val status: String? = null, @SerialName("last_active") val lastActive: Double? = null, + val title: String? = null, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt index 4060e569..eec6e688 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt @@ -51,6 +51,7 @@ class BackgroundConnectionController( status = status, isAutoReconnect = AuthManager.isAutoReconnect(), hasActiveNetwork = NetworkMonitor.isConnected.value, + notifyCompletionsOptIn = AuthManager.isNotifySessionCompletions(), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt index 693108ad..216ca3da 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt @@ -14,6 +14,11 @@ data class BackgroundConnectionSnapshot( val status: ConnectionStatus, val isAutoReconnect: Boolean = true, val hasActiveNetwork: Boolean = true, + /** + * User opted into "notify when a session completes" — completions arrive + * only over a live connection, so this implies the keep-connected lease. + */ + val notifyCompletionsOptIn: Boolean = false, ) { companion object { fun ineligible( @@ -77,8 +82,12 @@ object BackgroundConnectionPolicy { ) } - // Demand check: need either a pending reply or persistent opt-in - val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn + // Demand check: need a pending reply or a persistent opt-in. The + // completions notify opt-in implies the keep-connected lease — a + // session can only be observed finishing over a live connection. + val notifyCompletionsDemand = snapshot.notifyCompletionsOptIn + val keepConnectedLease = snapshot.keepConnectedOptIn || notifyCompletionsDemand + val hasDemand = snapshot.pendingReply || keepConnectedLease if (!hasDemand) { return BackgroundConnectionDecision( shouldHoldService = false, @@ -134,7 +143,7 @@ object BackgroundConnectionPolicy { return BackgroundConnectionDecision( shouldHoldService = true, - shouldHoldPersistentLease = snapshot.keepConnectedOptIn, + shouldHoldPersistentLease = keepConnectedLease, notificationState = notificationState, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 6b1668ec..0f3ac200 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -9,16 +9,21 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput import com.m57.hermescontrol.MainActivity import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.ActiveSessionsResponse import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams import com.m57.hermescontrol.ui.chat.replyFailureFromPayload +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,6 +32,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.serializer import java.util.concurrent.atomic.AtomicBoolean internal data class MessageCompleteNotificationPlan( @@ -38,6 +46,87 @@ internal data class MessageCompleteNotificationPlan( val allowInlineReply: Boolean, ) +internal data class CompletedSessionRef( + val storedId: String, + val title: String?, +) + +private const val TAG = "ChatNotificationService" + +/** Short cap so a slow `session.active_list` cannot hold a completion alert hostage. */ +private const val ACTIVE_LIST_TIMEOUT_MS = 5_000L + +/** Where a background `message.complete` goes. */ +internal sealed interface MessageCompleteRoute { + /** The reply flow: reply notification, then the foreground service is retired. */ + data object Reply : MessageCompleteRoute + + /** A session the phone did not submit finished; the opt-in alert fires. */ + data object ForeignSession : MessageCompleteRoute +} + +/** + * Route a completion the way the service did before the opt-in existed: + * toggle OFF must keep the legacy behavior exactly — every completion uses + * the reply path, even when `ActiveSessionHolder` was cleared by a background + * reconnect (dropping the completion would both hide the reply notification + * and strand the foreground service). + */ +internal fun messageCompleteRoute( + holderSessionId: String?, + toggleOn: Boolean, + eventSessionId: String?, +): MessageCompleteRoute = + if (!toggleOn || (!eventSessionId.isNullOrBlank() && eventSessionId == holderSessionId)) { + MessageCompleteRoute.Reply + } else { + MessageCompleteRoute.ForeignSession + } + +/** + * Map a WS event's runtime session id to the STORED session row the app can + * open. The gateway emits `message.complete` with its ephemeral transport id + * (`uuid4().hex[:8]`), while History/`switchSession` use the persisted + * `session_key` — a runtime id taps into a blank chat. `session.active_list` + * lists every live session (idle included) with both ids plus the title. + * + * Accepts the typed RPC result (`JsonElement`) and its pre-JSON `Map` shape, + * mirroring `SessionLiveStatusDecoder.decodeResponse`. + */ +internal fun parseActiveSessionLookup( + raw: Any?, + runtimeId: String, +): CompletedSessionRef? { + if (runtimeId.isBlank()) return null + val element = + when (raw) { + is JsonObject -> { + raw + } + + is String -> { + runCatching { lookupJson.parseToJsonElement(raw) }.getOrNull() ?: return null + } + + else -> { + return null + } + } + val response = + runCatching { + lookupJson.decodeFromJsonElement(serializer(), element) + }.getOrNull() ?: return null + for (item in response.sessions) { + if (item.id?.trim() != runtimeId) continue + val storedId = item.sessionKey?.trim().orEmpty() + if (storedId.isBlank()) return null + return CompletedSessionRef(storedId, item.title?.trim()?.takeIf { it.isNotEmpty() }) + } + return null +} + +private val lookupJson = Json { ignoreUnknownKeys = true } + internal fun messageCompleteNotificationPlan( event: WsEvent.MessageComplete, targetSessionId: String?, @@ -107,6 +196,9 @@ class ChatNotificationService : Service() { internal const val NOTIFICATION_ID = 1 internal const val PENDING_NOTIFICATION_ID = 2 + /** Session-complete notifications: one fixed id, tagged per stored session. */ + private const val SESSION_COMPLETE_NOTIFICATION_ID = 10_000 + private val isAppInForeground = AtomicBoolean(false) internal val lifecycle = ForegroundServiceLifecycle() @@ -153,43 +245,99 @@ class ChatNotificationService : Service() { if (!isAppInForeground.get()) { when (event) { is WsEvent.MessageComplete -> { - val targetSessionId = - event.storedSessionId - ?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId) - val plan = - messageCompleteNotificationPlan( - event = event, - targetSessionId = targetSessionId, - newMessageText = getString(R.string.notif_new_message), - failureText = getString(R.string.chat_reply_failed_title), - ) - showReplyNotification( - text = plan.text, - sessionId = plan.sessionId, - isReplyMessage = plan.isReplyMessage, - completionId = plan.completionId, - allowInlineReply = plan.allowInlineReply, - // The durable REST row for this turn, when the - // boundary armed before the prompt was submitted - // still lets us name it unambiguously. Null is a - // normal, safe outcome: the notification is then - // never auto-dismissed from REST hydration, which - // is strictly better than dismissing the wrong - // duplicate reply. - serverMessageId = - plan.correlationText?.let { - coalesceTurnRow(plan.sessionId, it) - }, - ) - // The wait is over — retire the foreground - // service. The reply notification above - // replaces the persistent "waiting" one, - // and the pendingReply flag is cleared by - // HermesWsClient's own collector, so the - // service is not restarted on the next - // ON_STOP (issue #794). - // A delayed completion must not retire a newer turn/start. - BackgroundConnectionController.default.onReplyCompleted(generation) + when ( + val route = + messageCompleteRoute( + holderSessionId = ActiveSessionHolder.activeSessionId.value, + toggleOn = AuthManager.isNotifySessionCompletions(), + eventSessionId = event.sessionId, + ) + ) { + MessageCompleteRoute.Reply -> { + val targetSessionId = + event.storedSessionId + ?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId) + val plan = + messageCompleteNotificationPlan( + event = event, + targetSessionId = targetSessionId, + newMessageText = getString(R.string.notif_new_message), + failureText = getString(R.string.chat_reply_failed_title), + ) + showReplyNotification( + text = plan.text, + sessionId = plan.sessionId, + isReplyMessage = plan.isReplyMessage, + completionId = plan.completionId, + allowInlineReply = plan.allowInlineReply, + // The durable REST row for this turn, when the + // boundary armed before the prompt was submitted + // still lets us name it unambiguously. Null is a + // normal, safe outcome: the notification is then + // never auto-dismissed from REST hydration. + serverMessageId = + plan.correlationText?.let { + coalesceTurnRow(plan.sessionId, it) + }, + ) + // The wait is over: retire the foreground service + // (issue #794). A delayed completion must not + // retire a newer turn/start. + BackgroundConnectionController.default.onReplyCompleted(generation) + } + + MessageCompleteRoute.ForeignSession -> { + // Another session finished. Resolve its runtime + // id to the STORED session row (the gateway's + // transport id means nothing to History/chat); + // without the stored id the tap would open a + // blank chat, so skip instead. The notification + // is plain — no inline reply, no reply-tracker + // registration — and the service keeps listening + // for the next completion (the opt-in demand + // holds it). + val ref = + try { + parseActiveSessionLookup( + HermesWsClient.call( + RpcMethods.SESSION_ACTIVE_LIST, + SessionActiveListParams(), + timeoutMs = ACTIVE_LIST_TIMEOUT_MS, + ), + event.sessionId.orEmpty(), + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if (ref != null) { + // A failed turn must not read as success. + val failure = + replyFailureFromPayload( + event.rawPayload, + fallback = "Unknown gateway error", + ) + val snippet = + (failure?.details ?: event.text) + .take(100) + .replace("\n", " ") + .ifBlank { getString(R.string.notif_new_message) } + val body = + if (failure != null) { + getString(R.string.notif_session_failed, snippet) + } else { + getString(R.string.notif_session_complete, snippet) + } + showSessionCompleteNotification(ref, body) + } else { + Log.w( + TAG, + "Session-complete notification skipped: no stored id for runtime ${event.sessionId}", + ) + } + } + } } is WsEvent.ClarifyRequest -> { @@ -365,6 +513,35 @@ class ChatNotificationService : Service() { } } + /** + * A completion from a session the phone did not submit (cron, another + * client). Fires only when "notify on session completion" is on, with the + * session's STORED id (tap opens the real transcript) and its title when + * the gateway reported one. Plain alert, auto-cancel; the notification + * uses the tag overload — per-session tag with one shared id — so tags + * never collide and no session's alert can clobber a live reply + * notification or tombstone the tracker. + */ + private fun showSessionCompleteNotification( + ref: CompletedSessionRef, + body: String, + ) { + val notification = + NotificationCompat + .Builder(this, CHAT_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(ref.title ?: getString(R.string.notif_title)) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(true) + .setContentIntent(buildContentIntent(ref.storedId)) + .build() + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return + manager.notify(ref.storedId, SESSION_COMPLETE_NOTIFICATION_ID, notification) + } + private fun buildContentIntent(sessionId: String?): PendingIntent { val intent = Intent(this, MainActivity::class.java).apply { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 174605b6..84352316 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -241,6 +241,8 @@ internal fun SettingsBehaviorPage( onAutoReconnectChange = viewModel::onAutoReconnectChange, keepConnectedInBackground = state.keepConnectedInBackground, onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange, + notifySessionCompletions = state.notifySessionCompletions, + onNotifySessionCompletionsChange = viewModel::onNotifySessionCompletionsChange, restoreLastSession = state.restoreLastSession, onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index e2ebd227..1c1f3ef5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -47,6 +47,7 @@ data class SettingsUiState( val showTokensPerSecond: Boolean = true, val showModelProvider: Boolean = false, val keepConnectedInBackground: Boolean = false, + val notifySessionCompletions: Boolean = false, val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", @@ -96,6 +97,7 @@ class SettingsViewModel( val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled() val showModelProvider = AuthManager.isModelProviderShown() val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground() + val notifySessionCompletions = AuthManager.isNotifySessionCompletions() val profiles = AuthManager.getConnectionProfiles() val appLanguage = AuthManager.getAppLanguage() val renameProfileName = @@ -124,6 +126,7 @@ class SettingsViewModel( showTokensPerSecond = showTokensPerSecond, showModelProvider = showModelProvider, keepConnectedInBackground = keepConnectedInBackground, + notifySessionCompletions = notifySessionCompletions, profiles = profiles, selectedProfileId = selectedId, renameProfileName = renameProfileName, @@ -394,6 +397,11 @@ class SettingsViewModel( AuthManager.setKeepConnectedInBackground(enabled) } + fun onNotifySessionCompletionsChange(enabled: Boolean) { + _uiState.update { it.copy(notifySessionCompletions = enabled) } + AuthManager.setNotifySessionCompletions(enabled) + } + /** Reconcile the chat shortcut with changes made through Settings or another screen. */ fun refreshKeepConnectedInBackground() { val enabled = AuthManager.isKeepConnectedInBackground() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index fe8b38b0..00c8e92a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -1,5 +1,10 @@ package com.m57.hermescontrol.ui.settings.components +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,8 +18,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.settings.SectionCard @@ -24,9 +31,20 @@ internal fun BehaviorSection( onAutoReconnectChange: (Boolean) -> Unit, keepConnectedInBackground: Boolean, onKeepConnectedInBackgroundChange: (Boolean) -> Unit, + notifySessionCompletions: Boolean, + onNotifySessionCompletionsChange: (Boolean) -> Unit, restoreLastSession: Boolean, onRestoreLastSessionChange: (Boolean) -> Unit, ) { + val context = LocalContext.current + // Turning session-complete alerts on needs POST_NOTIFICATIONS (Android + // 13+). The chat screen requests it on open, but the toggle lives in + // Settings — gate here so enabling never lands in a state where the OS + // silently blocks what the user just asked for. Denial reverts the toggle. + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) onNotifySessionCompletionsChange(true) + } SectionCard { Row( modifier = Modifier.fillMaxWidth(), @@ -84,6 +102,49 @@ internal fun BehaviorSection( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_notify_session_completions_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_notify_session_completions_desc), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = notifySessionCompletions, + onCheckedChange = { enabled -> + if (!enabled) { + onNotifySessionCompletionsChange(false) + } else { + val permission = Manifest.permission.POST_NOTIFICATIONS + val granted = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, permission) == + PackageManager.PERMISSION_GRANTED + if (granted) { + onNotifySessionCompletionsChange(true) + } else { + permissionLauncher.launch(permission) + } + } + }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb91aa7a..213cfc57 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,6 +165,8 @@ Keeps connection alive in the background Hermes Chat Replies Alerts for new replies from Hermes + Session finished: %1$s + Session failed: %1$s Reply @@ -270,6 +272,8 @@ Auth Token Stay connected in background Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. + Notify when a session completes + Alert when any Hermes session finishes while the app is in the background. Keeps Hermes connected in the background (ongoing notification, extra battery use) so finished sessions can alert you. Behavior Auto-Reconnect Automatically reconnect on disconnect diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt index 209cb199..abd1ca1c 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt @@ -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 diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt index fda85801..c0bbc81a 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt index 71e116e7..225a33c1 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 2d678c93..c35f6770 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -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/"