feat(notifications): optional alert when any session completes (#1401)

Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
Nikhil Nair
2026-09-30 23:31:08 +03:00
committed by GitHub
co-authored by M57
parent 0c336ecc4d
commit 5bf43e4a21
14 changed files with 474 additions and 40 deletions
@@ -32,6 +32,9 @@ data class ServerStoreState(
val showTokensPerSecond: Boolean = true, val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false, val showModelProvider: Boolean = false,
val keepConnectedInBackground: 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 // Opt-in update channel: when true the in-app update check also considers
// pre-release release-candidate GitHub releases, not just stable ones. // pre-release release-candidate GitHub releases, not just stable ones.
val checkReleaseCandidateUpdates: Boolean = false, val checkReleaseCandidateUpdates: Boolean = false,
@@ -871,6 +871,12 @@ object AuthManager {
serverStore.update { it.copy(keepConnectedInBackground = enabled) } 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) ─────────────────────────────────── // ── Chat Font Scale (issue #1004) ───────────────────────────────────
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
@@ -30,6 +30,7 @@ data class ActiveSessionItem(
val status: String? = null, val status: String? = null,
@SerialName("last_active") @SerialName("last_active")
val lastActive: Double? = null, val lastActive: Double? = null,
val title: String? = null,
) )
/** /**
@@ -51,6 +51,7 @@ class BackgroundConnectionController(
status = status, status = status,
isAutoReconnect = AuthManager.isAutoReconnect(), isAutoReconnect = AuthManager.isAutoReconnect(),
hasActiveNetwork = NetworkMonitor.isConnected.value, hasActiveNetwork = NetworkMonitor.isConnected.value,
notifyCompletionsOptIn = AuthManager.isNotifySessionCompletions(),
) )
} }
} }
@@ -14,6 +14,11 @@ data class BackgroundConnectionSnapshot(
val status: ConnectionStatus, val status: ConnectionStatus,
val isAutoReconnect: Boolean = true, val isAutoReconnect: Boolean = true,
val hasActiveNetwork: 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 { companion object {
fun ineligible( fun ineligible(
@@ -77,8 +82,12 @@ object BackgroundConnectionPolicy {
) )
} }
// Demand check: need either a pending reply or persistent opt-in // Demand check: need a pending reply or a persistent opt-in. The
val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn // 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) { if (!hasDemand) {
return BackgroundConnectionDecision( return BackgroundConnectionDecision(
shouldHoldService = false, shouldHoldService = false,
@@ -134,7 +143,7 @@ object BackgroundConnectionPolicy {
return BackgroundConnectionDecision( return BackgroundConnectionDecision(
shouldHoldService = true, shouldHoldService = true,
shouldHoldPersistentLease = snapshot.keepConnectedOptIn, shouldHoldPersistentLease = keepConnectedLease,
notificationState = notificationState, notificationState = notificationState,
) )
} }
@@ -9,16 +9,21 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager 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.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent 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 com.m57.hermescontrol.ui.chat.replyFailureFromPayload
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -27,6 +32,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.serializer
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
internal data class MessageCompleteNotificationPlan( internal data class MessageCompleteNotificationPlan(
@@ -38,6 +46,87 @@ internal data class MessageCompleteNotificationPlan(
val allowInlineReply: Boolean, 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<ActiveSessionsResponse>(), 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( internal fun messageCompleteNotificationPlan(
event: WsEvent.MessageComplete, event: WsEvent.MessageComplete,
targetSessionId: String?, targetSessionId: String?,
@@ -107,6 +196,9 @@ 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
/** Session-complete notifications: one fixed id, tagged per stored session. */
private const val SESSION_COMPLETE_NOTIFICATION_ID = 10_000
private val isAppInForeground = AtomicBoolean(false) private val isAppInForeground = AtomicBoolean(false)
internal val lifecycle = ForegroundServiceLifecycle() internal val lifecycle = ForegroundServiceLifecycle()
@@ -153,43 +245,99 @@ class ChatNotificationService : Service() {
if (!isAppInForeground.get()) { if (!isAppInForeground.get()) {
when (event) { when (event) {
is WsEvent.MessageComplete -> { is WsEvent.MessageComplete -> {
val targetSessionId = when (
event.storedSessionId val route =
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId) messageCompleteRoute(
val plan = holderSessionId = ActiveSessionHolder.activeSessionId.value,
messageCompleteNotificationPlan( toggleOn = AuthManager.isNotifySessionCompletions(),
event = event, eventSessionId = event.sessionId,
targetSessionId = targetSessionId, )
newMessageText = getString(R.string.notif_new_message), ) {
failureText = getString(R.string.chat_reply_failed_title), MessageCompleteRoute.Reply -> {
) val targetSessionId =
showReplyNotification( event.storedSessionId
text = plan.text, ?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
sessionId = plan.sessionId, val plan =
isReplyMessage = plan.isReplyMessage, messageCompleteNotificationPlan(
completionId = plan.completionId, event = event,
allowInlineReply = plan.allowInlineReply, targetSessionId = targetSessionId,
// The durable REST row for this turn, when the newMessageText = getString(R.string.notif_new_message),
// boundary armed before the prompt was submitted failureText = getString(R.string.chat_reply_failed_title),
// still lets us name it unambiguously. Null is a )
// normal, safe outcome: the notification is then showReplyNotification(
// never auto-dismissed from REST hydration, which text = plan.text,
// is strictly better than dismissing the wrong sessionId = plan.sessionId,
// duplicate reply. isReplyMessage = plan.isReplyMessage,
serverMessageId = completionId = plan.completionId,
plan.correlationText?.let { allowInlineReply = plan.allowInlineReply,
coalesceTurnRow(plan.sessionId, it) // 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
// The wait is over — retire the foreground // normal, safe outcome: the notification is then
// service. The reply notification above // never auto-dismissed from REST hydration.
// replaces the persistent "waiting" one, serverMessageId =
// and the pendingReply flag is cleared by plan.correlationText?.let {
// HermesWsClient's own collector, so the coalesceTurnRow(plan.sessionId, it)
// service is not restarted on the next },
// ON_STOP (issue #794). )
// A delayed completion must not retire a newer turn/start. // The wait is over: retire the foreground service
BackgroundConnectionController.default.onReplyCompleted(generation) // (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 -> { 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 { private fun buildContentIntent(sessionId: String?): PendingIntent {
val intent = val intent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
@@ -241,6 +241,8 @@ internal fun SettingsBehaviorPage(
onAutoReconnectChange = viewModel::onAutoReconnectChange, onAutoReconnectChange = viewModel::onAutoReconnectChange,
keepConnectedInBackground = state.keepConnectedInBackground, keepConnectedInBackground = state.keepConnectedInBackground,
onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange, onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange,
notifySessionCompletions = state.notifySessionCompletions,
onNotifySessionCompletionsChange = viewModel::onNotifySessionCompletionsChange,
restoreLastSession = state.restoreLastSession, restoreLastSession = state.restoreLastSession,
onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange,
) )
@@ -47,6 +47,7 @@ data class SettingsUiState(
val showTokensPerSecond: Boolean = true, val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false, val showModelProvider: Boolean = false,
val keepConnectedInBackground: Boolean = false, val keepConnectedInBackground: Boolean = false,
val notifySessionCompletions: Boolean = false,
val profiles: List<ConnectionProfile> = emptyList(), val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null, val selectedProfileId: String? = null,
val renameProfileName: String = "", val renameProfileName: String = "",
@@ -96,6 +97,7 @@ class SettingsViewModel(
val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled() val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled()
val showModelProvider = AuthManager.isModelProviderShown() val showModelProvider = AuthManager.isModelProviderShown()
val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground() val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground()
val notifySessionCompletions = AuthManager.isNotifySessionCompletions()
val profiles = AuthManager.getConnectionProfiles() val profiles = AuthManager.getConnectionProfiles()
val appLanguage = AuthManager.getAppLanguage() val appLanguage = AuthManager.getAppLanguage()
val renameProfileName = val renameProfileName =
@@ -124,6 +126,7 @@ class SettingsViewModel(
showTokensPerSecond = showTokensPerSecond, showTokensPerSecond = showTokensPerSecond,
showModelProvider = showModelProvider, showModelProvider = showModelProvider,
keepConnectedInBackground = keepConnectedInBackground, keepConnectedInBackground = keepConnectedInBackground,
notifySessionCompletions = notifySessionCompletions,
profiles = profiles, profiles = profiles,
selectedProfileId = selectedId, selectedProfileId = selectedId,
renameProfileName = renameProfileName, renameProfileName = renameProfileName,
@@ -394,6 +397,11 @@ class SettingsViewModel(
AuthManager.setKeepConnectedInBackground(enabled) 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. */ /** Reconcile the chat shortcut with changes made through Settings or another screen. */
fun refreshKeepConnectedInBackground() { fun refreshKeepConnectedInBackground() {
val enabled = AuthManager.isKeepConnectedInBackground() val enabled = AuthManager.isKeepConnectedInBackground()
@@ -1,5 +1,10 @@
package com.m57.hermescontrol.ui.settings.components 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -13,8 +18,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.settings.SectionCard import com.m57.hermescontrol.ui.settings.SectionCard
@@ -24,9 +31,20 @@ internal fun BehaviorSection(
onAutoReconnectChange: (Boolean) -> Unit, onAutoReconnectChange: (Boolean) -> Unit,
keepConnectedInBackground: Boolean, keepConnectedInBackground: Boolean,
onKeepConnectedInBackgroundChange: (Boolean) -> Unit, onKeepConnectedInBackgroundChange: (Boolean) -> Unit,
notifySessionCompletions: Boolean,
onNotifySessionCompletionsChange: (Boolean) -> Unit,
restoreLastSession: Boolean, restoreLastSession: Boolean,
onRestoreLastSessionChange: (Boolean) -> Unit, 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 { SectionCard {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -84,6 +102,49 @@ internal fun BehaviorSection(
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp)) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
+4
View File
@@ -165,6 +165,8 @@
<string name="notif_channel_service_desc">Keeps connection alive in the background</string> <string name="notif_channel_service_desc">Keeps connection alive in the background</string>
<string name="notif_channel_chat_name">Hermes Chat Replies</string> <string name="notif_channel_chat_name">Hermes Chat Replies</string>
<string name="notif_channel_chat_desc">Alerts for new replies from Hermes</string> <string name="notif_channel_chat_desc">Alerts for new replies from Hermes</string>
<string name="notif_session_complete">Session finished: %1$s</string>
<string name="notif_session_failed">Session failed: %1$s</string>
<string name="action_reply">Reply</string> <string name="action_reply">Reply</string>
<!-- Chat Screen & Bubble --> <!-- Chat Screen & Bubble -->
@@ -270,6 +272,8 @@
<string name="settings_field_token">Auth Token</string> <string name="settings_field_token">Auth Token</string>
<string name="settings_keep_connected_in_background_title">Stay connected in background</string> <string name="settings_keep_connected_in_background_title">Stay connected in background</string>
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string> <string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
<string name="settings_notify_session_completions_title">Notify when a session completes</string>
<string name="settings_notify_session_completions_desc">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.</string>
<string name="settings_sec_behavior">Behavior</string> <string name="settings_sec_behavior">Behavior</string>
<string name="settings_item_auto_reconnect">Auto-Reconnect</string> <string name="settings_item_auto_reconnect">Auto-Reconnect</string>
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string> <string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
@@ -287,6 +287,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns true every { AuthManager.isGatedMode() } returns true
every { AuthManager.getToken() } returns null every { AuthManager.getToken() } returns null
every { AuthManager.isKeepConnectedInBackground() } returns true every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false every { HermesWsClient.pendingReply } returns false
@@ -320,6 +321,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns null every { AuthManager.getToken() } returns null
every { AuthManager.isKeepConnectedInBackground() } returns true every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false every { HermesWsClient.pendingReply } returns false
@@ -352,6 +354,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns "some-token" every { AuthManager.getToken() } returns "some-token"
every { AuthManager.isKeepConnectedInBackground() } returns true every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false every { HermesWsClient.pendingReply } returns false
@@ -385,6 +388,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns "some-token" every { AuthManager.getToken() } returns "some-token"
every { AuthManager.isKeepConnectedInBackground() } returns true every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isAutoReconnect() } returns false
every { ChatNotificationService.isAppInForeground() } returns false every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false every { HermesWsClient.pendingReply } returns false
@@ -250,4 +250,46 @@ class BackgroundConnectionPolicyTest {
assertTrue(decision.shouldHoldPersistentLease) assertTrue(decision.shouldHoldPersistentLease)
assertEquals(BackgroundNotificationState.Connecting, decision.notificationState) 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 package com.m57.hermescontrol.notification
import android.content.Context import android.content.Context
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@@ -164,3 +165,116 @@ class ChatNotificationServiceTest {
assertEquals("Answer\ncontinued", plan.correlationText) 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.isTokensPerSecondEnabled() } returns true
every { AuthManager.isModelProviderShown() } returns false every { AuthManager.isModelProviderShown() } returns false
every { AuthManager.isKeepConnectedInBackground() } 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.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"