Compare commits

...
38 changed files with 1839 additions and 226 deletions
+3
View File
@@ -8,10 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
### Fixed
- **Background chats stay reachable without an always-on connection.** Android now protects user-started work until every concurrent Gateway session settles, shows active and waiting counts in its ongoing notification, and gives each privacy-safe interaction alert expanded session context plus a direct review, answer, or secure-response action.
- **Android alerts when a background Gateway turn needs input.** Approval, clarification, elevated-permission, and secret requests post privacy-safe notifications that reopen the correct conversation, survive reconnect replay without duplicates, and clear when the request is answered or expires.
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
+48
View File
@@ -1,5 +1,38 @@
# Hermes-Relay — Dev Log
## 2026-07-25 — Active-turn retention and actionable interaction alerts
Android now promotes user-started chat work to foreground execution until every
connection/profile/session-scoped turn settles. Independent leases preserve
concurrent detached Gateway sessions, track sessions paused for input, and
prevent one completion from stopping protection for siblings. The existing
Persistent connection switch now extends retention only to idle periods.
The foreground notification reports active and waiting session counts.
Interaction alerts add privacy-safe expanded profile/session context and an
explicit review, answer, or secure-response action that deep-links to the exact
conversation; commands, questions, passwords, secrets, and environment-variable
names remain confined to the authenticated chat surface.
Audited `ChatViewModel`, `ConnectionViewModel`, `GatewayChatClient`,
`GatewayKeepAliveService`, `InteractionRequestNotifier`, the shared Android
manifest, Android settings copy, chat user documentation, and Play foreground
service declaration guidance.
## 2026-07-24 — Post-connect permission setup
Android onboarding now finishes with a layered permission step after a
successful Hermes connection. Standard Chat and Manage are explicitly ready
without a phone grant; Android notifications are recommended through one
user-triggered runtime prompt; camera, microphone, notification companion, and
flavor-supported device tools remain individually optional on the centralized
Permissions screen. Existing just-in-time permission prompts remain available
when users skip setup.
Coverage separates the Android-version notification policy from the Compose
presentation and checks the recommended, granted, optional-review, and skip
states. English and all shipped Android locale catalogs were updated together.
## 2026-07-24 — Realtime background-task delivery continuity
Chat stream completion now preserves an otherwise empty assistant row when it
@@ -9,6 +42,21 @@ forced-summary events update and settle the initiating row even when a newer
persistent Voice command has started. Existing empty-response cleanup remains
unchanged for assistant rows without background work.
## 2026-07-23 — Background interaction notifications
Android Gateway chat now treats approval, clarification, elevated-permission,
and secret requests as actionable background events. Privacy-safe notifications
use stable per-session identities, reopen the exact conversation, replace
replayed requests, and clear on answer, expiry, or resumed turn activity.
Detached active turns retain and replay their pending interaction when the
conversation is reopened.
The audit classified `terminal.read.request` as renderer plumbing rather than a
user decision. Android now answers it with the upstream no-terminal empty
response instead of showing an interaction or waiting for the server timeout.
The shared main manifest continues to provide notification and persistent
connection support to both Google Play and sideload builds.
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
The shared Android network security configuration now accepts CA certificates
+5 -7
View File
@@ -90,12 +90,10 @@
android:name=".notifications.ProactiveReplyReceiver"
android:exported="false" />
<!-- Opt-in "Persistent connection" — holds the user's connection to
Hermes open while backgrounded so messages and live features stay
responsive (relay-paired setups also keep device control +
notification mirroring reachable). In main so BOTH flavors ship it
(Home-Assistant-class persistent connection). Off by default; only
runs while the user has explicitly enabled the toggle. specialUse
<!-- Protects user-started active turns automatically; the optional
"Persistent connection" setting extends the same foreground
protection to idle/background connectivity (and relay-paired
device features). In main so BOTH flavors ship it. specialUse
needs a Play Console foreground-service declaration at submission. -->
<service
android:name=".network.upstream.GatewayKeepAliveService"
@@ -103,7 +101,7 @@
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
</service>
</application>
@@ -20,6 +20,7 @@ import com.hermesandroid.relay.bridge.BridgeForegroundService
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -142,6 +143,10 @@ class MainActivity : AppCompatActivity() {
// Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this)
// Action-required notifications are durable across process death.
// Once the authenticated chat surface is visible it owns presentation;
// unresolved asks are re-posted if the app returns to the background.
InteractionRequestNotifier.cancelAll(this)
// v0.4.1 — register this activity as the host for
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
// we don't leak the Activity past its lifecycle. The unattended-
@@ -0,0 +1,76 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-local ownership of background protection for work the user already
* started. Keys are connection/profile/session scoped, so detached sibling
* sessions retain independent leases and one completion cannot release
* another session's protection.
*/
object ActiveTurnKeepAliveRegistry {
data class Snapshot(
val activeTurnCount: Int = 0,
val waitingSessionCount: Int = 0,
) {
val required: Boolean get() = activeTurnCount > 0
}
private val lock = Any()
private val leases = linkedMapOf<String, Boolean>()
private val _snapshot = MutableStateFlow(Snapshot())
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()
fun acquire(key: String) {
synchronized(lock) {
leases[key] = leases[key] ?: false
publishLocked()
}
}
fun setWaiting(key: String, waiting: Boolean) {
synchronized(lock) {
if (key in leases) {
leases[key] = waiting
publishLocked()
}
}
}
fun rename(oldKey: String, newKey: String) {
if (oldKey == newKey) return
synchronized(lock) {
val waiting = leases.remove(oldKey) ?: return
leases[newKey] = waiting
publishLocked()
}
}
fun release(key: String) {
synchronized(lock) {
if (leases.remove(key) != null) publishLocked()
}
}
fun releaseAll() {
synchronized(lock) {
if (leases.isNotEmpty()) {
leases.clear()
publishLocked()
}
}
}
internal fun resetForTest() {
releaseAll()
}
private fun publishLocked() {
_snapshot.value = Snapshot(
activeTurnCount = leases.size,
waitingSessionCount = leases.count { it.value },
)
}
}
@@ -372,6 +372,7 @@ class GatewayChatClient(
private data class BackgroundTurn(
val storedSessionId: String,
val profile: String?,
@Volatile var pendingAsk: GatewayAsk? = null,
)
/**
@@ -410,6 +411,11 @@ class GatewayChatClient(
private var unmatchedTurnCompleteListener:
((GatewayBackgroundTurnCompletion) -> Unit)? = null
/** Input requested or resolved on a deliberately detached turn. */
@Volatile
private var backgroundInteractionListener:
((GatewayBackgroundInteractionEvent) -> Unit)? = null
/**
* Connection-level process listener. Unlike [GatewayTurnCallbacks], this is
* consulted even when there is no locally initiated [activeTurn].
@@ -591,10 +597,23 @@ class GatewayChatClient(
val turn = activeTurn?.takeIf { !it.ended } ?: return false
val liveId = liveSessionId ?: return false
val storedId = storedSessionId ?: return false
backgroundTurns[liveId] = BackgroundTurn(
val backgroundTurn = BackgroundTurn(
storedSessionId = storedId,
profile = liveSessionProfile,
pendingAsk = turn.pendingInteraction,
)
backgroundTurns[liveId] = backgroundTurn
backgroundTurn.pendingAsk?.let { ask ->
callbackDispatcher {
backgroundInteractionListener?.invoke(
GatewayBackgroundInteractionEvent.Requested(
storedSessionId = storedId,
profile = backgroundTurn.profile,
ask = ask,
),
)
}
}
turn.detach()
return true
}
@@ -664,6 +683,12 @@ class GatewayChatClient(
unmatchedTurnCompleteListener = listener
}
fun setBackgroundInteractionListener(
listener: ((GatewayBackgroundInteractionEvent) -> Unit)?,
) {
backgroundInteractionListener = listener
}
fun setProcessEventListener(listener: ((GatewayProcessEvent) -> Unit)?) {
processEventListener = listener
}
@@ -854,6 +879,9 @@ class GatewayChatClient(
}
}
boundTurn.releaseDeferredEvents()
claimedBackground?.pendingAsk?.let { ask ->
boundTurn.restoreInteraction(ask)
}
boundTurn.armWatchdog()
} else if (queued != null) {
// A queued-only snapshot belongs to the NEXT turn. Never let
@@ -1381,6 +1409,7 @@ class GatewayChatClient(
unsolicitedTurnProvider = null
coldPrewarmSessionReadyListener = null
unmatchedTurnCompleteListener = null
backgroundInteractionListener = null
processEventListener = null
closeSocket("client shutdown")
backgroundCloseJob?.cancel()
@@ -1792,6 +1821,30 @@ class GatewayChatClient(
return
}
// read_terminal is a renderer query, not a user decision. Android has
// no xterm pane on the Gateway chat surface, so mirror upstream
// desktop's no-live-pane behavior and answer with empty text instead
// of blocking the agent for the server's 30-second timeout.
if (type == "terminal.read.request") {
val requestId = payload?.stringField("request_id")
val ownedSession = !eventSessionId.isNullOrBlank() &&
(eventSessionId == liveSessionId || backgroundTurns.containsKey(eventSessionId))
if (!requestId.isNullOrBlank() && ownedSession) {
scope.launch {
rpc(
"terminal.read.respond",
buildJsonObject {
put("request_id", requestId)
put("text", "")
},
).onFailure {
Log.w(TAG, "terminal.read.respond failed: ${it.message}")
}
}
}
return
}
// A profile/session switch may leave an upstream turn running while a
// different profile becomes visible. Its events must never paint the
// new transcript, but the terminal event still needs to reconcile the
@@ -1799,6 +1852,47 @@ class GatewayChatClient(
// switches back.
val backgroundTurn = eventSessionId?.let(backgroundTurns::get)
if (backgroundTurn != null) {
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
if (interactionRequest != null) {
val previous = backgroundTurn.pendingAsk
backgroundTurn.pendingAsk = interactionRequest
if (previous?.kind != interactionRequest.kind ||
previous.requestId != interactionRequest.requestId
) {
callbackDispatcher {
backgroundInteractionListener?.invoke(
GatewayBackgroundInteractionEvent.Requested(
storedSessionId = backgroundTurn.storedSessionId,
profile = backgroundTurn.profile,
ask = interactionRequest,
),
)
}
}
return
}
val expiry = GatewayEventMapper.interactionExpiry(type, payload)
val pendingAsk = backgroundTurn.pendingAsk
val explicitlyExpired = expiry != null && pendingAsk != null &&
pendingAsk.kind == expiry.kind &&
(pendingAsk.kind == GatewayAsk.Kind.APPROVAL ||
pendingAsk.requestId == expiry.requestId)
val turnResumed = pendingAsk != null &&
GatewayEventMapper.isInteractionResumeEvent(type)
if (explicitlyExpired || turnResumed) {
backgroundTurn.pendingAsk = null
callbackDispatcher {
backgroundInteractionListener?.invoke(
GatewayBackgroundInteractionEvent.Resolved(
storedSessionId = backgroundTurn.storedSessionId,
profile = backgroundTurn.profile,
ask = pendingAsk,
),
)
}
}
if (type == "message.complete" || type == "error") {
backgroundTurns.remove(eventSessionId, backgroundTurn)
val expectedText = if (type == "message.complete") payload?.stringField("text") else null
@@ -2271,7 +2365,7 @@ class GatewayChatClient(
private fun watchdogTimeoutFor(eventType: String): Long = when (eventType) {
"clarify.request", "secret.request" -> ASK_CLARIFY_SECRET_TIMEOUT_MS
"sudo.request" -> ASK_SUDO_TIMEOUT_MS
"approval.request", "terminal.read.request" -> ASK_UNBOUNDED_TIMEOUT_MS
"approval.request" -> ASK_UNBOUNDED_TIMEOUT_MS
else -> turnIdleTimeoutMs
}
@@ -2281,6 +2375,11 @@ class GatewayChatClient(
deferEvents: Boolean = false,
) : ActiveTurnHandle {
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
val pendingInteraction: GatewayAsk?
get() = mapper.currentInteraction
fun restoreInteraction(ask: GatewayAsk) {
mapper.restoreInteraction(ask)
}
private val deferredEventLock = Any()
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
private var eventsDeferred = deferEvents
@@ -2620,6 +2719,7 @@ class GatewayChatClient(
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
// onStatusUpdate a default no-op, so omitting it here silently swallows
// EVERY gateway status line — the ❌ terminal-error lifecycle update
@@ -31,6 +31,15 @@ class GatewayEventMapper(
var turnEnded: Boolean = false
private set
internal val currentInteraction: GatewayAsk?
get() = pendingInteraction
internal fun restoreInteraction(ask: GatewayAsk) {
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
pendingInteraction = ask
if (!duplicate) callbacks.onInteractionRequest(ask)
}
private var sawMessageStart = false
private var previousEventType: String? = null
private var sawTextDelta = false
@@ -39,6 +48,7 @@ class GatewayEventMapper(
private var syntheticToolCounter = 0
private var providerWaitStatusActive = false
private var compactionStatusActive = false
private var pendingInteraction: GatewayAsk? = null
/**
* `tool.complete` events match their `tool.start` by `tool_id`; when a
@@ -57,6 +67,30 @@ class GatewayEventMapper(
fun onEvent(type: String, payload: JsonObject?) {
if (turnEnded) return
interactionRequest(type, payload)?.let { ask ->
restoreInteraction(ask)
previousEventType = type
return
}
interactionExpiry(type, payload)?.let { expiry ->
val pending = pendingInteraction
if (pending != null && pending.matches(expiry)) {
pendingInteraction = null
}
callbacks.onInteractionExpired(expiry)
previousEventType = type
return
}
if (type in INTERACTION_RESUME_EVENTS) {
pendingInteraction?.let { ask ->
pendingInteraction = null
callbacks.onInteractionResolved(
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
)
}
}
when (type) {
"reasoning.delta" -> {
val text = payload.string("text")
@@ -236,36 +270,6 @@ class GatewayEventMapper(
)
}
"clarify.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
),
)
"approval.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
// Upstream approvals correlate per-SESSION, never
// per-request — a stray request_id must not be adopted.
requestId = null,
text = listOfNotNull(payload.string("command"), payload.string("description"))
.joinToString(" — ")
.ifBlank { "a command approval" },
choices = payload.approvalChoices(),
smartDenied = payload.boolean("smart_denied") == true,
// Current Hermes omits timeout metadata. Keep the legacy
// no-countdown behavior unless a future contract exposes
// the effective per-request timeout explicitly.
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
),
)
"tool.output_risk" -> {
val toolId = payload.string("tool_id")
val risk = payload.string("risk")?.lowercase() ?: return
@@ -290,56 +294,6 @@ class GatewayEventMapper(
// Android does not currently render these upstream events.
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
"sudo.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
// Payload carries request_id ONLY — no command to show.
text = "Elevated permissions requested",
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
),
)
"secret.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
text = payload.string("prompt") ?: "The agent needs a secret value",
envVar = payload.string("env_var"),
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
),
)
"sudo.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
),
)
"secret.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
),
)
"clarify.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
),
)
// Forward-compatible consumer for the proposed upstream approval
// expiry event. Approvals correlate by session, never request id.
"approval.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.APPROVAL,
requestId = null,
),
)
"status.update" -> {
val text = payload.string("text")
if (!text.isNullOrBlank()) {
@@ -376,21 +330,93 @@ class GatewayEventMapper(
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
}
private fun JsonObject?.approvalChoices(): List<String>? =
(this?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
?.filter { it in APPROVAL_CHOICES }
?.distinct()
?.takeIf { it.isNotEmpty() }
private fun JsonObject?.boolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
companion object {
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
const val COMPACTION_STATUS_KIND = "compacting"
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
private val INTERACTION_RESUME_EVENTS = setOf(
"reasoning.delta",
"thinking.delta",
"reasoning.available",
"message.delta",
"message.interim",
"message.start",
"tool.generating",
"tool.start",
"tool.complete",
"message.complete",
"error",
)
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
"clarify.request" -> GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
)
"approval.request" -> GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
// Upstream approvals correlate per-SESSION, never
// per-request — a stray request_id must not be adopted.
requestId = null,
text = listOfNotNull(payload.string("command"), payload.string("description"))
.joinToString(" — ")
.ifBlank { "a command approval" },
choices = payload.approvalChoices(),
smartDenied = payload.boolean("smart_denied") == true,
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
)
"sudo.request" -> GatewayAsk(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
text = "Elevated permissions requested",
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
)
"secret.request" -> GatewayAsk(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
text = payload.string("prompt") ?: "The agent needs a secret value",
envVar = payload.string("env_var"),
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
)
else -> null
}
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
"clarify.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
)
"sudo.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
)
"secret.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
)
// Forward-compatible consumer for a future upstream approval
// expiry event. Approvals correlate by session, never request id.
"approval.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.APPROVAL,
requestId = null,
)
else -> null
}
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
/**
* Hermes 2026-07-15 emits these operational wait lines through the
@@ -456,3 +482,20 @@ private fun JsonObject?.int(key: String): Int? =
private fun JsonObject?.double(key: String): Double? =
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
private fun JsonObject?.boolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
private fun JsonObject?.approvalChoices(): List<String>? =
(this?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
?.filter { it in setOf("once", "session", "always", "deny") }
?.distinct()
?.takeIf { it.isNotEmpty() }
private fun GatewayAsk.sameRequestAs(other: GatewayAsk): Boolean =
kind == other.kind && requestId == other.requestId
private fun GatewayAsk.matches(expiry: GatewayAskExpiry): Boolean =
kind == expiry.kind &&
(kind == GatewayAsk.Kind.APPROVAL || requestId == expiry.requestId)
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Opt-in foreground service that holds the app process up so the app's
* connection to Hermes survives Android's background-freeze / Doze — i.e.
* Foreground service that holds the app process up so work the user already
* started survives Android's background-freeze / Doze. It runs automatically
* while one or more turns are active, or continuously when the user enables
* "persistent connection". Concretely it keeps the gateway chat WebSocket
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
@@ -39,13 +40,14 @@ import kotlinx.coroutines.launch
* connection use case Google Play permits. The `specialUse` type is honest for
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
* SDK 35) but requires a one-time Play Console foreground-service declaration
* at submission. Off by default; only runs while the user enables the toggle.
* at submission. Continuous idle retention is off by default; active work is
* protected automatically and releases its lease on terminal settlement.
*
* # It does NOT own the socket
*
* The service's only job is to hold the process in the foreground. The socket
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
* idle-close timer while the toggle is on. On task removal (user swipes the app
* idle-close timer while retention is required. On task removal (user swipes the app
* away) the ViewModel + socket die with the process, so the service stops
* itself rather than leave a notification that lies about being connected.
*
@@ -63,9 +65,31 @@ class GatewayKeepAliveService : Service() {
private const val CHANNEL_NAME = "Persistent connection"
const val NOTIFICATION_ID = 4713
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
private const val EXTRA_PERSISTENT = "persistent"
private const val EXTRA_ACTIVE_TURNS = "active_turns"
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
@Volatile private var runningInstance: GatewayKeepAliveService? = null
fun start(context: Context) {
fun update(
context: Context,
persistent: Boolean,
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
) {
if (!persistent && !activeTurns.required) {
stop(context)
return
}
runningInstance?.let { service ->
service.applyState(persistent, activeTurns)
service.startForegroundNotification()
return
}
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
.setAction(ACTION_REFRESH)
.putExtra(EXTRA_PERSISTENT, persistent)
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var persistent = false
private var activeTurns = 0
private var waitingSessions = 0
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
runningInstance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_REFRESH) {
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
.coerceIn(0, activeTurns)
}
startForegroundNotification()
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
// Flip the pref off so ConnectionViewModel's collector won't
// restart us on the next foreground.
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
persistent = false
if (activeTurns == 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
startForegroundNotification()
}
return START_NOT_STICKY
}
return START_STICKY
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
// The socket lives in the ViewModel, which dies when the task is
// removed — keeping the notification would be a lie. Stop cleanly.
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
ActiveTurnKeepAliveRegistry.releaseAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
if (runningInstance === this) runningInstance = null
scope.cancel()
super.onDestroy()
}
private fun applyState(
persistent: Boolean,
turns: ActiveTurnKeepAliveRegistry.Snapshot,
) {
this.persistent = persistent
activeTurns = turns.activeTurnCount
waitingSessions = turns.waitingSessionCount.coerceIn(0, activeTurns)
}
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
// are all declared in the main manifest (both flavors), so the type is
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
@@ -148,17 +200,42 @@ class GatewayKeepAliveService : Service() {
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
return NotificationCompat.Builder(this, CHANNEL_ID)
val (title, body) = when {
waitingSessions > 0 -> {
val title = if (waitingSessions == 1) {
"Hermes is waiting for input"
} else {
"$waitingSessions Hermes sessions need input"
}
title to if (activeTurns > waitingSessions) {
"$waitingSessions waiting · ${activeTurns - waitingSessions} still working"
} else {
"Open the requested session to review and continue."
}
}
activeTurns > 0 -> {
val title = if (activeTurns == 1) {
"Hermes is finishing a turn"
} else {
"Hermes is finishing $activeTurns turns"
}
title to "The connection stays active until this work completes."
}
else -> getString(R.string.gateway_keepalive_title) to
getString(R.string.gateway_keepalive_body)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.gateway_keepalive_title))
.setContentText(getString(R.string.gateway_keepalive_body))
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(tapPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.addAction(0, "Turn off", stopPending)
.build()
if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
return builder.build()
}
private fun ensureChannel() {
@@ -133,6 +133,25 @@ data class GatewayBackgroundTurnCompletion(
val expectedAssistantText: String?,
)
/** Input lifecycle from a deliberately detached Gateway turn. */
sealed interface GatewayBackgroundInteractionEvent {
val storedSessionId: String
val profile: String?
val ask: GatewayAsk
data class Requested(
override val storedSessionId: String,
override val profile: String?,
override val ask: GatewayAsk,
) : GatewayBackgroundInteractionEvent
data class Resolved(
override val storedSessionId: String,
override val profile: String?,
override val ask: GatewayAsk,
) : GatewayBackgroundInteractionEvent
}
/**
* One server-side interactive ask. The agent thread upstream is BLOCKED
* until the matching respond RPC arrives, the ask times out (resolves to ""
@@ -387,6 +406,8 @@ class GatewayTurnCallbacks(
val onInteractionRequest: (GatewayAsk) -> Unit,
/** Server declared a pending interaction expired; clear only the matching card. */
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
/** The turn resumed after a pending interaction was resolved elsewhere. */
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
/**
* Gateway `status.update` lifecycle line — model fallback, retries, and
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
@@ -0,0 +1,216 @@
package com.hermesandroid.relay.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayAsk
/**
* Action-required notifications for Gateway turns blocked on user input.
*
* Notification copy is deliberately generic. Approval commands, clarification
* questions, secret prompts, environment-variable names, and password requests
* stay inside the authenticated chat surface and never appear on the lock
* screen. A stable tag derived from the durable session and request identity
* makes replay/reconnect delivery replace the existing notification.
*/
object InteractionRequestNotifier {
private const val TAG = "InteractionNotifier"
internal const val CHANNEL_ID = "chat_interactions"
private const val CHANNEL_NAME = "Hermes needs input"
private const val GROUP_KEY = "gateway-interactions"
internal const val NOTIFICATION_ID = 3823
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
internal fun shouldPost(
alertsEnabled: Boolean,
appForeground: Boolean,
hasPermission: Boolean,
): Boolean = alertsEnabled && !appForeground && hasPermission
internal fun requestKey(sessionId: String, ask: GatewayAsk, profile: String? = null): String {
val requestIdentity = ask.requestId?.takeIf { it.isNotBlank() } ?: "session"
return "${profile ?: DEFAULT_PROFILE_ROUTE_VALUE}:$sessionId:${ask.kind.name}:$requestIdentity"
}
internal fun notificationTag(
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
): String = "gateway-interaction:${requestKey(sessionId, ask, profile)}"
internal fun chatRoute(sessionId: String, profile: String? = null): String =
"chat?sessionId=${Uri.encode(sessionId)}&profile=${Uri.encode(profile ?: DEFAULT_PROFILE_ROUTE_VALUE)}"
internal fun safeTitle(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Hermes needs approval"
GatewayAsk.Kind.CLARIFY -> "Hermes has a question"
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Hermes needs sensitive input"
}
internal fun safeBody(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Open Hermes to review the requested action."
GatewayAsk.Kind.CLARIFY -> "Open Hermes to answer and continue this turn."
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Open Hermes to respond securely."
}
internal fun safeExpandedBody(
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
): String {
val action = when (ask.kind) {
GatewayAsk.Kind.APPROVAL ->
"Review the requested action. Nothing is approved from the notification."
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
}
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
val sessionLabel = sessionId.takeLast(12)
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
}
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Review approval"
GatewayAsk.Kind.CLARIFY -> "Answer"
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Respond securely"
}
@SuppressLint("MissingPermission", "NotificationPermission")
fun notify(
context: Context,
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
alertsEnabled: Boolean,
appForeground: Boolean,
): Boolean {
ensureChannel(context)
if (!shouldPost(alertsEnabled, appForeground, hasPostNotificationsPermission(context))) {
return false
}
val tag = notificationTag(sessionId, ask, profile)
val requestKey = requestKey(sessionId, ask, profile)
val requestCode = requestKey.hashCode()
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.Builder()
.scheme("hermes-relay")
.authority("interaction")
.appendPath(requestKey)
.build()
putExtra(MainActivity.EXTRA_NAV_ROUTE, chatRoute(sessionId, profile))
}
val tapPending = PendingIntent.getActivity(
context,
requestCode,
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val title = safeTitle(ask)
val body = safeBody(ask)
val expandedBody = safeExpandedBody(sessionId, ask, profile)
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes needs your input")
.setContentText("Open Hermes to continue.")
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
.setContentIntent(tapPending)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.setGroup(GROUP_KEY)
.addAction(0, actionLabel(ask), tapPending)
.build()
return runCatching {
NotificationManagerCompat.from(context).notify(tag, NOTIFICATION_ID, notification)
true
}.onFailure {
Log.w(TAG, "notify failed", it)
}.getOrDefault(false)
}
fun cancel(context: Context, sessionId: String, ask: GatewayAsk, profile: String? = null) {
runCatching {
NotificationManagerCompat.from(context)
.cancel(notificationTag(sessionId, ask, profile), NOTIFICATION_ID)
}.onFailure {
Log.w(TAG, "cancel failed", it)
}
}
/**
* Clear only this feature's notifications. Android keeps notifications
* across process death, so the active-notification scan is also used when
* MainActivity returns without an in-memory request registry.
*/
fun cancelAll(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
runCatching {
manager.activeNotifications
.filter { it.notification.channelId == CHANNEL_ID }
.forEach { manager.cancel(it.tag, it.id) }
}.onFailure {
Log.w(TAG, "cancelAll failed", it)
}
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Alerts when a Hermes turn is waiting for your response."
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
setShowBadge(true)
},
)
}
private fun hasPostNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
}
@@ -274,13 +274,29 @@ sealed class Screen(
// NavHost, and the NavigationBarItem click must navigate via [route]()
// so no unresolved `{openAgentSheet}` leaks into the destination.
data object Chat : Screen(
"chat?openAgentSheet={openAgentSheet}",
"chat?openAgentSheet={openAgentSheet}&sessionId={sessionId}&profile={profile}",
"Chat",
Icons.AutoMirrored.Filled.Chat,
) {
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
fun route(openAgentSheet: Boolean = false): String =
if (openAgentSheet) "chat?openAgentSheet=true" else "chat"
const val ARG_SESSION_ID: String = "sessionId"
const val ARG_PROFILE: String = "profile"
fun route(
openAgentSheet: Boolean = false,
sessionId: String? = null,
profile: String? = null,
): String {
val params = buildList {
if (openAgentSheet) add("$ARG_OPEN_AGENT_SHEET=true")
sessionId?.takeIf { it.isNotBlank() }?.let {
add("$ARG_SESSION_ID=${android.net.Uri.encode(it)}")
}
profile?.takeIf { it.isNotBlank() }?.let {
add("$ARG_PROFILE=${android.net.Uri.encode(it)}")
}
}
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
}
}
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
@@ -1825,6 +1841,16 @@ fun RelayApp() {
type = NavType.BoolType
defaultValue = false
},
navArgument(Screen.Chat.ARG_SESSION_ID) {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument(Screen.Chat.ARG_PROFILE) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { backStackEntry ->
// Responsive bubble width based on screen width. The "Blend"
@@ -1849,6 +1875,48 @@ fun RelayApp() {
// sheet.
val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val requestedSessionId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_SESSION_ID)
?.takeIf { it.isNotBlank() }
val requestedProfileRoute = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROFILE)
?.takeIf { it.isNotBlank() }
LaunchedEffect(
requestedSessionId,
requestedProfileRoute,
profileSelectionSettled,
effectiveSessionProfileName,
agentProfiles,
) {
val sessionId = requestedSessionId ?: return@LaunchedEffect
if (requestedProfileRoute != null) {
val targetProfile = requestedProfileRoute.takeUnless {
it == com.hermesandroid.relay.notifications
.InteractionRequestNotifier.DEFAULT_PROFILE_ROUTE_VALUE
}
if (!profileSelectionSettled) return@LaunchedEffect
if (effectiveSessionProfileName != targetProfile) {
val selection = targetProfile?.let { name ->
agentProfiles.firstOrNull { it.name == name }
}
if (targetProfile == null || selection != null) {
connectionViewModel.selectProfile(selection)
}
return@LaunchedEffect
}
chatViewModel.switchProfileContext(
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnectionId,
profileName = targetProfile,
),
sessionId = sessionId,
)
} else if (chatViewModel.currentSessionId.value != sessionId) {
chatViewModel.switchSession(sessionId)
}
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
}
val screenChatLabel = stringResource(R.string.screen_chat_label)
@@ -1,5 +1,9 @@
package com.hermesandroid.relay.ui.onboarding
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -25,11 +29,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
@@ -59,6 +65,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
@@ -71,13 +78,42 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.R
import com.hermesandroid.relay.permissions.AppPermissionStatusProbe
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
/** Page identifiers for dynamic onboarding flow. */
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
/** Page identifiers for the standard-first onboarding flow. */
internal enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect, Permissions }
internal val standardOnboardingPages = listOf(
OnboardingPage.Welcome,
OnboardingPage.Chat,
OnboardingPage.Manage,
OnboardingPage.Power,
OnboardingPage.Connect,
OnboardingPage.Permissions,
)
internal enum class OnboardingNotificationAction {
RequestPermission,
Finish,
}
internal fun onboardingNotificationAction(
sdkInt: Int,
notificationsPermitted: Boolean,
): OnboardingNotificationAction {
return if (
sdkInt >= Build.VERSION_CODES.TIRAMISU &&
!notificationsPermitted
) {
OnboardingNotificationAction.RequestPermission
} else {
OnboardingNotificationAction.Finish
}
}
private val OnboardingAccent = Color(0xFF7B55F6)
@@ -125,21 +161,23 @@ fun OnboardingScreen(
*/
onTryDemo: () -> Unit = {},
) {
val pages = remember {
buildList {
add(OnboardingPage.Welcome)
add(OnboardingPage.Chat)
add(OnboardingPage.Manage)
add(OnboardingPage.Power)
add(OnboardingPage.Connect)
}
}
val pages = standardOnboardingPages
val pageCount = pages.size
val lastPage = pageCount - 1
val pagerState = rememberPagerState(pageCount = { pageCount })
val coroutineScope = rememberCoroutineScope()
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
var notificationsPermitted by remember {
mutableStateOf(AppPermissionStatusProbe.snapshot(context).notificationsPermitted)
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
notificationsPermitted =
granted || AppPermissionStatusProbe.snapshot(context).notificationsPermitted
}
Surface(
modifier = Modifier.fillMaxSize(),
@@ -174,7 +212,11 @@ fun OnboardingScreen(
// The selected welcome frame has no toolbar. Later information
// pages keep quiet navigation without reserving space above it.
if (currentPage in 1 until lastPage) {
if (
currentPageType == OnboardingPage.Chat ||
currentPageType == OnboardingPage.Manage ||
currentPageType == OnboardingPage.Power
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -201,7 +243,13 @@ fun OnboardingScreen(
// Pager content
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
// Connect and permission setup advance only through their
// explicit actions. This keeps the post-connect setup page
// unreachable until the wizard reports a successful save.
userScrollEnabled =
currentPageType != OnboardingPage.Connect &&
currentPageType != OnboardingPage.Permissions,
) { pageIndex ->
Box(
modifier = Modifier.fillMaxSize(),
@@ -216,18 +264,42 @@ fun OnboardingScreen(
)
OnboardingPage.Connect -> ConnectPage(
connectionViewModel = connectionViewModel,
onComplete = onComplete,
onComplete = {
notificationsPermitted =
AppPermissionStatusProbe.snapshot(context).notificationsPermitted
coroutineScope.launch {
pagerState.animateScrollToPage(
pages.indexOf(OnboardingPage.Permissions),
)
}
},
onManageSignIn = onManageSignIn,
onSkip = { showSkipConfirm = true },
onTryDemo = onTryDemo,
)
OnboardingPage.Permissions -> PermissionSetupPage(
notificationAction = onboardingNotificationAction(
sdkInt = Build.VERSION.SDK_INT,
notificationsPermitted = notificationsPermitted,
),
onEnableNotifications = {
notificationPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS,
)
},
onReviewPermissions = onOpenPermissions,
onFinish = onComplete,
)
}
}
}
// Bottom navigation only on informational pages — the wizard
// owns its own back/pair affordances.
if (currentPageType != OnboardingPage.Connect) {
if (
currentPageType != OnboardingPage.Connect &&
currentPageType != OnboardingPage.Permissions
) {
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
Column(
modifier = Modifier
@@ -627,6 +699,80 @@ private fun PowerToolsPage(
}
}
@Composable
internal fun PermissionSetupPage(
notificationAction: OnboardingNotificationAction,
onEnableNotifications: () -> Unit,
onReviewPermissions: () -> Unit,
onFinish: () -> Unit,
) {
OnboardingPage(
icon = Icons.Filled.Security,
title = stringResource(R.string.onboarding_finish_setup_title),
description = stringResource(R.string.onboarding_finish_setup_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
SetupPathSummary(
icon = Icons.Filled.CheckCircle,
label = stringResource(R.string.perms_chat_and_manage),
description = stringResource(R.string.onboarding_chat_manage_ready),
)
SetupPathSummary(
icon = Icons.Filled.Notifications,
label = stringResource(R.string.onboarding_chat_alerts),
description = if (
notificationAction == OnboardingNotificationAction.Finish
) {
stringResource(R.string.onboarding_chat_alerts_ready)
} else {
stringResource(R.string.onboarding_chat_alerts_description)
},
)
SetupPathSummary(
icon = Icons.Filled.Tune,
label = stringResource(R.string.onboarding_optional_features),
description = stringResource(R.string.onboarding_optional_features_description),
)
OutlinedButton(
onClick = onReviewPermissions,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_review_optional_permissions))
}
Spacer(Modifier.height(2.dp))
if (notificationAction == OnboardingNotificationAction.RequestPermission) {
GradientOnboardingButton(
label = stringResource(R.string.onboarding_enable_chat_alerts),
onClick = onEnableNotifications,
)
TextButton(
onClick = onFinish,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.onboarding_not_now))
}
} else {
GradientOnboardingButton(
label = stringResource(R.string.onboarding_finish),
onClick = onFinish,
)
}
}
}
}
@Composable
private fun ConnectPage(
connectionViewModel: ConnectionViewModel,
@@ -443,7 +443,7 @@ fun ChatSettingsScreen(
HorizontalDivider()
// Turn-complete notification toggle. First enable on
// Background chat-alert toggle. First enable on
// API 33+ runs the POST_NOTIFICATIONS request (the
// BridgeScreen master-toggle precedent); if the user
// denies, the notifier silently no-ops at post time.
@@ -1,5 +1,10 @@
package com.hermesandroid.relay.ui.screens
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.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -812,6 +817,20 @@ private fun QuickControlsCard(
) {
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
val context = LocalContext.current
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Posting re-checks the grant. */ }
val requestNotificationPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
androidx.core.content.ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -844,7 +863,10 @@ private fun QuickControlsCard(
stringResource(R.string.settings_connect_on_demand)
},
checked = gatewayKeepAlive,
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) },
onCheckedChange = { enabled ->
connectionViewModel.setGatewayKeepAlive(enabled)
if (enabled && notifyTurnComplete) requestNotificationPermission()
},
)
// Doze: even with the keep-alive service running, a specialUse FGS
// still gets its network deferred in deep sleep unless the app is
@@ -863,7 +885,10 @@ private fun QuickControlsCard(
stringResource(R.string.settings_turn_complete_alerts_off)
},
checked = notifyTurnComplete,
onCheckedChange = { connectionViewModel.setNotifyTurnComplete(it) },
onCheckedChange = { enabled ->
connectionViewModel.setNotifyTurnComplete(enabled)
if (enabled) requestNotificationPermission()
},
)
}
}
@@ -41,9 +41,11 @@ import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.ActiveTurnHandle
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.network.upstream.GatewayAsk
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
import com.hermesandroid.relay.network.upstream.GatewayBackgroundInteractionEvent
import com.hermesandroid.relay.network.upstream.GatewayBackgroundTurnCompletion
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayCompressResult
@@ -71,6 +73,7 @@ import com.hermesandroid.relay.network.upstream.models.SessionItem
import com.hermesandroid.relay.network.upstream.models.SkillInfo
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
import com.hermesandroid.relay.ui.components.ServerImageResult
import com.hermesandroid.relay.ui.components.SlashCommand
import com.hermesandroid.relay.voice.RealtimeTurnSyncBuilder
@@ -177,6 +180,20 @@ class ChatViewModel : ViewModel() {
private data class TurnCheckpointKey(val contextKey: String, val sessionId: String)
private val backgroundTurnCheckpoints =
ConcurrentHashMap<TurnCheckpointKey, ChatTurnCheckpoint>()
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
private fun activeTurnCheckpointKey(): TurnCheckpointKey? =
activeTurnCheckpointSeed?.let { seed ->
(seed.contextKey ?: activeProfileContextKey)?.let { TurnCheckpointKey(it, seed.sessionId) }
}
private fun backgroundTurnKey(sessionId: String, profile: String?): TurnCheckpointKey? {
val profileKey = AgentDisplay.profileSessionKey(profile)
return backgroundTurnCheckpoints.keys.firstOrNull { key ->
key.sessionId == sessionId && key.contextKey.substringAfterLast("::") == profileKey
}
}
private var checkpointWriteJob: Job? = null
private var checkpointStatusJob: Job? = null
private var checkpointForegroundJob: Job? = null
@@ -1082,6 +1099,7 @@ class ChatViewModel : ViewModel() {
previousClient?.setUnsolicitedTurnProvider(null)
previousClient?.setColdPrewarmSessionReadyListener(null)
previousClient?.setUnmatchedTurnCompleteListener(null)
previousClient?.setBackgroundInteractionListener(null)
}
gatewayClient = client
if (changed) {
@@ -1143,6 +1161,35 @@ class ChatViewModel : ViewModel() {
expectedAssistantText = completion.expectedAssistantText,
)
}
client?.setBackgroundInteractionListener { event ->
val key = backgroundTurnKey(event.storedSessionId, event.profile)
when (event) {
is GatewayBackgroundInteractionEvent.Requested -> {
if (key != null) {
backgroundPendingInteractions[key] =
BackgroundPendingInteraction(event.profile, event.ask)
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), true)
}
maybeNotifyInteraction(event.storedSessionId, event.ask, event.profile)
}
is GatewayBackgroundInteractionEvent.Resolved -> {
if (key != null) {
backgroundPendingInteractions.computeIfPresent(key) { _, current ->
if (current.profile == event.profile &&
current.ask.kind == event.ask.kind &&
current.ask.requestId == event.ask.requestId
) {
null
} else {
current
}
}
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), false)
}
cancelInteractionNotification(event.storedSessionId, event.ask, event.profile)
}
}
}
// (Re)bind the session.info state sync to the live client so server-side
// personality/model changes drive the UI. Cancelled when the client drops.
if (changed) {
@@ -1200,6 +1247,10 @@ class ChatViewModel : ViewModel() {
}
if (matching.isEmpty()) return
matching.forEach(backgroundTurnCheckpoints::remove)
matching.forEach { key ->
backgroundPendingInteractions.remove(key)
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
}
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
@@ -1369,6 +1420,9 @@ class ChatViewModel : ViewModel() {
onInteractionExpired = { expiry ->
if (acceptsEvent()) expirePendingAsk(expiry)
},
onInteractionResolved = { resolved ->
if (acceptsEvent()) resolvePendingAsk(resolved)
},
onStatusUpdate = { kind, text ->
if (acceptsEvent()) {
handler.setTurnStatus(text, kind)
@@ -1628,6 +1682,13 @@ class ChatViewModel : ViewModel() {
*/
private val _pendingAsk = MutableStateFlow<PendingAsk?>(null)
val pendingAsk: StateFlow<PendingAsk?> = _pendingAsk.asStateFlow()
private data class BackgroundPendingInteraction(
val profile: String?,
val ask: GatewayAsk,
)
private val backgroundPendingInteractions =
ConcurrentHashMap<TurnCheckpointKey, BackgroundPendingInteraction>()
/** Ask cardKeys with a respond RPC in flight — blocks double-taps until it settles. */
private val answeredAskIds = mutableSetOf<String>()
@@ -1718,11 +1779,17 @@ class ChatViewModel : ViewModel() {
val openModelPicker: SharedFlow<Unit> = _openModelPicker.asSharedFlow()
/**
* "Notify when Hermes finishes" setting — mirrored from
* Chat alerts setting — mirrored from
* ConnectionViewModel's DataStore flow by RelayApp, same pattern as
* [appContextSettings]. Default ON matches the DataStore default.
* [appContextSettings]. It covers background turn completion and
* action-required Gateway interactions. Default ON matches the DataStore
* default and keeps the existing preference key backward-compatible.
*/
var notifyOnTurnComplete: Boolean = true
set(value) {
field = value
if (!value) appContext?.let(InteractionRequestNotifier::cancelAll)
}
/**
* Edit-and-regenerate: 0-based USER ordinal armed by
@@ -2438,6 +2505,7 @@ class ChatViewModel : ViewModel() {
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
_steerNotice.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_contextUsage.value = null
_contextWindow.value = null
@@ -2502,6 +2570,7 @@ class ChatViewModel : ViewModel() {
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
_steerNotice.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_contextUsage.value = null
_contextWindow.value = null
@@ -2757,6 +2826,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_yoloEnabled.value = null
_fastEnabled.value = null
@@ -2795,6 +2865,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_yoloEnabled.value = null
_fastEnabled.value = null
@@ -2839,6 +2910,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
onSessionChanged?.invoke(null)
}
@@ -2900,6 +2972,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_yoloEnabled.value = null
_fastEnabled.value = null
@@ -3225,6 +3298,13 @@ class ChatViewModel : ViewModel() {
// === Gateway interactive asks ===
private fun dismissPendingAskNotification() {
val pending = _pendingAsk.value ?: return
chatHandler?.currentSessionId?.value?.let {
cancelInteractionNotification(it, pending.ask)
}
}
/**
* Build the local ask card for a gateway interaction request and track
* it as the pending ask. Card id (= cardKey) is the ask's `request_id`,
@@ -3237,6 +3317,20 @@ class ChatViewModel : ViewModel() {
ask: GatewayAsk,
restored: ChatTurnAskCheckpoint? = null,
) {
val sessionId = handler.currentSessionId.value
val existing = _pendingAsk.value
if (existing != null &&
existing.ask.kind == ask.kind &&
existing.ask.requestId == ask.requestId
) {
sessionId?.let { maybeNotifyInteraction(it, existing.ask) }
return
}
existing?.let { pending ->
sessionId?.let { cancelInteractionNotification(it, pending.ask) }
}
val activeKey = activeTurnCheckpointKey()
if (activeKey != null) backgroundPendingInteractions.remove(activeKey)
val now = restored?.receivedAt ?: System.currentTimeMillis()
val cardKey = restored?.cardKey ?: ask.requestId
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
@@ -3336,7 +3430,9 @@ class ChatViewModel : ViewModel() {
cardKey = cardKey,
receivedAt = now,
)
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), true) }
scheduleCheckpointWrite(immediate = true)
sessionId?.let { maybeNotifyInteraction(it, ask) }
}
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
@@ -3435,7 +3531,13 @@ class ChatViewModel : ViewModel() {
// must leave the card answerable for a retry.
handler.recordCardDispatch(pending.messageId, cardKey, stampValue)
if (_pendingAsk.value === pending) {
handler.currentSessionId.value?.let {
cancelInteractionNotification(it, pending.ask)
}
_pendingAsk.value = null
activeTurnCheckpointKey()?.let {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
}
scheduleCheckpointWrite(immediate = true)
}
},
@@ -3460,7 +3562,13 @@ class ChatViewModel : ViewModel() {
val requestId = expiry.requestId?.takeIf { it.isNotBlank() } ?: return
if (pending.ask.requestId != requestId) return
}
chatHandler?.currentSessionId?.value?.let {
cancelInteractionNotification(it, pending.ask)
}
_pendingAsk.value = null
activeTurnCheckpointKey()?.let {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
}
answeredAskIds.remove(pending.cardKey)
scheduleCheckpointWrite(immediate = true)
chatHandler?.recordCardDispatch(
@@ -3470,6 +3578,17 @@ class ChatViewModel : ViewModel() {
)
}
private fun resolvePendingAsk(resolution: GatewayAskExpiry) {
val pending = _pendingAsk.value ?: return
if (pending.ask.kind != resolution.kind) return
if (resolution.kind != GatewayAsk.Kind.APPROVAL &&
pending.ask.requestId != resolution.requestId
) {
return
}
clearPendingAsk(approvalStamp = "Resolved")
}
/**
* Drop pending-ask UI state when the turn is torn down, stamping a
* still-open approval card with [approvalStamp] so its buttons don't
@@ -3479,7 +3598,13 @@ class ChatViewModel : ViewModel() {
*/
private fun clearPendingAsk(approvalStamp: String) {
val pending = _pendingAsk.value ?: return
chatHandler?.currentSessionId?.value?.let {
cancelInteractionNotification(it, pending.ask)
}
_pendingAsk.value = null
activeTurnCheckpointKey()?.let {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
}
scheduleCheckpointWrite(immediate = true)
if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, approvalStamp)
@@ -3768,6 +3893,31 @@ class ChatViewModel : ViewModel() {
// === Turn-complete notification ===
private fun maybeNotifyInteraction(
sessionId: String,
ask: GatewayAsk,
profile: String? = sessionProfileNameProvider(),
) {
val context = appContext ?: return
InteractionRequestNotifier.notify(
context = context,
sessionId = sessionId,
ask = ask,
profile = profile,
alertsEnabled = notifyOnTurnComplete,
appForeground = AppForegroundTracker.isForeground.value,
)
}
private fun cancelInteractionNotification(
sessionId: String,
ask: GatewayAsk,
profile: String? = sessionProfileNameProvider(),
) {
val context = appContext ?: return
InteractionRequestNotifier.cancel(context, sessionId, ask, profile)
}
/**
* Post the one-shot "Hermes finished" notification when the turn ends
* while the app is backgrounded. Never fires for cancelled streams
@@ -3806,7 +3956,25 @@ class ChatViewModel : ViewModel() {
if (checkpointForegroundJob == null) {
checkpointForegroundJob = viewModelScope.launch {
AppForegroundTracker.isForeground.collect { foreground ->
if (!foreground) scheduleCheckpointWrite(immediate = true)
val context = appContext
if (foreground) {
context?.let(InteractionRequestNotifier::cancelAll)
} else {
scheduleCheckpointWrite(immediate = true)
val handler = chatHandler
val sessionId = handler?.currentSessionId?.value
val pending = _pendingAsk.value
if (sessionId != null && pending != null) {
maybeNotifyInteraction(sessionId, pending.ask)
}
backgroundPendingInteractions.forEach { (key, pending) ->
maybeNotifyInteraction(
key.sessionId,
pending.ask,
pending.profile,
)
}
}
}
}
}
@@ -3821,6 +3989,7 @@ class ChatViewModel : ViewModel() {
assistantMessageId: String,
assistantTimestamp: Long,
) {
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.release(it.keepAliveKey()) }
val userMessage = handler.messages.value.lastOrNull { it.id == userMessageId }
val snapshot = handler.messages.value
val generation = checkpointGeneration.incrementAndGet()
@@ -3842,6 +4011,7 @@ class ChatViewModel : ViewModel() {
},
startedAt = assistantTimestamp,
)
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.acquire(it.keepAliveKey()) }
// Remove a prior turn for THIS session immediately. Detached sibling
// turns retain their own durable checkpoints while this chat starts a
// new one.
@@ -3860,6 +4030,7 @@ class ChatViewModel : ViewModel() {
}
private fun adoptTurnCheckpoint(checkpoint: ChatTurnCheckpoint) {
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.release(it.keepAliveKey()) }
checkpointGeneration.incrementAndGet()
checkpointWriteJob?.cancel()
activeTurnCheckpointSeed = ActiveTurnCheckpointSeed(
@@ -3876,12 +4047,21 @@ class ChatViewModel : ViewModel() {
baselineAssistantCount = checkpoint.baselineAssistantCount,
startedAt = checkpoint.startedAt,
)
activeTurnCheckpointKey()?.let { key ->
ActiveTurnKeepAliveRegistry.acquire(key.keepAliveKey())
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), checkpoint.pendingAsk != null)
}
}
private fun updateTurnCheckpointSession(sessionId: String) {
activeTurnCheckpointSeed?.let { seed ->
val oldKey = activeTurnCheckpointKey()
seed.sessionId = sessionId
seed.liveSessionId = gatewayClient?.currentLiveSessionId(sessionId)
val newKey = activeTurnCheckpointKey()
if (oldKey != null && newKey != null) {
ActiveTurnKeepAliveRegistry.rename(oldKey.keepAliveKey(), newKey.keepAliveKey())
}
scheduleCheckpointWrite(immediate = true)
}
}
@@ -4025,7 +4205,11 @@ class ChatViewModel : ViewModel() {
val generation = checkpointGeneration.incrementAndGet()
checkpointWriteJob?.cancel()
checkpointWriteJob = null
if (key != null) backgroundTurnCheckpoints.remove(key)
if (key != null) {
backgroundTurnCheckpoints.remove(key)
backgroundPendingInteractions.remove(key)
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
}
val store = chatTurnCheckpointStore ?: return
viewModelScope.launch {
checkpointMutex.withLock {
@@ -4362,6 +4546,9 @@ class ChatViewModel : ViewModel() {
onInteractionExpired = { expiry ->
if (owns()) expirePendingAsk(expiry)
},
onInteractionResolved = { resolved ->
if (owns()) resolvePendingAsk(resolved)
},
onStatusUpdate = { kind, text ->
if (owns()) {
handler.setTurnStatus(text, kind)
@@ -6381,6 +6568,9 @@ class ChatViewModel : ViewModel() {
onInteractionExpired = { expiry ->
expirePendingAsk(expiry)
},
onInteractionResolved = { resolved ->
resolvePendingAsk(resolved)
},
onStatusUpdate = { kind, text ->
handler.setTurnStatus(text, kind)
// The server prefixes terminal failures with ❌ —
@@ -7039,6 +7229,8 @@ class ChatViewModel : ViewModel() {
gatewayClient?.setUnsolicitedTurnProvider(null)
gatewayClient?.setColdPrewarmSessionReadyListener(null)
gatewayClient?.setUnmatchedTurnCompleteListener(null)
gatewayClient?.setBackgroundInteractionListener(null)
backgroundPendingInteractions.clear()
gatewayHistoryReconcileJob?.cancel()
gatewayHistoryReconcileJob = null
backgroundProcessSessionJob?.cancel()
@@ -71,6 +71,7 @@ import com.hermesandroid.relay.network.upstream.DashboardStatus
import com.hermesandroid.relay.network.upstream.ToolsetInfo
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
@@ -623,7 +624,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
context = application,
activeConnectionIdProvider = { connectionStore.activeConnectionId.value },
dashboardUrlProvider = { activeDashboardUrl() },
gatewayKeepAliveProvider = { gatewayKeepAlive.value },
gatewayKeepAliveProvider = {
gatewayKeepAlive.value || ActiveTurnKeepAliveRegistry.snapshot.value.required
},
// Lets the dashboard cookie store ride the connection's token keyset
// (one keyset build instead of two on cold start).
tokenStoreKeyProvider = { cid ->
@@ -1671,17 +1674,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
init {
// Drive the keep-alive: flip the active client's no-background-close
// flag and start/stop the foreground service. Both flavors — the
// Drive the keep-alive from either the user's always-on preference or
// work the user already started. Active-turn leases are session scoped,
// so sibling turns release independently. Both flavors — the
// GatewayKeepAliveService is declared in the main manifest (Play permits
// this Home-Assistant-class persistent-connection use case). Mirrors
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
viewModelScope.launch {
gatewayKeepAlive.collect { enabled ->
upstreamTransport.applyGatewayKeepAlive(enabled)
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
persistent to turns
}.distinctUntilChanged().collect { (persistent, turns) ->
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
val ctx = getApplication<Application>()
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
else runCatching { GatewayKeepAliveService.stop(ctx) }
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
}
}
}
+19 -7
View File
@@ -51,6 +51,18 @@
<string name="onboarding_realtime_label">Tempo real</string>
<string name="onboarding_realtime_description">Agente de voz em tempo real nativo do provedor e provedores de voz específicos para cada perfil.</string>
<string name="onboarding_review_permissions">Revisar permissões</string>
<string name="onboarding_finish_setup_title">Concluir configuração</string>
<string name="onboarding_finish_setup_description">Sua conexão com o Hermes está pronta. Escolha agora o que este telefone pode fazer ou altere depois nas Configurações.</string>
<string name="onboarding_chat_manage_ready">Pronto — nenhuma permissão do telefone é necessária.</string>
<string name="onboarding_chat_alerts">Alertas do chat</string>
<string name="onboarding_chat_alerts_description">Permita notificações do Android para que os alertas ativados cheguem em segundo plano.</string>
<string name="onboarding_chat_alerts_ready">As notificações estão ativadas para este app.</string>
<string name="onboarding_optional_features">Recursos opcionais</string>
<string name="onboarding_optional_features_description">Câmera, microfone, assistente de notificações e ferramentas disponíveis do dispositivo ficam desativados até você escolhê-los.</string>
<string name="onboarding_review_optional_permissions">Revisar permissões opcionais</string>
<string name="onboarding_enable_chat_alerts">Ativar alertas do chat</string>
<string name="onboarding_not_now">Agora não</string>
<string name="onboarding_finish">Concluir configuração</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">Conversa por voz ao vivo</string>
<string name="chat_input_add_attachment">Adicionar anexo</string>
@@ -619,11 +631,11 @@
<string name="settings_profile_lock_desc">Fixe o app em um perfil de agente</string>
<string name="settings_quick_controls">Controles rápidos</string>
<string name="settings_persistent_connection">Conexão persistente</string>
<string name="settings_persistent_connection_desc">Mantém sua conexão com o Hermes aberta em segundo plano</string>
<string name="settings_connect_on_demand">Conectar somente quando necessário · economiza bateria</string>
<string name="settings_turn_complete_alerts">Alertas de turno concluído</string>
<string name="settings_turn_complete_alerts_desc">Notifique quando uma resposta terminar enquanto o app estiver em segundo plano</string>
<string name="settings_turn_complete_alerts_off">Sem alerta quando uma resposta em segundo plano terminar</string>
<string name="settings_persistent_connection_desc">Manter a conexão mesmo sem nenhum chat em andamento</string>
<string name="settings_connect_on_demand">Chats ativos permanecem conectados automaticamente · a conexão ociosa é fechada para economizar bateria</string>
<string name="settings_turn_complete_alerts">Alertas de chat</string>
<string name="settings_turn_complete_alerts_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
<string name="settings_turn_complete_alerts_off">Sem alertas para atividade de chat em segundo plano</string>
<string name="settings_keep_connected_deep_sleep">Manter conectado em suspensão profunda</string>
<string name="settings_keep_connected_battery_desc">O Android ainda pode pausar a conexão depois que a tela fica desligada por algum tempo (Doze). Permita o uso irrestrito da bateria para que a Conexão persistente continue funcionando em segundo plano.</string>
<string name="settings_allow_unrestricted_battery">Permitir uso irrestrito da bateria</string>
@@ -652,8 +664,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Mostre suas mensagens recentes como chips tocáveis acima do editor para enviá-las novamente. Desativado por padrão.</string>
<string name="chat_settings_keep_keyboard_open">Manter o teclado aberto ao enviar</string>
<string name="chat_settings_keep_keyboard_open_desc">Permaneça no editor após enviar. Desative para fechar o teclado depois de cada mensagem enviada.</string>
<string name="chat_settings_notify_when_finishes">Notificar quando o Hermes terminar</string>
<string name="chat_settings_notify_when_finishes_desc">Publique uma notificação quando uma resposta for concluída enquanto o app estiver em segundo plano</string>
<string name="chat_settings_notify_when_finishes">Alertas de chat em segundo plano</string>
<string name="chat_settings_notify_when_finishes_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
<string name="chat_settings_share_phone_status">Compartilhar o status do celular com o agente</string>
<string name="chat_settings_share_phone_status_desc">Inclua uma breve mensagem do sistema sobre o app e o celular em cada turno do chat</string>
<string name="chat_settings_bridge_permissions">Bridge + permissões</string>
+19 -7
View File
@@ -56,6 +56,18 @@
<string name="onboarding_realtime_label">实时语音</string>
<string name="onboarding_realtime_description">提供商原生的实时语音代理,以及按配置文件区分的语音提供商。</string>
<string name="onboarding_review_permissions">查看权限</string>
<string name="onboarding_finish_setup_title">完成设置</string>
<string name="onboarding_finish_setup_description">Hermes 连接已就绪。现在选择这部手机可以使用的功能,或稍后在设置中更改。</string>
<string name="onboarding_chat_manage_ready">已就绪 — 无需手机权限。</string>
<string name="onboarding_chat_alerts">聊天提醒</string>
<string name="onboarding_chat_alerts_description">允许 Android 通知,以便已启用的提醒可在后台送达。</string>
<string name="onboarding_chat_alerts_ready">此应用的通知已启用。</string>
<string name="onboarding_optional_features">可选功能</string>
<string name="onboarding_optional_features_description">相机、麦克风、通知助手和可用的设备工具会保持关闭,直到你主动选择。</string>
<string name="onboarding_review_optional_permissions">查看可选权限</string>
<string name="onboarding_enable_chat_alerts">启用聊天提醒</string>
<string name="onboarding_not_now">暂不</string>
<string name="onboarding_finish">完成设置</string>
<!-- 聊天输入栏 -->
<string name="chat_input_live_voice_hint">实时语音对话</string>
@@ -660,11 +672,11 @@
<string name="settings_profile_lock_desc">将应用固定到一个代理配置文件</string>
<string name="settings_quick_controls">快捷控制</string>
<string name="settings_persistent_connection">持久连接</string>
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
<string name="settings_turn_complete_alerts">回合完成提醒</string>
<string name="settings_turn_complete_alerts_desc">应用在后台时回复完成则通知</string>
<string name="settings_turn_complete_alerts_off">后台回复完成时不提醒</string>
<string name="settings_persistent_connection_desc">即使没有聊天运行也保持连接</string>
<string name="settings_connect_on_demand">活跃聊天会自动保持连接 · 空闲时关闭连接以节省电量</string>
<string name="settings_turn_complete_alerts">聊天提醒</string>
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
<string name="settings_turn_complete_alerts_off">不提醒后台聊天活动</string>
<string name="settings_keep_connected_deep_sleep">在深度睡眠中保持连接</string>
<string name="settings_keep_connected_battery_desc">屏幕关闭一段时间后 Android 仍可能暂停连接(Doze)。允许不受限制的电池使用,让持久连接在后台继续工作。</string>
<string name="settings_allow_unrestricted_battery">允许不受限制的电池</string>
@@ -694,8 +706,8 @@
<string name="chat_settings_recent_prompt_chips_desc">在输入栏上方显示最近消息为可点击的标签以便重发。默认关闭。</string>
<string name="chat_settings_keep_keyboard_open">发送时保持键盘打开</string>
<string name="chat_settings_keep_keyboard_open_desc">发送后留在输入栏。关闭则在每条消息发送后收起键盘。</string>
<string name="chat_settings_notify_when_finishes">Hermes 完成时通知</string>
<string name="chat_settings_notify_when_finishes_desc">应用在后台时回复完成则发送通知</string>
<string name="chat_settings_notify_when_finishes">后台聊天提醒</string>
<string name="chat_settings_notify_when_finishes_desc">Hermes 在后台需要输入或完成回复时通知</string>
<string name="chat_settings_share_phone_status">与代理分享手机状态</string>
<string name="chat_settings_share_phone_status_desc">每轮聊天附带一条关于应用和手机的简短系统消息</string>
<string name="chat_settings_bridge_permissions">Bridge + 权限</string>
+19 -7
View File
@@ -56,6 +56,18 @@
<string name="onboarding_realtime_label">Echtzeit</string>
<string name="onboarding_realtime_description">Anbietereigener Echtzeit-Sprachagent und profilbezogene Sprachanbieter.</string>
<string name="onboarding_review_permissions">Berechtigungen prüfen</string>
<string name="onboarding_finish_setup_title">Einrichtung abschließen</string>
<string name="onboarding_finish_setup_description">Deine Hermes-Verbindung ist bereit. Wähle jetzt aus, was dieses Telefon tun darf, oder ändere es später in den Einstellungen.</string>
<string name="onboarding_chat_manage_ready">Bereit — keine Telefonberechtigung erforderlich.</string>
<string name="onboarding_chat_alerts">Chat-Benachrichtigungen</string>
<string name="onboarding_chat_alerts_description">Erlaube Android-Benachrichtigungen, damit aktivierte Hinweise dich im Hintergrund erreichen.</string>
<string name="onboarding_chat_alerts_ready">Benachrichtigungen sind für diese App aktiviert.</string>
<string name="onboarding_optional_features">Optionale Funktionen</string>
<string name="onboarding_optional_features_description">Kamera, Mikrofon, Benachrichtigungsbegleiter und verfügbare Gerätewerkzeuge bleiben aus, bis du sie auswählst.</string>
<string name="onboarding_review_optional_permissions">Optionale Berechtigungen prüfen</string>
<string name="onboarding_enable_chat_alerts">Chat-Benachrichtigungen aktivieren</string>
<string name="onboarding_not_now">Jetzt nicht</string>
<string name="onboarding_finish">Einrichtung abschließen</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">Live-Sprachgespräch</string>
@@ -660,11 +672,11 @@
<string name="settings_profile_lock_desc">App auf ein Agentenprofil festlegen</string>
<string name="settings_quick_controls">Schnellsteuerung</string>
<string name="settings_persistent_connection">Dauerhafte Verbindung</string>
<string name="settings_persistent_connection_desc">Verbindung zu Hermes im Hintergrund offen halten</string>
<string name="settings_connect_on_demand">Nur bei Bedarf verbinden · spart Akku</string>
<string name="settings_turn_complete_alerts">Hinweise bei Abschluss</string>
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
<string name="settings_turn_complete_alerts_off">Kein Hinweis, wenn eine Antwort im Hintergrund abgeschlossen wird</string>
<string name="settings_persistent_connection_desc">Auch ohne laufenden Chat verbunden bleiben</string>
<string name="settings_connect_on_demand">Aktive Chats bleiben automatisch verbunden · Leerlaufverbindung wird geschlossen, um Akku zu sparen</string>
<string name="settings_turn_complete_alerts">Chat-Benachrichtigungen</string>
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
<string name="settings_turn_complete_alerts_off">Keine Benachrichtigungen für Chat-Aktivität im Hintergrund</string>
<string name="settings_keep_connected_deep_sleep">Im Tiefschlaf verbunden bleiben</string>
<string name="settings_keep_connected_battery_desc">Android kann die Verbindung weiterhin pausieren, nachdem der Bildschirm eine Weile ausgeschaltet war (Doze). Erlaube uneingeschränkte Akkunutzung, damit die dauerhafte Verbindung im Hintergrund funktioniert.</string>
<string name="settings_allow_unrestricted_battery">Uneingeschränkte Akkunutzung erlauben</string>
@@ -694,8 +706,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Letzte Nachrichten als antippbare Chips über dem Eingabefeld anzeigen, um sie erneut zu senden. Standardmäßig aus.</string>
<string name="chat_settings_keep_keyboard_open">Tastatur nach dem Senden geöffnet lassen</string>
<string name="chat_settings_keep_keyboard_open_desc">Nach dem Senden im Eingabefeld bleiben. Ausschalten, um die Tastatur nach jeder gesendeten Nachricht zu schließen.</string>
<string name="chat_settings_notify_when_finishes">Benachrichtigen, wenn Hermes fertig ist</string>
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigung senden, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
<string name="chat_settings_notify_when_finishes">Chat-Benachrichtigungen im Hintergrund</string>
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
<string name="chat_settings_share_phone_status">Smartphone-Status mit Agent teilen</string>
<string name="chat_settings_share_phone_status_desc">Bei jedem Chatdurchlauf eine kurze Systemnachricht über App und Smartphone einfügen</string>
<string name="chat_settings_bridge_permissions">Bridge + Berechtigungen</string>
+19 -7
View File
@@ -51,6 +51,18 @@
<string name="onboarding_realtime_label">En tiempo real</string>
<string name="onboarding_realtime_description">Agente de voz en tiempo real nativo del proveedor y proveedores de voz con reconocimiento de perfil.</string>
<string name="onboarding_review_permissions">Revisar permisos</string>
<string name="onboarding_finish_setup_title">Finalizar configuración</string>
<string name="onboarding_finish_setup_description">Tu conexión con Hermes está lista. Elige ahora lo que puede hacer este teléfono o cámbialo más tarde en Ajustes.</string>
<string name="onboarding_chat_manage_ready">Listo — no se necesitan permisos del teléfono.</string>
<string name="onboarding_chat_alerts">Alertas de chat</string>
<string name="onboarding_chat_alerts_description">Permite las notificaciones de Android para que las alertas activadas te lleguen en segundo plano.</string>
<string name="onboarding_chat_alerts_ready">Las notificaciones están activadas para esta aplicación.</string>
<string name="onboarding_optional_features">Funciones opcionales</string>
<string name="onboarding_optional_features_description">La cámara, el micrófono, el asistente de notificaciones y las herramientas disponibles del dispositivo permanecen desactivados hasta que los elijas.</string>
<string name="onboarding_review_optional_permissions">Revisar permisos opcionales</string>
<string name="onboarding_enable_chat_alerts">Activar alertas de chat</string>
<string name="onboarding_not_now">Ahora no</string>
<string name="onboarding_finish">Finalizar configuración</string>
<string name="chat_input_live_voice_hint">Conversación de voz en vivo</string>
<string name="chat_input_add_attachment">Agregar archivo adjunto</string>
<string name="chat_input_browse_commands">Comandos de exploración</string>
@@ -587,11 +599,11 @@
<string name="settings_profile_lock_desc">Anclar la aplicación a un perfil de agente</string>
<string name="settings_quick_controls">Controles rápidos</string>
<string name="settings_persistent_connection">Conexión persistente</string>
<string name="settings_persistent_connection_desc">Mantener abierta su conexión a Hermes en segundo plano</string>
<string name="settings_connect_on_demand">Conexión solo bajo demanda · ahorra batería</string>
<string name="settings_turn_complete_alerts">Alertas de turno completo</string>
<string name="settings_turn_complete_alerts_desc">Notificar cuando finaliza una respuesta mientras la aplicación está en segundo plano</string>
<string name="settings_turn_complete_alerts_off">No hay alerta cuando finaliza una respuesta en segundo plano</string>
<string name="settings_persistent_connection_desc">Mantener la conexión aunque no haya ningún chat en curso</string>
<string name="settings_connect_on_demand">Los chats activos siguen conectados automáticamente · la conexión inactiva se cierra para ahorrar batería</string>
<string name="settings_turn_complete_alerts">Alertas de chat</string>
<string name="settings_turn_complete_alerts_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
<string name="settings_turn_complete_alerts_off">Sin alertas de actividad de chat en segundo plano</string>
<string name="settings_keep_connected_deep_sleep">Mantenlo conectado mientras duermes profundamente</string>
<string name="settings_keep_connected_battery_desc">Android todavía puede pausar la conexión una vez que la pantalla ha estado apagada por un tiempo (Doze). Permita la batería sin restricciones para que la conexión persistente siga funcionando en segundo plano.</string>
<string name="settings_allow_unrestricted_battery">Permitir batería sin restricciones</string>
@@ -619,8 +631,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Muestre sus mensajes recientes como chips que se pueden tocar encima del redactor para enviarlos nuevamente. Desactivado de forma predeterminada.</string>
<string name="chat_settings_keep_keyboard_open">Mantener el teclado abierto al enviar</string>
<string name="chat_settings_keep_keyboard_open_desc">Permanece en el compositor después del envío. Desactívelo para descartar el teclado después de cada mensaje enviado.</string>
<string name="chat_settings_notify_when_finishes">Notificar cuando finalice Hermes</string>
<string name="chat_settings_notify_when_finishes_desc">Publicar una notificación cuando se complete una respuesta mientras la aplicación está en segundo plano</string>
<string name="chat_settings_notify_when_finishes">Alertas de chat en segundo plano</string>
<string name="chat_settings_notify_when_finishes_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
<string name="chat_settings_share_phone_status">Compartir el estado del teléfono con el agente</string>
<string name="chat_settings_share_phone_status_desc">Incluya un breve mensaje del sistema sobre la aplicación y el teléfono en cada turno de chat.</string>
<string name="chat_settings_bridge_permissions">Bridge + permisos</string>
+19 -7
View File
@@ -56,6 +56,18 @@
<string name="onboarding_realtime_label">リアルタイム</string>
<string name="onboarding_realtime_description">プロバイダーネイティブのリアルタイム音声エージェントとプロファイル認識音声プロバイダー。</string>
<string name="onboarding_review_permissions">権限の確認</string>
<string name="onboarding_finish_setup_title">セットアップを完了</string>
<string name="onboarding_finish_setup_description">Hermes への接続準備ができました。このスマートフォンで許可する機能を選ぶか、後で設定から変更できます。</string>
<string name="onboarding_chat_manage_ready">準備完了 — スマートフォンの権限は必要ありません。</string>
<string name="onboarding_chat_alerts">チャット通知</string>
<string name="onboarding_chat_alerts_description">有効にした通知をバックグラウンドでも受け取れるよう、Android の通知を許可します。</string>
<string name="onboarding_chat_alerts_ready">このアプリの通知は有効です。</string>
<string name="onboarding_optional_features">オプション機能</string>
<string name="onboarding_optional_features_description">カメラ、マイク、通知コンパニオン、利用可能な端末ツールは、選択するまで無効のままです。</string>
<string name="onboarding_review_optional_permissions">オプション権限を確認</string>
<string name="onboarding_enable_chat_alerts">チャット通知を有効にする</string>
<string name="onboarding_not_now">今はしない</string>
<string name="onboarding_finish">セットアップを完了</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">ライブ音声会話</string>
@@ -660,11 +672,11 @@
<string name="settings_profile_lock_desc">アプリを 1 つのエージェント プロファイルに固定する</string>
<string name="settings_quick_controls">クイックコントロール</string>
<string name="settings_persistent_connection">永続的な接続</string>
<string name="settings_persistent_connection_desc">Hermes への接続をバッ​​クグラウンドで開いたままにします</string>
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
<string name="settings_turn_complete_alerts">ターン完了アラート</string>
<string name="settings_turn_complete_alerts_desc">アプリがバックグラウンドで動作しているときに返信が完了したときに通知する</string>
<string name="settings_turn_complete_alerts_off">バックグラウンドでの返信が終了してもアラートは表示されません</string>
<string name="settings_persistent_connection_desc">チャットが実行されていないときも接続を維持</string>
<string name="settings_connect_on_demand">アクティブなチャットは自動的に接続を維持 · アイドル時はバッテリー節約のため接続を閉じます</string>
<string name="settings_turn_complete_alerts">チャット通知</string>
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
<string name="settings_turn_complete_alerts_off">バックグラウンドのチャット動作を通知しません</string>
<string name="settings_keep_connected_deep_sleep">深い睡眠中も接続を維持する</string>
<string name="settings_keep_connected_battery_desc">Android は、画面がしばらくオフになった後でも接続を一時停止できます (Doze)。永続的な接続がバックグラウンドで動作し続けるように、無制限のバッテリーを許可します。</string>
<string name="settings_allow_unrestricted_battery">無制限のバッテリーを許可する</string>
@@ -694,8 +706,8 @@
<string name="chat_settings_recent_prompt_chips_desc">最近のメッセージをコンポーザーの上にタップ可能なチップとして表示し、再送信します。デフォルトではオフです。</string>
<string name="chat_settings_keep_keyboard_open">送信時にキーボードを開いたままにする</string>
<string name="chat_settings_keep_keyboard_open_desc">送信後はコンポーザー内に留まります。メッセージを送信するたびにキーボードを閉じるには、オフにします。</string>
<string name="chat_settings_notify_when_finishes">Hermes が終了したら通知する</string>
<string name="chat_settings_notify_when_finishes_desc">アプリがバックグラウンドで動作中に返信が完了したときに通知を投稿する</string>
<string name="chat_settings_notify_when_finishes">バックグラウンドのチャット通知</string>
<string name="chat_settings_notify_when_finishes_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
<string name="chat_settings_share_phone_status">電話のステータスをエージェントと共有する</string>
<string name="chat_settings_share_phone_status_desc">すべてのチャット ターンにアプリと電話に関する短いシステム メッセージを含めます</string>
<string name="chat_settings_bridge_permissions">Bridge + 権限</string>
+19 -7
View File
@@ -56,6 +56,18 @@
<string name="onboarding_realtime_label">Realtime</string>
<string name="onboarding_realtime_description">Provider-native realtime voice agent and profile-aware voice providers.</string>
<string name="onboarding_review_permissions">Review permissions</string>
<string name="onboarding_finish_setup_title">Finish setup</string>
<string name="onboarding_finish_setup_description">Your Hermes connection is ready. Choose what this phone can do now, or change it later in Settings.</string>
<string name="onboarding_chat_manage_ready">Ready — no phone permission needed.</string>
<string name="onboarding_chat_alerts">Chat alerts</string>
<string name="onboarding_chat_alerts_description">Allow Android notifications so enabled alerts can reach you in the background.</string>
<string name="onboarding_chat_alerts_ready">Notifications are enabled for this app.</string>
<string name="onboarding_optional_features">Optional features</string>
<string name="onboarding_optional_features_description">Camera, microphone, notification companion, and available device tools stay off until you choose them.</string>
<string name="onboarding_review_optional_permissions">Review optional permissions</string>
<string name="onboarding_enable_chat_alerts">Enable chat alerts</string>
<string name="onboarding_not_now">Not now</string>
<string name="onboarding_finish">Finish setup</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">Live voice conversation</string>
@@ -677,11 +689,11 @@
<string name="settings_profile_lock_desc">Pin the app to one agent profile</string>
<string name="settings_quick_controls">Quick Controls</string>
<string name="settings_persistent_connection">Persistent connection</string>
<string name="settings_persistent_connection_desc">Keeping your connection to Hermes open in the background</string>
<string name="settings_connect_on_demand">Connect on demand only · saves battery</string>
<string name="settings_turn_complete_alerts">Turn-complete alerts</string>
<string name="settings_turn_complete_alerts_desc">Notify when a reply finishes while the app is in the background</string>
<string name="settings_turn_complete_alerts_off">No alert when a backgrounded reply finishes</string>
<string name="settings_persistent_connection_desc">Stay connected even when no chat is running</string>
<string name="settings_connect_on_demand">Active chats stay connected automatically · idle connection closes to save battery</string>
<string name="settings_turn_complete_alerts">Chat alerts</string>
<string name="settings_turn_complete_alerts_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
<string name="settings_turn_complete_alerts_off">No alerts for background chat activity</string>
<string name="settings_keep_connected_deep_sleep">Keep it connected in deep sleep</string>
<string name="settings_keep_connected_battery_desc">Android can still pause the connection once the screen\'s been off a while (Doze). Allow unrestricted battery so Persistent connection keeps working in the background.</string>
<string name="settings_allow_unrestricted_battery">Allow unrestricted battery</string>
@@ -711,8 +723,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Show your recent messages as tappable chips above the composer to send them again. Off by default.</string>
<string name="chat_settings_keep_keyboard_open">Keep keyboard open on send</string>
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
<string name="chat_settings_notify_when_finishes">Notify when Hermes finishes</string>
<string name="chat_settings_notify_when_finishes_desc">Post a notification when a reply completes while the app is in the background</string>
<string name="chat_settings_notify_when_finishes">Background chat alerts</string>
<string name="chat_settings_notify_when_finishes_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
<string name="chat_settings_share_phone_status">Share phone status with agent</string>
<string name="chat_settings_share_phone_status_desc">Include a short system message about the app and phone on every chat turn</string>
<string name="chat_settings_bridge_permissions">Bridge + permissions</string>
@@ -0,0 +1,43 @@
package com.hermesandroid.relay.network.upstream
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ActiveTurnKeepAliveRegistryTest {
@After
fun tearDown() = ActiveTurnKeepAliveRegistry.resetForTest()
@Test
fun siblingSessionsHoldIndependentLeases() {
ActiveTurnKeepAliveRegistry.acquire("connection::victor::session-a")
ActiveTurnKeepAliveRegistry.acquire("connection::default::session-b")
ActiveTurnKeepAliveRegistry.setWaiting("connection::victor::session-a", true)
assertEquals(
ActiveTurnKeepAliveRegistry.Snapshot(activeTurnCount = 2, waitingSessionCount = 1),
ActiveTurnKeepAliveRegistry.snapshot.value,
)
ActiveTurnKeepAliveRegistry.release("connection::victor::session-a")
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
assertEquals(0, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
}
@Test
fun sessionRenameMovesRatherThanDuplicatesLease() {
ActiveTurnKeepAliveRegistry.acquire("temporary")
ActiveTurnKeepAliveRegistry.setWaiting("temporary", true)
ActiveTurnKeepAliveRegistry.rename("temporary", "durable")
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
ActiveTurnKeepAliveRegistry.release("durable")
assertFalse(ActiveTurnKeepAliveRegistry.snapshot.value.required)
}
}
@@ -420,6 +420,7 @@ class GatewayChatClientTest {
val sessionIds = ConcurrentLinkedQueue<String>()
val errors = ConcurrentLinkedQueue<String>()
val interactions = ConcurrentLinkedQueue<GatewayAsk>()
val interactionResolutions = ConcurrentLinkedQueue<GatewayAskExpiry>()
val toolStarts = ConcurrentLinkedQueue<Pair<String, String>>()
val toolDone = ConcurrentLinkedQueue<Pair<String, String?>>()
@@ -448,6 +449,7 @@ class GatewayChatClientTest {
onSubagentEvent = { subagentEvents += it },
onInteractionRequest = { interactions += it },
onInteractionExpired = { },
onInteractionResolved = { interactionResolutions += it },
onStatusUpdate = { _, _ -> },
onStatusClear = { },
)
@@ -2234,6 +2236,94 @@ class GatewayChatClientTest {
assertTrue(harness.rpcLog.none { it.first == "session.interrupt" })
}
@Test
fun `detached interaction is retained and replayed when exact session reopens`() {
val original = Recorder()
val backgroundEvents = ConcurrentLinkedQueue<GatewayBackgroundInteractionEvent>()
client.setBackgroundInteractionListener(backgroundEvents::add)
client.sessionProfileProvider = { "work" }
client.sendTurn(null, "long task", null, original.callbacks) {
original.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
assertTrue(client.backgroundActiveTurn())
serverWs.send(
harness.eventFrame(
"approval.request",
buildJsonObject { put("command", "redacted command") },
"live-1",
),
)
repeat(50) {
if (backgroundEvents.isNotEmpty()) return@repeat
Thread.sleep(20)
}
val requested = backgroundEvents.single() as GatewayBackgroundInteractionEvent.Requested
assertEquals("20260612_120000_abc123", requested.storedSessionId)
assertEquals("work", requested.profile)
assertEquals(GatewayAsk.Kind.APPROVAL, requested.ask.kind)
harness.recoveryRunning = true
val resumed = Recorder()
runBlocking {
client.recoverTurn(
storedId = "20260612_120000_abc123",
preferredLiveId = "live-1",
callbacks = resumed.callbacks,
).getOrThrow()
}
repeat(50) {
if (resumed.interactions.isNotEmpty()) return@repeat
Thread.sleep(20)
}
assertEquals(GatewayAsk.Kind.APPROVAL, resumed.interactions.single().kind)
serverWs.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", "continued") },
"live-1",
),
)
repeat(50) {
if (resumed.interactionResolutions.isNotEmpty()) return@repeat
Thread.sleep(20)
}
assertEquals(
GatewayAskExpiry(GatewayAsk.Kind.APPROVAL, null),
resumed.interactionResolutions.single(),
)
}
@Test
fun `terminal read request is answered empty without user interaction`() {
val recorder = Recorder()
client.sendTurn(null, "inspect terminal", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
serverWs.send(
harness.eventFrame(
"terminal.read.request",
buildJsonObject {
put("request_id", "terminal-1")
put("start", 0)
put("count", 20)
},
"live-1",
),
)
val response = harness.awaitRpc("terminal.read.respond")
assertEquals(JsonPrimitive("terminal-1"), response["request_id"])
assertEquals(JsonPrimitive(""), response["text"])
assertTrue(recorder.interactions.isEmpty())
}
@Test
fun `background turn reconnects shared socket and reports completion`() {
val original = Recorder()
@@ -28,6 +28,7 @@ class GatewayEventMapperTest {
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
val interactions = mutableListOf<GatewayAsk>()
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
val interactionResolutions = mutableListOf<GatewayAskExpiry>()
val statusUpdates = mutableListOf<Pair<String?, String>>()
val statusClears = mutableListOf<String>()
val sessionIds = mutableListOf<String>()
@@ -58,6 +59,7 @@ class GatewayEventMapperTest {
onSubagentEvent = { subagentEvents += it },
onInteractionRequest = { interactions += it },
onInteractionExpired = { interactionExpiries += it },
onInteractionResolved = { interactionResolutions += it },
onStatusUpdate = { kind, text -> statusUpdates += kind to text },
onStatusClear = { statusClears += it },
)
@@ -682,24 +684,52 @@ class GatewayEventMapperTest {
}
@Test
fun `sudo secret clarify and approval expiry events preserve correlation contract`() {
fun `interaction expiry events preserve correlation contract`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("clarify.expire", obj("""{"request_id":"r1"}"""))
mapper.onEvent("sudo.expire", obj("""{"request_id":"r2"}"""))
mapper.onEvent("secret.expire", obj("""{"request_id":"r3"}"""))
mapper.onEvent("clarify.expire", obj("""{"request_id":"r4"}"""))
mapper.onEvent("approval.expire", obj("""{"request_id":"ignored"}"""))
assertEquals(
listOf(
GatewayAsk.Kind.CLARIFY,
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
GatewayAsk.Kind.CLARIFY,
GatewayAsk.Kind.APPROVAL,
),
r.interactionExpiries.map { it.kind },
)
assertEquals(listOf("r2", "r3", "r4", null), r.interactionExpiries.map { it.requestId })
assertEquals(listOf("r1", "r2", "r3", null), r.interactionExpiries.map { it.requestId })
}
@Test
fun `replayed request is deduplicated and resumed turn resolves it`() {
val r = Recorder()
val mapper = mapperWith(r)
val request = obj("""{"request_id":"r1","question":"Choose","choices":["A","B"]}""")
mapper.onEvent("clarify.request", request)
mapper.onEvent("clarify.request", request)
mapper.onEvent("message.delta", obj("""{"text":"Continuing"}"""))
assertEquals(1, r.interactions.size)
assertEquals(
GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, "r1"),
r.interactionResolutions.single(),
)
}
@Test
fun `terminal read request is renderer plumbing not an actionable interaction`() {
val r = Recorder()
mapperWith(r).onEvent(
"terminal.read.request",
obj("""{"request_id":"terminal-1","start":0,"count":20}"""),
)
assertTrue(r.interactions.isEmpty())
}
// --- Forward compat ---
@@ -728,7 +758,7 @@ class GatewayEventMapperTest {
"reasoning.delta", "thinking.delta", "message.delta", "message.interim", "message.start",
"message.complete", "error", "clarify.request", "approval.request",
"sudo.request", "secret.request", "reasoning.available",
"sudo.expire", "secret.expire", "clarify.expire", "approval.expire",
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
"tool.generating", "subagent.start", "subagent.thinking",
"subagent.tool", "subagent.progress", "subagent.complete",
"tool.output_risk", "moa.reference", "moa.aggregating",
@@ -0,0 +1,189 @@
package com.hermesandroid.relay.notifications
import android.Manifest
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.network.upstream.GatewayAsk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
class InteractionRequestNotifierTest {
private lateinit var context: Context
private lateinit var manager: NotificationManager
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
manager = context.getSystemService(NotificationManager::class.java)
manager.cancelAll()
shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(
Manifest.permission.POST_NOTIFICATIONS,
)
}
@After
fun tearDown() {
manager.cancelAll()
}
@Test
fun backgroundAndSettingAndPermissionGatesAreExplicit() {
assertTrue(InteractionRequestNotifier.shouldPost(true, false, true))
assertFalse(InteractionRequestNotifier.shouldPost(false, false, true))
assertFalse(InteractionRequestNotifier.shouldPost(true, true, true))
assertFalse(InteractionRequestNotifier.shouldPost(true, false, false))
}
@Test
fun requestReplayReplacesOneNotificationAndDeepLinksToExactSession() {
val ask = approval(command = "rm -rf /private/path")
assertTrue(post(ask, SESSION_ID, PROFILE))
assertTrue(post(ask, SESSION_ID, PROFILE))
val active = manager.activeNotifications.single()
assertEquals(InteractionRequestNotifier.notificationTag(SESSION_ID, ask, PROFILE), active.tag)
val tapIntent = shadowOf(active.notification.contentIntent).savedIntent
assertEquals(
InteractionRequestNotifier.chatRoute(SESSION_ID, PROFILE),
tapIntent.getStringExtra(MainActivity.EXTRA_NAV_ROUTE),
)
assertEquals(
InteractionRequestNotifier.requestKey(SESSION_ID, ask, PROFILE),
tapIntent.data?.lastPathSegment,
)
}
@Test
fun copyAndPublicVersionNeverExposePromptCommandOrSecretMetadata() {
val secret = GatewayAsk(
kind = GatewayAsk.Kind.SECRET,
requestId = "secret-1",
text = "Paste the production payment token",
envVar = "PAYMENT_TOKEN",
timeoutSeconds = 300,
)
assertTrue(post(secret, SESSION_ID))
val notification = manager.activeNotifications.single().notification
val privateText = notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
val expandedText = notification.extras
.getCharSequence(Notification.EXTRA_BIG_TEXT)
.toString()
val privateTitle = notification.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
val publicText = notification.publicVersion.extras
.getCharSequence(Notification.EXTRA_TEXT)
.toString()
val visibleCopy = "$privateTitle $privateText $expandedText $publicText"
assertFalse(visibleCopy.contains(secret.text))
assertFalse(visibleCopy.contains(secret.envVar!!))
assertTrue(visibleCopy.contains("Open Hermes"))
assertTrue(expandedText.contains("Profile: Server default"))
assertEquals("Respond securely", notification.actions.single().title)
assertEquals(Notification.VISIBILITY_PRIVATE, notification.visibility)
}
@Test
fun sameStoredSessionInTwoProfilesKeepsIndependentNotificationSlots() {
val ask = approval(command = "private")
assertTrue(post(ask, SESSION_ID, "victor"))
assertTrue(post(ask, SESSION_ID, "server-default"))
assertEquals(2, manager.activeNotifications.size)
assertEquals(
2,
manager.activeNotifications.map { it.tag }.distinct().size,
)
}
@Test
fun resolvedOrExpiredRequestCancelsOnlyItsStableSlot() {
val first = approval(command = "first")
val second = GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = "clarify-2",
text = "second",
timeoutSeconds = 300,
)
assertTrue(post(first, SESSION_ID))
assertTrue(post(second, SESSION_ID))
assertEquals(2, manager.activeNotifications.size)
InteractionRequestNotifier.cancel(context, SESSION_ID, first)
val remaining = manager.activeNotifications.single()
assertEquals(InteractionRequestNotifier.notificationTag(SESSION_ID, second), remaining.tag)
}
@Test
fun appRelaunchCleanupRemovesOnlyInteractionChannelNotifications() {
val ask = approval(command = "private")
assertTrue(post(ask, SESSION_ID))
val unrelated = Notification.Builder(context, "other-channel")
.setSmallIcon(com.hermesandroid.relay.R.mipmap.ic_launcher)
.setContentTitle("Unrelated")
.build()
manager.createNotificationChannel(
android.app.NotificationChannel(
"other-channel",
"Other",
NotificationManager.IMPORTANCE_DEFAULT,
),
)
manager.notify(99, unrelated)
InteractionRequestNotifier.cancelAll(context)
assertEquals(1, manager.activeNotifications.size)
assertEquals(99, manager.activeNotifications.single().id)
}
@Test
fun missingAndroidPermissionSkipsPosting() {
shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(
Manifest.permission.POST_NOTIFICATIONS,
)
assertFalse(post(approval(command = "hidden"), SESSION_ID))
assertTrue(manager.activeNotifications.isEmpty())
}
private fun post(ask: GatewayAsk, sessionId: String, profile: String? = null): Boolean =
InteractionRequestNotifier.notify(
context = context,
sessionId = sessionId,
ask = ask,
profile = profile,
alertsEnabled = true,
appForeground = false,
)
private fun approval(command: String) = GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
requestId = null,
text = command,
timeoutSeconds = 0,
)
companion object {
private const val SESSION_ID = "session with spaces/and/slashes"
private const val PROFILE = "work profile"
}
}
@@ -0,0 +1,49 @@
package com.hermesandroid.relay.ui.onboarding
import android.os.Build
import org.junit.Assert.assertEquals
import org.junit.Test
class OnboardingPermissionPolicyTest {
@Test
fun permissionSetup_followsSuccessfulConnection() {
assertEquals(
OnboardingPage.Permissions,
standardOnboardingPages[standardOnboardingPages.indexOf(OnboardingPage.Connect) + 1],
)
}
@Test
fun android13WithoutNotificationGrant_requestsPermission() {
assertEquals(
OnboardingNotificationAction.RequestPermission,
onboardingNotificationAction(
sdkInt = Build.VERSION_CODES.TIRAMISU,
notificationsPermitted = false,
),
)
}
@Test
fun grantedNotificationPermission_finishesSetup() {
assertEquals(
OnboardingNotificationAction.Finish,
onboardingNotificationAction(
sdkInt = Build.VERSION_CODES.TIRAMISU,
notificationsPermitted = true,
),
)
}
@Test
fun preAndroid13_finishesWithoutRuntimeNotificationPrompt() {
assertEquals(
OnboardingNotificationAction.Finish,
onboardingNotificationAction(
sdkInt = Build.VERSION_CODES.S_V2,
notificationsPermitted = false,
),
)
}
}
@@ -0,0 +1,89 @@
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xxhdpi")
class PermissionSetupPageTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun missingNotifications_showsOneTapRecommendationAndOptionalReview() {
var permissionRequests = 0
var finishes = 0
composeTestRule.setContent {
HermesRelayTheme {
PermissionSetupPage(
notificationAction = OnboardingNotificationAction.RequestPermission,
onEnableNotifications = { permissionRequests += 1 },
onReviewPermissions = {},
onFinish = { finishes += 1 },
)
}
}
composeTestRule.onNodeWithText("Finish setup").assertIsDisplayed()
composeTestRule.onNodeWithText("Ready — no phone permission needed.").assertIsDisplayed()
composeTestRule
.onNodeWithText("Review optional permissions")
.performScrollTo()
.assertIsDisplayed()
.assertIsEnabled()
composeTestRule
.onNodeWithText("Enable chat alerts")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Not now")
.performScrollTo()
.performClick()
composeTestRule.runOnIdle {
assertEquals(1, permissionRequests)
assertEquals(1, finishes)
}
}
@Test
fun grantedNotifications_showsFinishWithoutPermissionPrompt() {
composeTestRule.setContent {
HermesRelayTheme {
PermissionSetupPage(
notificationAction = OnboardingNotificationAction.Finish,
onEnableNotifications = {},
onReviewPermissions = {},
onFinish = {},
)
}
}
composeTestRule
.onNodeWithText("Notifications are enabled for this app.")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Enable chat alerts").assertDoesNotExist()
composeTestRule.onNodeWithText("Not now").assertDoesNotExist()
composeTestRule
.onNode(hasText("Finish setup") and hasClickAction())
.performScrollTo()
.assertIsEnabled()
}
}
+10 -10
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -26,7 +26,7 @@
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -61,7 +61,7 @@
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -85,7 +85,7 @@
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -109,7 +109,7 @@
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -133,7 +133,7 @@
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
+5 -5
View File
@@ -156,12 +156,12 @@ This app is a client for a Hermes server the user runs themselves, so a fresh in
### Foreground service permissions
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for `GatewayKeepAliveService`, backing the opt-in **Persistent connection** feature (off by default). At submission, complete **App content → Foreground service permissions** for `specialUse`:
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for `GatewayKeepAliveService`. It protects user-started active chat turns automatically and also backs the opt-in **Persistent connection** feature for idle connectivity. At submission, complete **App content → Foreground service permissions** for `specialUse`:
- **Use case:** maintains a persistent connection to the user's own Hermes agent server so the assistant stays responsive and delivers replies while the app is backgrounded.
- **Use case:** keeps a user-started Hermes turn connected until it finishes or pauses for user input, including multiple concurrent sessions; optionally maintains the idle connection when the user enables Persistent connection.
- **Why a foreground service:** it's a real-time, user-initiated streaming connection that must survive Doze / background execution limits; `dataSync` is force-stopped after a 6-hour/day cap on Android 15, so `specialUse` is the only fit for "stay connected."
- **User control:** off by default; enabled only via *Settings → Quick Controls → Persistent connection*; shows an ongoing notification with a **Turn off** action; ends when the app is swiped from recents.
- Google usually asks for a short screen recording of the toggle + notification.
- **User control:** the service starts when the user sends a chat message and stops after all active turns settle. Continuous idle retention is off by default and enabled only via *Settings → Quick Controls → Persistent connection*. The ongoing notification shows the active/waiting session count; its **Turn off always-on** action disables idle retention without interrupting active work. Swiping the app from recents ends it.
- Google usually asks for a short screen recording of a backgrounded active turn, the ongoing notification, and the optional persistent toggle.
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
@@ -172,7 +172,7 @@ No data collection or sharing to declare: no telemetry, ads, or third-party anal
### Sensitive / runtime permissions in the Play build
- `RECORD_AUDIO` — Voice mode, requested at use.
- `POST_NOTIFICATIONS` — turn-complete + keep-alive notifications, requested on API 33+.
- `POST_NOTIFICATIONS` — chat input, turn-complete, and keep-alive notifications, requested on API 33+.
- `CAMERA` — QR pairing / attachments, requested at use.
- Notification listener (companion) — user-enabled in system settings.
+2 -2
View File
@@ -63,13 +63,13 @@ INTERNET, ACCESS_NETWORK_STATE, CAMERA, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS,
POST_NOTIFICATIONS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_SPECIAL_USE
```
It also declares the optional notification companion service and the opt-in `GatewayKeepAliveService` — the "Persistent connection" feature, a `specialUse` foreground service that is off by default and ships on **both** flavors. It does not declare the accessibility service, the Device Control bridge foreground service, overlay permission, wake lock, MediaProjection foreground-service type, contacts, location, SMS, or call permissions.
It also declares the optional notification companion service and `GatewayKeepAliveService`, a `specialUse` foreground service that protects user-started active turns automatically and optionally extends to idle time through "Persistent connection." It ships on **both** flavors. It does not declare the accessibility service, the Device Control bridge foreground service, overlay permission, wake lock, MediaProjection foreground-service type, contacts, location, SMS, or call permissions.
### Google Play manifest
The Play manifest does not add Device Control permissions or services. The merged Play manifest should contain no `AccessibilityService`, `BIND_ACCESSIBILITY_SERVICE`, `SYSTEM_ALERT_WINDOW`, `WAKE_LOCK`, `FOREGROUND_SERVICE_MEDIA_PROJECTION`, or `BridgeForegroundService` declaration.
> **Exception — `FOREGROUND_SERVICE_SPECIAL_USE` + `GatewayKeepAliveService` _are_ in the merged Play manifest.** They back the opt-in "Persistent connection" feature (off by default), which ships on both flavors via the main manifest. This means the Play build carries one `specialUse` foreground service and therefore needs a Play Console foreground-service declaration at submission (see `docs/play-store-listing.md`). The Device Control bridge's `specialUse|mediaProjection` service stays sideload-only.
> **Exception — `FOREGROUND_SERVICE_SPECIAL_USE` + `GatewayKeepAliveService` _are_ in the merged Play manifest.** They protect user-started active turns and back the opt-in idle "Persistent connection" feature, which ships on both flavors via the main manifest. This means the Play build carries one `specialUse` foreground service and therefore needs a Play Console foreground-service declaration at submission (see `docs/play-store-listing.md`). The Device Control bridge's `specialUse|mediaProjection` service stays sideload-only.
### Sideload manifest
+2 -1
View File
@@ -55,7 +55,8 @@ API-Schlüssel ist davon getrennt und kein Dashboard-Login.
2. Suche Hermes im LAN, trage die Dashboard/Gateway-URL ein oder scanne einen Einrichtungs-QR; ältere API-first-QRs bleiben kompatibel.
3. Melde dich bei Aufforderung am Dashboard an.
4. Tippe auf **Connect** und prüfe **Chat · Ready**.
5. Füge API-Fallback, Relay oder weitere Remote-Routen bei Bedarf später unter **Advanced** hinzu.
5. Aktiviere unter **Einrichtung abschließen** Android-Benachrichtigungen, wenn du Chat-Hinweise im Hintergrund erhalten möchtest. Kamera, Mikrofon und weitere Funktionen bleiben optional und werden einzeln eingerichtet; mit **Jetzt nicht** kannst du direkt fortfahren.
6. Füge API-Fallback, Relay oder weitere Remote-Routen bei Bedarf später unter **Advanced** hinzu.
Eine Tailscale-Dashboard-Adresse wie `http://100.x.y.z:9119` oder eine separat
veröffentlichte `.ts.net`-Adresse kann ohne API-Server oder API-Schlüssel als Route hinzugefügt und geprüft werden.
+2 -1
View File
@@ -54,7 +54,8 @@ La clave de API es independiente y no inicia sesión en el dashboard.
2. Busca Hermes en la LAN, introduce la URL del Dashboard/Gateway o escanea un QR; los QR API-first antiguos siguen siendo compatibles.
3. Inicia sesión en el dashboard cuando se solicite.
4. Pulsa **Connect** y comprueba **Chat · Ready**.
5. Añade API fallback, Relay o rutas remotas después desde **Advanced** si lo necesitas.
5. En **Finalizar configuración**, activa las notificaciones de Android si quieres alertas de chat en segundo plano. La cámara, el micrófono y las demás funciones siguen siendo opcionales y se configuran una a una; pulsa **Ahora no** para continuar directamente.
6. Añade API fallback, Relay o rutas remotas después desde **Advanced** si lo necesitas.
Puedes añadir y probar una dirección Dashboard de Tailscale como
`http://100.x.y.z:9119`, o una dirección `.ts.net` publicada por separado, sin configurar el servidor API ni una clave API.
+28 -9
View File
@@ -127,12 +127,19 @@ around 75% and **red** near 90%, with a `· NN% ctx` readout in the header
subtitle as you get close to the limit — a quiet heads-up to wrap up or
`/compress` before the model starts dropping the earliest messages.
## Turn-complete notifications
## Chat alerts
Long task running while you switch away? If you leave the app mid-reply,
Hermes-Relay posts a **"Hermes replies"** notification when the turn finishes, so
you can jump back in. Toggle it under **Settings → Chat → Notify when Hermes
finishes** (it asks for notification permission the first time).
If you leave the app during a Gateway turn, Hermes-Relay alerts you when Hermes
needs an approval, clarification, elevated-permission response, or secret, and
again when the turn finishes. An action-required alert opens the exact
conversation and request. Replayed events replace the existing alert, and the
alert clears after you respond, the request expires, or the turn resumes.
Notification text never includes the command, question, secret prompt, or
environment-variable name. Those details stay inside the authenticated chat
surface, including on the lock screen. Toggle this behavior under **Settings →
Quick Controls → Chat alerts**; Android asks for notification permission the
first time.
## Smooth auto-scroll
@@ -238,11 +245,23 @@ A running Gateway chat keeps its event connection attached; if that connection
drops, the app reconnects while the server continues working. Idle chats pre-warm
when reopened so the next message is fast.
If you want the connection to stay fully open even when the app is in the
background, turn on **Settings → Quick Controls → Persistent connection** (off by
default). It holds the app's connection to Hermes open via an ongoing
While any chat is running, Hermes-Relay automatically uses an ongoing Android
notification to keep the app and its connection active until every concurrent
turn finishes or is interrupted. If a turn needs approval, clarification, or
sensitive input, its separate alert reopens the exact profile and conversation.
Expanding that alert shows safe session context, but never the requested command,
question, password, secret, or environment-variable name.
If you want the connection to stay fully open even when no chat is running,
turn on **Settings → Quick Controls → Persistent connection** (off by default).
It extends the same foreground protection to idle time via an ongoing
notification, so messages and live features stay responsive; for relay-paired
setups it also keeps device control and notification mirroring reachable. Tap
**Turn off** on the notification, or flip the toggle, to stop. This uses more
**Turn off always-on** on the notification, or flip the toggle, to stop the idle
retention without interrupting active work. This uses more
battery; swiping the app away from recents also ends it. It's the same approach
apps like Home Assistant use to stay connected.
Chat alerts and active-turn protection do not require Persistent connection.
Enable it only when an idle connection or Relay-powered live features must
remain reachable during long background or Doze periods.
+4
View File
@@ -263,6 +263,10 @@ On first launch:
and headless configurations.
3. Optional: add API fallback, Relay, or remote routes under **Advanced**.
4. Tap **Connect**.
5. On **Finish setup**, enable Android notifications if you want background
chat alerts. Camera, microphone, notification companion, and Device Control
permissions remain optional and can be reviewed one at a time. Choose
**Not now** to keep those features off and start chatting.
That's it — Chat is live, and the Manage surfaces (Skills, Cron, MCP, Profiles,
Models/Config) light up too. Manage may ask you to sign in to the dashboard the
+2 -1
View File
@@ -54,7 +54,8 @@ API キーは別の認証情報で、ダッシュボードへのログインに
2. LAN 検索、Dashboard/Gateway URL の入力、またはセットアップ QR を選びます。従来の API-first QR も互換です。
3. 求められた場合はダッシュボードへログインします。
4. **Connect** をタップし、**Chat · Ready** を確認します。
5. 必要なら後から **Advanced** で API fallback、Relay、リモートルートを追加します。
5. **セットアップを完了** で、バックグラウンドのチャット通知が必要なら Android の通知を有効にします。カメラ、マイク、その他の機能は引き続き任意で、個別に設定できます。すぐ進む場合は **今はしない** を選びます。
6. 必要なら後から **Advanced** で API fallback、Relay、リモートルートを追加します。
`http://100.x.y.z:9119` のような Tailscale Dashboard アドレスや、別途公開した
`.ts.net` アドレスは、API サーバーや API キーなしで追加・テストできます。
+2 -1
View File
@@ -54,7 +54,8 @@ API é separada e não autentica no dashboard.
2. Procure o Hermes na LAN, informe a URL do Dashboard/Gateway ou escaneie um QR; QRs API-first antigos continuam compatíveis.
3. Entre no dashboard quando solicitado.
4. Toque em **Connect** e confirme **Chat · Ready**.
5. Adicione API fallback, Relay ou rotas remotas depois em **Advanced**, se necessário.
5. Em **Concluir configuração**, ative as notificações do Android se quiser alertas do chat em segundo plano. Câmera, microfone e os demais recursos continuam opcionais e são configurados individualmente; toque em **Agora não** para seguir direto.
6. Adicione API fallback, Relay ou rotas remotas depois em **Advanced**, se necessário.
Você pode adicionar e testar um endereço Dashboard do Tailscale como
`http://100.x.y.z:9119`, ou um endereço `.ts.net` publicado separadamente, sem configurar o servidor de API nem uma chave de API.
+2 -1
View File
@@ -49,7 +49,8 @@ Dashboard 登录使用 Cookie 和短期 Gateway ticket。API 密钥与其独立
2. 搜索局域网中的 Hermes、输入 Dashboard/Gateway URL,或扫描设置二维码;旧版 API-first 二维码仍兼容。
3. 按提示登录 Dashboard。
4. 点击 **Connect** 并确认显示 **Chat · Ready**。
5. 如有需要,稍后在 **Advanced** 中添加 API fallback、Relay 或远程路由。
5. 在 **完成设置** 中,如需后台聊天提醒,请启用 Android 通知。相机、麦克风和其他功能仍为可选,并可逐项设置;选择 **暂不** 可直接继续。
6. 如有需要,稍后在 **Advanced** 中添加 API fallback、Relay 或远程路由。
可以添加并测试 `http://100.x.y.z:9119` 这样的 Tailscale Dashboard 地址,或单独发布的 `.ts.net` 地址;无需配置 API 服务器或 API 密钥。