Compare commits

...
32 changed files with 1458 additions and 173 deletions
+2
View File
@@ -8,10 +8,12 @@ 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
- **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.
+29
View File
@@ -1,5 +1,19 @@
# Hermes-Relay — Dev Log
## 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 +23,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
@@ -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-
@@ -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)
@@ -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,186 @@
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"
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."
}
@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 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)
.setContentIntent(tapPending)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.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()
},
)
}
}
@@ -44,6 +44,7 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnHandle
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 +72,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
@@ -1082,6 +1084,7 @@ class ChatViewModel : ViewModel() {
previousClient?.setUnsolicitedTurnProvider(null)
previousClient?.setColdPrewarmSessionReadyListener(null)
previousClient?.setUnmatchedTurnCompleteListener(null)
previousClient?.setBackgroundInteractionListener(null)
}
gatewayClient = client
if (changed) {
@@ -1143,6 +1146,28 @@ class ChatViewModel : ViewModel() {
expectedAssistantText = completion.expectedAssistantText,
)
}
client?.setBackgroundInteractionListener { event ->
when (event) {
is GatewayBackgroundInteractionEvent.Requested -> {
backgroundPendingInteractions[event.storedSessionId] =
BackgroundPendingInteraction(event.profile, event.ask)
maybeNotifyInteraction(event.storedSessionId, event.ask, event.profile)
}
is GatewayBackgroundInteractionEvent.Resolved -> {
backgroundPendingInteractions.computeIfPresent(event.storedSessionId) { _, current ->
if (current.profile == event.profile &&
current.ask.kind == event.ask.kind &&
current.ask.requestId == event.ask.requestId
) {
null
} else {
current
}
}
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) {
@@ -1369,6 +1394,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 +1656,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<String, BackgroundPendingInteraction>()
/** Ask cardKeys with a respond RPC in flight — blocks double-taps until it settles. */
private val answeredAskIds = mutableSetOf<String>()
@@ -1718,11 +1753,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 +2479,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 +2544,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 +2800,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_yoloEnabled.value = null
_fastEnabled.value = null
@@ -2795,6 +2839,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_yoloEnabled.value = null
_fastEnabled.value = null
@@ -2839,6 +2884,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
onSessionChanged?.invoke(null)
}
@@ -2900,6 +2946,7 @@ class ChatViewModel : ViewModel() {
handler.clearMessages()
_contextUsage.value = null
_contextWindow.value = null
dismissPendingAskNotification()
_pendingAsk.value = null
_yoloEnabled.value = null
_fastEnabled.value = null
@@ -3225,6 +3272,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 +3291,19 @@ 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) }
}
sessionId?.let { backgroundPendingInteractions.remove(it) }
val now = restored?.receivedAt ?: System.currentTimeMillis()
val cardKey = restored?.cardKey ?: ask.requestId
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
@@ -3337,6 +3404,7 @@ class ChatViewModel : ViewModel() {
receivedAt = now,
)
scheduleCheckpointWrite(immediate = true)
sessionId?.let { maybeNotifyInteraction(it, ask) }
}
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
@@ -3435,6 +3503,9 @@ 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
scheduleCheckpointWrite(immediate = true)
}
@@ -3460,6 +3531,9 @@ 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
answeredAskIds.remove(pending.cardKey)
scheduleCheckpointWrite(immediate = true)
@@ -3470,6 +3544,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,6 +3564,9 @@ class ChatViewModel : ViewModel() {
*/
private fun clearPendingAsk(approvalStamp: String) {
val pending = _pendingAsk.value ?: return
chatHandler?.currentSessionId?.value?.let {
cancelInteractionNotification(it, pending.ask)
}
_pendingAsk.value = null
scheduleCheckpointWrite(immediate = true)
if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
@@ -3768,6 +3856,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 +3919,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 { (backgroundSessionId, pending) ->
maybeNotifyInteraction(
backgroundSessionId,
pending.ask,
pending.profile,
)
}
}
}
}
}
@@ -4362,6 +4493,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 +6515,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 +7176,8 @@ class ChatViewModel : ViewModel() {
gatewayClient?.setUnsolicitedTurnProvider(null)
gatewayClient?.setColdPrewarmSessionReadyListener(null)
gatewayClient?.setUnmatchedTurnCompleteListener(null)
gatewayClient?.setBackgroundInteractionListener(null)
backgroundPendingInteractions.clear()
gatewayHistoryReconcileJob?.cancel()
gatewayHistoryReconcileJob = null
backgroundProcessSessionJob?.cancel()
+17 -5
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>
@@ -621,9 +633,9 @@
<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_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>
+17 -5
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>
@@ -662,9 +674,9 @@
<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_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>
+17 -5
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>
@@ -662,9 +674,9 @@
<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_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>
+17 -5
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>
@@ -589,9 +601,9 @@
<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_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>
+17 -5
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>
@@ -662,9 +674,9 @@
<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_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>
+17 -5
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>
@@ -679,9 +691,9 @@
<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_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>
@@ -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,170 @@
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 privateTitle = notification.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
val publicText = notification.publicVersion.extras
.getCharSequence(Notification.EXTRA_TEXT)
.toString()
val visibleCopy = "$privateTitle $privateText $publicText"
assertFalse(visibleCopy.contains(secret.text))
assertFalse(visibleCopy.contains(secret.envVar!!))
assertTrue(visibleCopy.contains("Open Hermes"))
assertEquals(Notification.VISIBILITY_PRIVATE, notification.visibility)
}
@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": "4b26eae02b463489ea2777b30571bbd16939a0b4878cd3b0ffa1aec1aafb7c29",
"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": "4b26eae02b463489ea2777b30571bbd16939a0b4878cd3b0ffa1aec1aafb7c29",
"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": "4b26eae02b463489ea2777b30571bbd16939a0b4878cd3b0ffa1aec1aafb7c29",
"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": "4b26eae02b463489ea2777b30571bbd16939a0b4878cd3b0ffa1aec1aafb7c29",
"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": "4b26eae02b463489ea2777b30571bbd16939a0b4878cd3b0ffa1aec1aafb7c29",
"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"
},
+1 -1
View File
@@ -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 -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.
+17 -5
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
@@ -246,3 +253,8 @@ 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
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 do not require Persistent connection while an already-running turn
is attached, but Android may stop an idle background app later. Enable
Persistent connection when approval and other action-required alerts must
remain reachable during long idle 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 密钥。