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 NairandM57 authored and GitHub committed 2026-09-30 23:31:08 +03:00
1 parent 0c336ecc4d
commit 5bf43e4a21
14 files changed
+474 -40

No files matched your search

@@ -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,
@@ -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
@@ -30,6 +30,7 @@ data class ActiveSessionItem(
val status: String? = null,
@SerialName("last_active")
val lastActive: Double? = null,
val title: String? = null,
)
/**
@@ -51,6 +51,7 @@ class BackgroundConnectionController(
status = status,
isAutoReconnect = AuthManager.isAutoReconnect(),
hasActiveNetwork = NetworkMonitor.isConnected.value,
notifyCompletionsOptIn = AuthManager.isNotifySessionCompletions(),
)
}
}
@@ -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,
)
}
@@ -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<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(
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 {
@@ -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,
)
@@ -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<ConnectionProfile> = 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()
@@ -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,