Skip to content
Merged
20 changes: 20 additions & 0 deletions app/src/main/java/com/hermes/client/data/network/ServerEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,23 @@ internal fun ServerEvent.bool(key: String): Boolean? =

internal fun ServerEvent.strList(key: String): List<String> =
(payload[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList()

/**
* Counts todo items from a `tool.complete` payload's `todos` array (gateway sends the full list
* as `{id, content, status}` objects). `done` counts `completed`; `total` counts every item that
* is NOT `cancelled` — a cancelled task never completes, so including it would stall the progress
* bar below 100% forever. Defensive like [str]: a malformed or absent payload yields 0 to 0
* rather than throwing, because a throw here would escape the event collector.
*/
internal fun ServerEvent.todoCounts(): Pair<Int, Int> {
val arr = payload["todos"] as? JsonArray ?: return 0 to 0
var done = 0
var total = 0
for (el in arr) {
val status = ((el as? JsonObject)?.get("status") as? JsonPrimitive)?.content?.lowercase()
if (status == "cancelled") continue
total++
if (status == "completed") done++
}
return done to total
}
69 changes: 69 additions & 0 deletions app/src/main/java/com/hermes/client/data/progress/RunProgress.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.hermes.client.data.progress

import com.hermes.client.data.network.ServerEvent
import com.hermes.client.data.network.bool
import com.hermes.client.data.network.str
import com.hermes.client.data.network.todoCounts

/**
* State of the agent run currently in flight, derived purely from gateway WebSocket events.
*
* Deliberately NOT part of ChatUiState: that is scoped to an open chat screen and dies when the
* app is backgrounded, which is exactly when this state must survive to drive a notification.
*/
data class RunProgress(
val running: Boolean = false,
val sessionId: String? = null,
val profile: String? = null,
val tool: String? = null,
val done: Int = 0,
val total: Int = 0,
) {
/** A determinate bar is only possible once the `todo` tool has reported a non-empty list. */
val determinate: Boolean get() = total > 0
}

/**
* Folds one gateway event into run state. Pure — no Android, no IO.
*
* `activeProfile` is latched into the run at the moment it starts (or restarts) so the
* tenant travels with the run itself rather than being re-read from a mutable source at
* notification-post time — a profile switch mid-run must never re-tag an in-flight run's
* notification with a different tenant while its route still points at the original session.
*
* `session.info.running` is the authoritative backstop: `message.complete` alone misses
* interrupted and compacted turns, which would otherwise strand a permanent "running" state.
* Tool events are ignored while idle so a late/stray `tool.*` cannot resurrect a finished run.
*/
fun RunProgress.reduce(event: ServerEvent, activeProfile: String?): RunProgress = when (event.type) {
"message.start" -> RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile)

"tool.start" -> if (!running) this else copy(tool = event.str("name")?.ifBlank { null })

"tool.complete" -> when {
!running -> this
event.str("name") == "todo" -> {
val (d, t) = event.todoCounts()
copy(tool = null, done = d, total = t)
}
else -> copy(tool = null)
}

"message.complete", "error" -> RunProgress()

// Authoritative busy/idle signal. A missing `running` field leaves state untouched.
"session.info" -> when (event.bool("running")) {
false -> RunProgress()
true -> when {
!running -> RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile)
// A differing (non-null) sessionId means a different run started — start fresh
// rather than silently keeping the stale run's counts/sessionId.
event.sessionId != null && event.sessionId != sessionId ->
RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile)
else -> this
}
null -> this
}

else -> this
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@ import kotlinx.coroutines.flow.map

private val Context.notificationDataStore by preferencesDataStore(name = "notifications")

/** Device-local notification preferences (master toggle + approvals + run-finished). Off by default. */
/**
* Device-local notification preferences (master toggle + approvals + run-finished +
* run-progress). Off by default.
*/
class NotificationSettings(private val context: Context) {
private val kEnabled = booleanPreferencesKey("enabled")
private val kApprovals = booleanPreferencesKey("approvals")
private val kRunFinished = booleanPreferencesKey("runFinished")
private val kRunProgress = booleanPreferencesKey("runProgress")

val prefs: Flow<NotificationPrefs> = context.notificationDataStore.data.map { p ->
NotificationPrefs(
enabled = p[kEnabled] ?: false,
approvals = p[kApprovals] ?: true,
runFinished = p[kRunFinished] ?: true,
runProgress = p[kRunProgress] ?: true,
)
}

suspend fun setEnabled(v: Boolean) = context.notificationDataStore.edit { it[kEnabled] = v }
suspend fun setApprovals(v: Boolean) = context.notificationDataStore.edit { it[kApprovals] = v }
suspend fun setRunFinished(v: Boolean) = context.notificationDataStore.edit { it[kRunFinished] = v }
suspend fun setRunProgress(v: Boolean) = context.notificationDataStore.edit { it[kRunProgress] = v }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.os.IBinder
import com.hermes.client.data.network.HermesGatewayClient
import com.hermes.client.data.progress.reduce
import com.hermes.client.data.repository.NotificationSettings
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
Expand All @@ -22,8 +23,12 @@ class GatewayConnectionService : Service() {
@Inject lateinit var client: HermesGatewayClient
@Inject lateinit var settings: NotificationSettings
@Inject lateinit var notifier: HermesNotifier
@Inject lateinit var profiles: com.hermes.client.data.repository.ProfileManager

private val scope = CoroutineScope(SupervisorJob())
// Held separately (not just scope.coroutineContext[Job]) so onDestroy can register an
// invokeOnCompletion callback on it directly — see onDestroy for why.
private val job = SupervisorJob()
private val scope = CoroutineScope(job)

// Latest notification prefs, kept current by a collector so the hot event loop never blocks on
// DataStore. @Volatile for cross-thread visibility (scope has no single-thread dispatcher).
Expand All @@ -35,6 +40,15 @@ class GatewayConnectionService : Service() {
// dispatcher).
@Volatile private var appInForeground = false

// Live run state, folded from the same event stream. @Volatile for the same reason as the
// fields above: the collector scope has no single-thread dispatcher.
@Volatile private var runProgress = com.hermes.client.data.progress.RunProgress()

// Last spec actually posted. message.delta fires many times per second and does not change
// the spec, so re-posting on every event would burn cycles and visibly flicker the
// notification. Only act when the derived spec actually changes.
@Volatile private var lastRunSpec: RunProgressSpec? = null

// ProcessLifecycleOwner is a process-lifetime singleton; hold the observer so onDestroy can
// remove it — otherwise each stop/start of this service (e.g. toggling notifications) would
// leak the retired Service instance (and its injected WS client) forever.
Expand Down Expand Up @@ -66,6 +80,7 @@ class GatewayConnectionService : Service() {
// ChatViewModel's reduce() uses around event handling.
runCatching {
toNotificationSpec(event, latestPrefs, appInForeground)?.let { notifier.post(it) }
updateRunProgress(event)
}
}
}
Expand All @@ -74,6 +89,22 @@ class GatewayConnectionService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
override fun onBind(intent: Intent?): IBinder? = null

/** Folds the event into run state and posts/cancels the progress notification on change. */
private fun updateRunProgress(event: com.hermes.client.data.network.ServerEvent) {
// Single read: profiles.active.value feeds the reducer, which latches it into the run
// itself (RunProgress.profile). The spec and the post call below both derive their tenant
// from that latched value rather than re-reading profiles.active.value, so a profile
// switch landing mid-run can never split a notification's title/route from its accent
// colour across two different tenants — see RunProgress.reduce's kdoc.
val activeProfile = profiles.active.value
runProgress = runProgress.reduce(event, activeProfile)
val spec = runProgress.toSpec(latestPrefs)
if (spec == lastRunSpec) return
lastRunSpec = spec
if (spec != null) notifier.postRunProgress(spec, runProgress.profile)
else notifier.cancelRunProgress()
}

// Android 15+ (API 35) caps a dataSync foreground service at ~6h and calls this instead of
// just killing the process; Android 16 (API 36) added a fgsType-aware overload. Implement
// both so whichever the OS invokes stops the service cleanly rather than crashing/ANR-ing.
Expand All @@ -87,6 +118,15 @@ class GatewayConnectionService : Service() {
}

override fun onDestroy() {
// A stopped service must never strand an ongoing "running" notification. scope.cancel()
// is cooperative: it does not preempt a collector iteration already executing
// synchronously, so if the collector is mid-lambda when we get here it can still call
// notifier.postRunProgress(...) after this function returns, with no ordering guarantee
// against a cancelRunProgress() called from here directly. Hanging the final cancel off
// the Job's actual completion — rather than off the cancel() call itself — guarantees it
// runs strictly after every child coroutine (including any such in-flight iteration) has
// finished, so no post can ever win the race.
job.invokeOnCompletion { notifier.cancelRunProgress() }
scope.cancel()
// onDestroy() runs on the main thread — remove the observer synchronously so this Service
// instance isn't retained (and no event can hit a defunct instance in a deferred window).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import com.hermes.client.MainActivity
import com.hermes.client.R
import com.hermes.client.ui.theme.accentArgb

/** Owns notification channels and turns a [NotificationSpec] into a posted Android notification. */
class HermesNotifier(private val context: Context) {
Expand All @@ -29,6 +32,9 @@ class HermesNotifier(private val context: Context) {
sys.createNotificationChannel(
NotificationChannel(Notif.CHANNEL_ACTIVITY, "Activity", NotificationManager.IMPORTANCE_DEFAULT),
)
sys.createNotificationChannel(
NotificationChannel(Notif.CHANNEL_RUN_PROGRESS, "Run progress", NotificationManager.IMPORTANCE_LOW),
)
}

fun serviceNotification(): Notification =
Expand Down Expand Up @@ -68,6 +74,65 @@ class HermesNotifier(private val context: Context) {

fun cancel(id: Int) = mgr.cancel(id)

/**
* Posts (or updates) the single ongoing run-progress notification. On API 36+ this uses the
* platform ProgressStyle so the system can promote it to a status-bar Live Update; below that
* it falls back to an ordinary ongoing progress notification.
*
* androidx.core 1.16.0 has no NotificationCompat.ProgressStyle, so the API 36+ branch builds
* with the platform Notification.Builder rather than upgrading the dependency.
*/
fun postRunProgress(spec: RunProgressSpec, profile: String?) {
if (!mgr.areNotificationsEnabled()) return
val accent = accentFor(profile)
val n = if (Build.VERSION.SDK_INT >= 36) buildPromoted(spec, accent) else buildCompat(spec, accent)
mgr.notify(RUN_PROGRESS_NOTIFICATION_ID, n)
}

fun cancelRunProgress() = mgr.cancel(RUN_PROGRESS_NOTIFICATION_ID)

/** Tenant accent, resolved against the system's current night mode. Chrome only. */
private fun accentFor(profile: String?): Int {
val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
return accentArgb(profile, dark)
}

@androidx.annotation.RequiresApi(36)
private fun buildPromoted(spec: RunProgressSpec, accent: Int): Notification {
val style = Notification.ProgressStyle().setProgressIndeterminate(spec.indeterminate)
if (!spec.indeterminate) {
// ProgressStyle has no setProgressMax(): the bar's maximum is the SUM of its segment
// lengths, so one segment of `total` gives a bar of exactly that length.
style.addProgressSegment(Notification.ProgressStyle.Segment(spec.total).setColor(accent))
style.setProgress(spec.done)
}
val b = Notification.Builder(context, Notif.CHANNEL_RUN_PROGRESS)
.setSmallIcon(R.drawable.ic_stat_hermes)
.setContentTitle(spec.title)
.setContentText(spec.body)
.setStyle(style)
.setOngoing(true)
.setColor(accent)
.setContentIntent(openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID))
// Status-bar chip text on a promoted notification. The system decides promotion itself
// (Notification.FLAG_PROMOTED_ONGOING); there is no request API to call.
spec.shortText?.let { b.setShortCriticalText(it) }
return b.build()
}

private fun buildCompat(spec: RunProgressSpec, accent: Int): Notification =
NotificationCompat.Builder(context, Notif.CHANNEL_RUN_PROGRESS)
.setSmallIcon(R.drawable.ic_stat_hermes)
.setContentTitle(spec.title)
.setContentText(spec.body)
.setProgress(spec.total, spec.done, spec.indeterminate)
.setOngoing(true)
.setColor(accent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID))
.build()

private fun openIntent(route: String?, id: Int): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
Expand Down Expand Up @@ -108,5 +173,9 @@ class HermesNotifier(private val context: Context) {

companion object {
const val SERVICE_NOTIFICATION_ID = 1001

// Distinct from SERVICE_NOTIFICATION_ID (1001) and from toNotificationSpec's 1002
// collision fallback, so the ongoing progress notification can never clobber either.
const val RUN_PROGRESS_NOTIFICATION_ID = 1003
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ fun toNotificationSpec(event: ServerEvent, prefs: NotificationPrefs, appInForegr
if (!prefs.enabled) return null
val sid = event.sessionId ?: return null
var id = (event.type + sid).hashCode()
// Never collide with HermesNotifier.SERVICE_NOTIFICATION_ID (1001) — that id belongs to the
// ongoing foreground-service notification, and notify()-ing over it would clobber it.
if (id == 1001) id = 1002
// Never collide with HermesNotifier.SERVICE_NOTIFICATION_ID (1001) or the run-progress
// notification id (1003) — those ids belong to the ongoing foreground-service notification
// and the live run-progress notification, and notify()-ing over either would clobber it.
if (id == 1001 || id == 1003) id = 1002
return when (event.type) {
Notif.EVENT_APPROVAL -> if (!prefs.approvals) null else {
val elevated = tierFor(event.bool("allow_permanent") ?: false) == ApprovalTier.ELEVATED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ data class NotificationPrefs(
val enabled: Boolean = false,
val approvals: Boolean = true,
val runFinished: Boolean = true,
val runProgress: Boolean = true,
)

/**
Expand All @@ -30,11 +31,30 @@ data class NotificationSpec(
val groupKey: String,
)

/**
* A platform-independent description of the live run-progress notification, so mapping stays
* unit-testable. [indeterminate] means no todo counts are available yet; [shortText] is the
* status-bar chip text used on API 36+ promoted notifications (null when indeterminate).
*/
data class RunProgressSpec(
val title: String,
val body: String,
val done: Int,
val total: Int,
val indeterminate: Boolean,
val route: String?,
val shortText: String?,
)

/** Channel ids, gateway event-type strings, and action names in one place. */
object Notif {
const val CHANNEL_APPROVALS = "approvals"
const val CHANNEL_SERVICE = "service"
const val CHANNEL_ACTIVITY = "activity"
// Live in-flight run progress. IMPORTANCE_LOW (not MIN like CHANNEL_SERVICE) so the ongoing
// progress notification is actually glanceable in the shade and eligible for promotion to a
// status-bar Live Update on API 36+, while still making no sound.
const val CHANNEL_RUN_PROGRESS = "run_progress"

// Notifiable events on the app's WebSocket (/api/ws), verified against the gateway source:
// - approval.request / clarify.request -> the agent needs the user (always notify)
Expand All @@ -48,6 +68,12 @@ object Notif {
const val EVENT_CLARIFY = "clarify.request"
const val EVENT_MESSAGE_COMPLETE = "message.complete"
const val EVENT_ERROR = "error"
// Run-lifecycle events consumed by the run-progress reducer (not by toNotificationSpec).
// `session.info` carries "running": bool and is the authoritative busy/idle backstop.
const val EVENT_MESSAGE_START = "message.start"
const val EVENT_TOOL_START = "tool.start"
const val EVENT_TOOL_COMPLETE = "tool.complete"
const val EVENT_SESSION_INFO = "session.info"

const val ACTION_ALLOW_ONCE = "allow_once"
const val ACTION_ALLOW_SESSION = "allow_session"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.hermes.client.notifications

import com.hermes.client.data.progress.RunProgress

/**
* Pure mapping from run state to a notification description, or null when nothing should be
* shown. Mirrors [toNotificationSpec]: all decisions live here so they are testable without
* Android, and [HermesNotifier] only renders.
*/
fun RunProgress.toSpec(prefs: NotificationPrefs): RunProgressSpec? {
if (!prefs.enabled || !prefs.runProgress) return null
if (!running) return null
val tenant = profile?.takeIf { it.isNotBlank() }
return RunProgressSpec(
title = if (tenant != null) "$tenant · agent running" else "Agent running",
body = tool?.let { "Calling tool: $it" } ?: "Working…",
done = done,
total = total,
indeterminate = !determinate,
route = sessionId?.let { "chat/$it" },
shortText = if (determinate) "$done/$total" else null,
)
}
Loading