Compare commits

..
Author SHA1 Message Date
Bailey Dixon 889c2fb316 Merge pull request #390 from Codename-11/dev
release(android): android-v1.11.0
2026-08-20 20:56:04 -04:00
Bailey Dixon 260c21737c merge: prepare Android 1.11.0 release 2026-08-20 20:12:40 -04:00
Bailey Dixon 7036219f90 release(android): android-v1.11.0 2026-08-20 20:12:06 -04:00
Bailey Dixon d741acab27 merge: clear Android release verification blockers 2026-08-20 19:53:04 -04:00
Bailey Dixon 8d9970449c test(android): align localized route diagnostics 2026-08-20 19:52:38 -04:00
Bailey Dixon be50f9a726 fix(android): preserve stopped recovery placeholders 2026-08-20 19:52:37 -04:00
Bailey Dixon f21923d39b merge: resync remote dev after website hotfix 2026-08-20 17:31:51 -04:00
Bailey Dixon 27970d4020 Merge pull request #389 from Codename-11/chore/backmerge-website-remote-access-link
chore: back-merge website link hotfix
2026-08-20 17:27:59 -04:00
Bailey Dixon 94992ff37f chore: back-merge website link hotfix 2026-08-20 17:27:01 -04:00
Bailey Dixon a25de7fe31 Merge pull request #388 from Codename-11/fix/website-remote-access-link
fix(website): repair remote access links
2026-08-20 17:24:45 -04:00
Bailey Dixon 2006d552e2 fix(website): repair remote access links 2026-08-20 17:22:23 -04:00
Bailey Dixon ab532d0696 merge: sync remote dev before release 2026-08-20 17:11:47 -04:00
Bailey Dixon 66971cb0e2 Merge pull request #383 from ophirhan/fix/soft-keyboard-newline
fix(android): only physical Enter sends; let IME return key insert newline
2026-08-20 14:20:47 -04:00
Bailey Dixon 9ae8de2c9d merge: fully expand Android Bridge screen access sheet 2026-08-20 13:17:59 -04:00
Bailey Dixon b5174d6279 fix(android): fully expand screen access sheet 2026-08-20 13:17:41 -04:00
Bailey Dixon 7ac5a48e18 merge: clarify Android Bridge access controls 2026-08-20 13:01:43 -04:00
Bailey Dixon ea6cb5a6a7 fix(android): clarify bridge access controls 2026-08-20 13:01:29 -04:00
Bailey Dixon d5cccd664a merge: preserve unlimited Android Bridge state 2026-08-20 12:40:14 -04:00
Bailey Dixon a48349e3cf fix(android): preserve unlimited bridge access state 2026-08-20 12:39:54 -04:00
Bailey Dixon d476704c3f merge: allow unlimited Android Bridge screen access 2026-08-20 11:53:15 -04:00
Bailey Dixon f7a070ecfe feat(android): allow unlimited bridge screen access 2026-08-20 11:52:52 -04:00
Bailey Dixon 37084566a0 merge: clarify Android Bridge access setup 2026-08-20 11:05:47 -04:00
Bailey Dixon c43f22f18d feat(android): clarify bridge access setup 2026-08-20 11:04:59 -04:00
Bailey Dixon 6244ab3781 merge: surface Android stored session resume failures 2026-08-20 09:14:08 -04:00
Bailey Dixon 9b1852a986 merge: reconcile current dev for Android resume failure fix
# Conflicts:
#	CHANGELOG.md
2026-08-20 08:54:52 -04:00
Bailey Dixon 99f853c98e fix(android): surface stored session resume failures 2026-08-20 08:53:49 -04:00
ophirhan 39782a5ff1 fix(android): only physical Enter sends; let IME return key insert newline
The #318 keyboard handling made any KEYCODE_ENTER key event submit the
message when physicalEnterSends is enabled. Some IMEs dispatch the soft
keyboard return key as a synthesized KEYCODE_ENTER key event (deviceId
-1), so on those keyboards the return key sent the message instead of
inserting a newline - leaving no way to type multi-line prompts from
the touchscreen.

Gate the submit path on physical keys (deviceId != -1) so IME-dispatched
Enter falls through to the default newline insertion while hardware Enter
keeps the send behavior. Adds a regression test for the IME key-event path.

Closes #367
2026-08-20 14:18:56 +03:00
Bailey Dixon 0d660c0e41 merge: add granular Android Bridge capability grants 2026-08-19 21:10:05 -04:00
Bailey Dixon 6b14ac0e0e Merge branch 'dev' into feature/android-bridge-capability-grants 2026-08-19 21:01:31 -04:00
Bailey Dixon 59b5424c49 Merge branch 'fix/android-power-audit-377' into dev 2026-08-19 20:58:24 -04:00
Bailey Dixon 0f83af76f6 fix(android): bound power-sensitive runtime work 2026-08-19 20:08:15 -04:00
Bailey Dixon 6a39e8dc1a feat(android): add granular bridge capability grants 2026-08-19 19:43:04 -04:00
Bailey Dixon e8473e14c8 Merge pull request #376 from Codename-11/chore/backmerge-android-1.10.0
chore: back-merge Android 1.10.0 release
2026-08-18 22:23:49 -04:00
Bailey Dixon e05018bd0b chore: back-merge Android 1.10.0 release 2026-08-18 22:22:49 -04:00
80 changed files with 4392 additions and 544 deletions
+19
View File
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.11.0] - 2026-08-20
### Added
- **Sideload Bridge access is explicitly capability-scoped.** Read-only, read-and-confirm, and custom presets grant only selected powers for the active connection. Screen inspection and control can be allowed for a bounded period or explicitly left unlimited, and Relay status reports the resulting permanent, timed, and unlimited grants.
### Changed
- **The sideload Bridge screen is a summary-first access cockpit.** Agent access, unattended mode, selected Android requirements, and advanced safety controls are separated clearly while the complete permission matrix and power-user controls remain available one tap deeper.
### Fixed
- **Android keeps failed session resumes visible and in context.** Continuing a stored Gateway session no longer falls through to a fresh session when Hermes rejects or mis-scopes the resume. Failed turns remain error-marked and expose a composer-adjacent recovery panel with route-aware details, explicit retry/dismiss actions, and sanitized Diagnostics evidence.
- **Software-keyboard Return inserts a newline across both common Android IME paths.** Keyboards that commit text directly and keyboards that synthesize `KEYCODE_ENTER` now keep multiline composition separate from physical-keyboard Send behavior. (#367)
- **Cancelled answer recovery retains its Stopped status.** Empty recovery placeholders with a persistent status badge are no longer discarded during stream finalization.
- **Android screen-on idle no longer continuously redraws the ASCII sphere.** Idle holds a stable frame while thinking, streaming, and voice states retain full-rate motion; inactive voice waveforms and closed session drawers also stop their frame loops.
- **Android capture and audio effects release power-sensitive resources at their actual lifecycle boundaries.** Screen capture attaches its MediaProjection surface only for a requested frame, unattended Bridge wake locks release when the command finishes, and barge-in AEC/noise suppression attach to the microphone capture session instead of playback.
- **Experimental wake-word listening reuses its PCM normalization buffer.** Continuous opt-in listening no longer allocates a new float frame for every inference call.
## [1.10.0] - 2026-08-18
### Added
+4 -3
View File
@@ -235,9 +235,10 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
| **App — Bridge** | |
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
| `bridge/BridgeSafetyManager.kt` | Connection-scoped capabilities + timed screen expiry + blocklist + destructive confirmation; unknown, denied, and expired commands fail closed |
| `bridge/BridgeCapabilities.kt` / `data/BridgeCapabilityPolicyRepository.kt` | Closed method/path registry + no-backup-bound per-Connection Always/Never/Timed policy; global safety vocabulary and timer duration remain in `BridgeSafetyPreferences.kt` |
| `ui/screens/BridgeScreen.kt` | Bridge cockpit — master → Agent access posture/setup → single Unattended Access control → capability-scoped Android readiness (expandable full matrix) → Advanced safety/full editor → activity log |
| `ui/components/BridgeAccessCards.kt` | Native access cockpit + first-use preset and screen-lease sheets (renewable idle limits or warned Until-off dedicated-device mode); preserves full permission/safety drilldowns while keeping selected policy/readiness above the fold |
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
+31
View File
@@ -1,5 +1,36 @@
# Hermes-Relay — Dev Log
## 2026-08-20 — Android stored-session resume failures stay visible
Android now treats a failed Gateway `session.resume` as authoritative for the
selected stored conversation. The client no longer creates a replacement
session and submits the continuation after a resume rejection or profile-scope
mismatch, preventing a context-free turn from silently selecting different
runtime state.
Gateway terminal failures and pre-submit transport failures now share a
session-scoped panel immediately above the composer. The panel keeps the failed
transcript row intact, shows only confirmed route/model/provider identity,
offers explicit Details, Retry, and Dismiss actions, and records bounded,
redacted evidence in the existing Diagnostics review/share flow. No route or
model is changed automatically.
## 2026-08-18 — Android 1.10.0 chat continuity and streaming Markdown
Hermes-Relay Android 1.10.0 is published from the immutable
`android-v1.10.0` tag, with the production Play submission committed as
versionCode 45. The release preserves exact-session composer drafts across
restarts, converts large pastes into reviewable attachments, and keeps standard
chat compatible with unmodified upstream Hermes.
Assistant replies now render completed Markdown structures incrementally while
holding an incomplete streaming tail stable. Stable message identity and a
bounded bottom-follow controller prevent completion-time replacement, stacked
scroll animations, and transcript-distance velocity from moving a reader who
has deliberately scrolled away. Foreground reconnect reattaches the visible
Gateway session, malformed imported credentials fail closed, and software
keyboard Return remains distinct from the dedicated Send action.
## 2026-08-17 — Android composer continuity and large-paste review
Android's multiline composer now leaves the software IME action as Return while
+20 -26
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.10.0
# Hermes-Relay-Android v1.11.0
**Release Date:** August 18, 2026
**Release Date:** August 20, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.10.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.11.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,35 +12,29 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This release makes Android chat more continuous: drafts survive restarts, large
pastes become reviewable attachments, live replies render with incremental
Markdown, and foreground reconnect or completion no longer disrupts the open
conversation.
This release makes Android control more explicit and chat recovery more honest. Sideload users can grant only the Bridge capabilities they intend, including bounded or unlimited screen access, while every build gains clearer stored-session failures, complete multiline keyboard behavior, persistent cancellation status, and lower idle power use.
## Added
- Preserve text, quote/edit context, and pending attachments in the exact
connection, profile, and session draft across app restarts.
- Convert large pastes into reviewable text attachments before sending while
retaining compatible text delivery on fallback transports.
- Render paragraphs, lists, links, fenced code, and tables incrementally from
the first streamed token without replacing the message at completion.
- Choose read-only, read-and-confirm, or custom Bridge capability presets for the active connection in sideload builds.
- Grant screen inspection and control for a bounded duration or explicitly keep them unlimited, with the active policy reflected in Relay status.
## Changed
- Use a summary-first Bridge screen that separates Agent access, unattended mode, Android readiness, and advanced safety without removing the full permission matrix.
## Fixed
- Reattach the visible Gateway session after background/foreground reconnect
and reconcile missed work without leaving the conversation.
- Expose Return on the software keyboard while keeping the dedicated Send
action and physical-keyboard behavior distinct.
- Reject malformed imported credentials before network-header construction or
encrypted-state replacement.
- Keep intentional scrollback fixed and bottom-follow stable while Markdown,
voice actions, timestamps, and token metadata settle.
- Keep stored-session resume failures in the conversation with route-aware details and explicit retry or dismiss actions instead of silently switching context.
- Insert newlines from both direct-text and synthesized-Enter software keyboards while preserving physical Enter, Shift+Enter, and Ctrl/Cmd+Enter behavior.
- Retain the Stopped status when a blank recovery placeholder is cancelled.
- Stop idle Sphere, waveform, and closed-drawer redraw loops when no motion is visible.
- Attach screen-capture surfaces only for requested frames, release unattended wake locks at command completion, and bind audio effects to the capture session.
- Reuse wake-word normalization buffers during continuous opt-in listening.
## Install / Verify
- App version: **1.10.0** (versionCode **45**).
- Standard Chat, sessions, Manage, profile identity, streaming Markdown, and
Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat,
foreground session reattachment, or streaming Markdown.
- App version: **1.11.0** (versionCode **46**).
- Standard Chat, sessions, Manage, stored-session recovery, software-keyboard multiline input, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control grants and screen-access durations are sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin is not required for standard Android chat, session recovery, or multiline composition.
+27 -1
View File
@@ -6,6 +6,32 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Certify Android power fixes across the reported device matrix
Issue #377's static estimates are not device measurements. The code now keeps
the idle Sphere static, gates inactive waveform/drawer animation, detaches the
MediaProjection surface between requested frames, binds AEC/NS to the capture
session, releases unattended wake locks at command completion, and reuses the
wake-word normalization buffer. Complete the remaining physical proof before
assigning battery percentages or declaring the report closed:
- Re-run the reported Android 13 / Pixel 4 XL workload with screen-on and
screen-off intervals separated, and with experimental wake listening both
disabled and explicitly enabled. Capture scoped CPU/thread/network/wakelock
evidence plus Battery Historian or Perfetto without resetting batterystats
unless the device owner approves the reset.
- On Android 14+ and a foldable/rotation path, request two screenshots around a
geometry change and verify the existing VirtualDisplay resizes, its surface
is detached between requests, and the projection token is not reused.
- On at least one device with platform AEC, run Standard and Realtime barge-in
through playback and confirm the effect is enabled on the AudioRecord session,
the microphone remains single-owner, interruption still works, and teardown
leaves no audio effect or capture session active.
- Compare Wi-Fi and cellular separately. Treat radio-tail claims as unproven
until packet timing and mobile-radio active time reproduce them on hardware.
---
## Certify the official Desktop Relay plugin
The unified `plugin/desktop/plugin.js` implementation is covered by source-level
@@ -1271,7 +1297,7 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
supported CUA range; restore a mandatory health gate only if the upstream
probe is bounded and cannot leave UI Automation falsely busy.
- **MediaProjection consent flow** — wired in MainActivity (2026-04-12), needs end-to-end test on a real device
- **WorkManager upgrade for auto-disable timer** — currently a coroutine `Job + delay()` in `AutoDisableWorker.kt`; documented at top of file. Upgrade when androidx.work joins the classpath
- **WorkManager upgrade for timed screen-access expiry notification** — authority already fails closed from persisted absolute expiry after restart; the prompt notification is currently a coroutine `Job + delay()` coordinated by `BridgeSafetyManager` / `AutoDisableWorker`. Upgrade only if background notification timing becomes important after androidx.work joins the classpath.
- **Wave 3 voice-bridge multi-turn confirmation** — currently a 5s TTS countdown with cancel; conversational confirmation is the follow-up
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
@@ -1 +1,3 @@
See Markdown take shape while replies stream without a final message rebuild or scroll jump. Return from another app and resume the open Hermes session automatically. Composer drafts and pending attachments now survive restarts, large pastes become reviewable text attachments, and the software keyboard exposes Return while the dedicated button sends.
v1.11.0 - More reliable chat
Keep stored-session failures visible with route-aware retry details. Use Return for multiline prompts across more software keyboards. Preserve Stopped status when cancelling answer recovery. Reduce idle redraws and release capture and audio resources sooner.
@@ -1 +1,3 @@
新增可在界面中漫游的 Petdex 浮动宠物、由已安装 Hermes 插件提供的安全原生页面,以及支持本地“Hey Hermes”的可选 Android 数字助理。本次更新还新增俄语,并改进语音恢复、路线切换、实时聊天稳定性和宠物移动。
v1.11.0 - 更可靠的聊天体验
已存会话恢复失败时会保留错误与重试信息。更多输入法可用回车键换行。取消回复恢复时会保留“已停止”状态。空闲动画更少,并更及时地释放屏幕捕获和音频资源。
+28
View File
@@ -1,5 +1,33 @@
{
"versions": [
{
"version": "1.11.0",
"title": "Access with clear boundaries",
"date": "2026-08-20",
"sections": [
{
"header": "Choose what Bridge can do",
"bullets": [
"Use read-only, read-and-confirm, or custom capability presets for the active connection in sideload builds.",
"Allow screen inspection and control for a bounded period or explicitly keep access unlimited."
]
},
{
"header": "Recover without losing context",
"bullets": [
"Keep stored-session failures visible with route-aware details and clear retry or dismiss actions.",
"Insert newlines across more software keyboards and retain Stopped status when answer recovery is cancelled."
]
},
{
"header": "Use less power while idle",
"bullets": [
"Pause invisible Sphere, waveform, and drawer animation loops when no motion is needed.",
"Attach capture surfaces only for requested frames and release audio or wake-lock resources at their lifecycle boundaries."
]
}
]
},
{
"version": "1.10.0",
"title": "Chat that stays put",
+7 -7
View File
@@ -1,8 +1,8 @@
v1.10.0 - Chat that stays put
v1.11.0 - Access with clear boundaries
* See Markdown take shape while replies stream, without a final message rebuild.
* Keep the bottom smoothly followed—or scroll back without being pulled away.
* Return from another app and resume the open Hermes session automatically.
* Keep composer drafts and pending attachments across app restarts.
* Turn large pastes into reviewable text attachments before sending.
* Use Return on the software keyboard while the dedicated button sends.
* Choose explicit Bridge capability presets in sideload builds.
* Allow screen inspection and control for a set time or explicitly keep access unlimited.
* Keep stored-session failures visible with route-aware retry details.
* Use Return for multiline prompts across more software keyboards.
* Preserve Stopped status when cancelling answer recovery.
* Reduce idle redraws and release capture and audio resources sooner.
@@ -21,6 +21,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.add
import kotlinx.serialization.json.put
/**
@@ -226,6 +228,7 @@ class BridgeStatusReporter(
val destructiveVerbsCount = safetySnapshot?.destructiveVerbs?.size ?: 0
val autoDisableMinutes = safetySnapshot?.autoDisableMinutes ?: 0
val autoDisableAtMs = safetyManager?.autoDisableAtMs?.value
val capabilityPolicy = safetyManager?.activeCapabilityPolicy?.value
val deviceName = Build.MODEL ?: "unknown"
@@ -281,6 +284,33 @@ class BridgeStatusReporter(
put("auto_disable_at_ms", autoDisableAtMs)
}
})
put("capabilities", buildJsonObject {
put("schema_version", capabilityPolicy?.schemaVersion ?: 1)
put("permanent", buildJsonArray {
capabilityPolicy?.permanentGrants
?.sortedBy { it.wireId }
?.forEach { add(it.wireId) }
})
put("timed", buildJsonObject {
capabilityPolicy?.timedExpiriesMs
?.filterValues {
it != com.hermesandroid.relay.bridge.BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
}
?.toSortedMap(compareBy { it.wireId })
?.forEach { (capability, expiry) ->
put(capability.wireId, expiry)
}
})
put("unlimited", buildJsonArray {
capabilityPolicy?.timedExpiriesMs
?.filterValues {
it == com.hermesandroid.relay.bridge.BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
}
?.keys
?.sortedBy { it.wireId }
?.forEach { add(it.wireId) }
})
})
// v0.4.1: unattended-access state so the agent can decide
// upfront whether commands will reach apps with the screen
@@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
*
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
* the hard switch — if it's off we never receive events. On top of that the
* user can flip a soft master in Settings (`bridge_master_enabled`); when
* user can flip a soft master in Settings (`bridge_master_enabled_v2`); when
* that's false we still run (Android requires it to stay connected) but we
* refuse to execute commands. [isMasterEnabled] is a StateFlow the UI
* observes and the command handler checks before dispatching actions.
@@ -61,7 +61,9 @@ class HermesAccessibilityService : AccessibilityService() {
private const val TAG = "HermesA11yService"
/** Master-enable DataStore key — read + toggled from Settings UI. */
val KEY_BRIDGE_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
val KEY_BRIDGE_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled_v2")
private val KEY_LEGACY_BRIDGE_MASTER_ENABLED =
booleanPreferencesKey("bridge_master_enabled")
/**
* Static reference to the live service instance, or null if the
@@ -92,6 +94,7 @@ class HermesAccessibilityService : AccessibilityService() {
suspend fun setMasterEnabled(context: Context, enabled: Boolean) {
context.applicationContext.relayDataStore.edit { prefs ->
prefs[KEY_BRIDGE_MASTER_ENABLED] = enabled
prefs[KEY_LEGACY_BRIDGE_MASTER_ENABLED] = false
}
}
}
@@ -152,12 +152,14 @@ class ScreenCapture(
// 13 and below but breaks the second /screenshot request on 14+.
//
// Fix: keep the VirtualDisplay + ImageReader + HandlerThread alive
// across captures, keyed by the MediaProjection instance. Rebuild only
// when the projection reference changes (fresh consent grant) or the
// dimensions change (orientation flip). The ImageReader's
// setOnImageAvailableListener drains the buffer continuously; each
// captureAndUpload() installs a one-shot [pendingCapture] callback
// that fires on the next frame.
// across captures, keyed by the MediaProjection instance. The reader
// surface is attached only while a request is waiting, then detached so
// SurfaceFlinger is not continuously mirroring into a drain-and-drop loop.
// Rebuild only when the projection reference changes (fresh consent
// grant). Orientation/size changes resize the existing VirtualDisplay and
// replace its detached ImageReader, preserving Android 14's single-create
// contract. Each captureAndUpload() installs a one-shot [pendingCapture]
// callback that fires on the next attached frame.
//
// Thread model:
// - `captureMutex` serializes concurrent captureAndUpload() calls
@@ -273,6 +275,7 @@ class ScreenCapture(
*/
fun releaseCache() {
synchronized(cacheLock) {
runCatching { cachedDisplay?.setSurface(null) }
runCatching { cachedDisplay?.release() }
runCatching { cachedReader?.close() }
runCatching { cachedThread?.quitSafely() }
@@ -326,6 +329,7 @@ class ScreenCapture(
}
return try {
attachCaptureSurface()
val timeoutMs = captureTimeoutMs()
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
@@ -336,6 +340,24 @@ class ScreenCapture(
} catch (t: Throwable) {
pendingCaptureRef.compareAndSet(deferred, null)
throw t
} finally {
detachCaptureSurface()
}
}
private fun attachCaptureSurface() {
synchronized(cacheLock) {
val display = cachedDisplay ?: throw IOException("capture display unavailable")
val surface = cachedReader?.surface ?: throw IOException("capture surface unavailable")
display.setSurface(surface)
Log.d(TAG, "screen capture surface attached for pending frame")
}
}
private fun detachCaptureSurface() {
synchronized(cacheLock) {
runCatching { cachedDisplay?.setSurface(null) }
.onFailure { Log.v(TAG, "screen capture surface detach failed: ${it.message}") }
}
}
@@ -350,11 +372,12 @@ class ScreenCapture(
/**
* Build (or reuse) the cached VirtualDisplay + ImageReader + HandlerThread
* for this projection. Rebuilds when:
* for this projection. Rebuilds the display when:
*
* - The projection reference has changed (new consent grant landed)
* - The captured dimensions don't match the current display (orientation
* flipped, foldable opened/closed, display switched)
*
* Geometry changes resize that existing display and replace its detached
* consumer surface, as required for Android 14's one-display-per-token rule.
*
* Must be called while [captureMutex] is held so the cached fields
* aren't racing another capture.
@@ -368,52 +391,41 @@ class ScreenCapture(
synchronized(cacheLock) {
val projectionChanged = cachedProjection !== projection
val dimensionsChanged = width != cachedWidth || height != cachedHeight
if (!projectionChanged && !dimensionsChanged && cachedDisplay != null && cachedReader != null) {
val densityChanged = densityDpi != cachedDensity
if (!projectionChanged && !dimensionsChanged && !densityChanged &&
cachedDisplay != null && cachedReader != null
) {
return
}
// Android 14 permits only one createVirtualDisplay() call per
// MediaProjection. Resize the existing display and replace only
// its detached consumer surface when the device geometry changes.
if (!projectionChanged && cachedDisplay != null && cachedThread != null) {
val display = cachedDisplay ?: return
val thread = cachedThread ?: return
val handler = cachedHandler ?: Handler(thread.looper)
display.setSurface(null)
runCatching { cachedReader?.close() }
display.resize(width, height, densityDpi)
cachedReader = createImageReader(width, height, handler)
cachedHandler = handler
cachedWidth = width
cachedHeight = height
cachedDensity = densityDpi
Log.i(TAG, "screen capture pipeline resized ${width}x$height dpi=$densityDpi")
return
}
// Tear down any stale cache before building fresh.
runCatching { cachedDisplay?.setSurface(null) }
runCatching { cachedDisplay?.release() }
runCatching { cachedReader?.close() }
runCatching { cachedThread?.quitSafely() }
val thread = HandlerThread("HermesScreenCapture").apply { start() }
val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, MAX_IMAGES
)
// Persistent listener — fires on every frame the VirtualDisplay
// produces. If there's a pending capture request, we encode
// the frame and complete it; otherwise we just drain the image
// so the ImageReader buffer stays clear.
reader.setOnImageAvailableListener({ r ->
val waiter = pendingCaptureRef.get()
if (waiter == null || !waiter.isActive) {
// Drain-and-drop — nobody's asking for a screenshot
// right now but frames are still arriving.
runCatching { r.acquireLatestImage() }.getOrNull()?.close()
return@setOnImageAvailableListener
}
var image: Image? = null
try {
image = r.acquireLatestImage()
?: return@setOnImageAvailableListener
val png = imageToPngBytes(image, width, height)
// Only complete the EXACT deferred we latched onto,
// so a stale listener firing after supersession doesn't
// resolve a new request.
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.complete(png)
}
} catch (t: Throwable) {
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.completeExceptionally(t)
}
} finally {
runCatching { image?.close() }
}
}, handler)
val reader = createImageReader(width, height, handler)
val display = try {
projection.createVirtualDisplay(
@@ -422,7 +434,7 @@ class ScreenCapture(
height,
densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
reader.surface,
null,
null,
handler,
)
@@ -461,6 +473,38 @@ class ScreenCapture(
}
}
private fun createImageReader(width: Int, height: Int, handler: Handler): ImageReader {
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, MAX_IMAGES,
)
// The listener receives frames only while captureFrame() has attached
// this reader's surface. The empty-waiter branch drains a frame already
// queued at the detach boundary.
reader.setOnImageAvailableListener({ source ->
val waiter = pendingCaptureRef.get()
if (waiter == null || !waiter.isActive) {
runCatching { source.acquireLatestImage() }.getOrNull()?.close()
return@setOnImageAvailableListener
}
var image: Image? = null
try {
image = source.acquireLatestImage()
?: return@setOnImageAvailableListener
val png = imageToPngBytes(image, width, height)
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.complete(png)
}
} catch (t: Throwable) {
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.completeExceptionally(t)
}
} finally {
runCatching { image?.close() }
}
}, handler)
return reader
}
/**
* Convert an [Image] from `ImageReader` into a PNG byte array. The
* plane's `rowStride` may be wider than `width * 4` — we must crop
@@ -52,18 +52,12 @@ import kotlin.math.max
*
* We configure [AudioRecord] with [MediaRecorder.AudioSource.VOICE_COMMUNICATION]
* so the platform's voice-call AEC pipeline is in play, and additionally try
* to attach [AcousticEchoCanceler] + [NoiseSuppressor] keyed to the ExoPlayer
* audio session id so TTS audio is cancelled from the mic stream specifically.
* to attach [AcousticEchoCanceler] + [NoiseSuppressor] to the capture
* [AudioRecord] session. Android audio preprocessors belong to the capture
* path; a playback session is not a valid attachment target for AEC/NS.
* Without AEC, the device's own speaker output would trip the VAD the moment
* TTS started and we'd interrupt ourselves.
*
* The ExoPlayer audio session id is not stable at the moment we want to start
* listening — Media3 allocates the underlying AudioTrack lazily on first
* playback, and callers may hit [start] before that's happened (e.g. the very
* first sentence of a turn). We poll [audioSessionIdProvider] for up to 1 s
* before giving up on AEC and proceeding with the mic-hardware AEC alone.
* See the `AEC_SESSION_POLL_*` constants below.
*
* ### Graceful degradation
*
* - `AudioRecord.getState() != STATE_INITIALIZED` → log WARN, emit nothing,
@@ -93,8 +87,8 @@ import kotlin.math.max
class BargeInListener internal constructor(
private val audioSource: AudioFrameSource,
private val vadEngine: VadEngine,
private val audioSessionIdProvider: () -> Int,
private val readerDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val nowMsProvider: () -> Long = System::currentTimeMillis,
) {
companion object {
@@ -108,12 +102,6 @@ class BargeInListener internal constructor(
* brief delay (GC pause, dispatcher contention). */
private const val AUDIO_BUFFER_FRAMES = 4
/** ExoPlayer may return `0` for its audio session id until its
* AudioTrack is first allocated (on playback start). Poll the
* provider briefly before giving up on AEC and proceeding without. */
private const val AEC_SESSION_POLL_INTERVAL_MS = 50L
private const val AEC_SESSION_POLL_TIMEOUT_MS = 1_000L
/**
* Factory for the production path. Builds an [AudioRecordSource] from
* a `Context` and wires it to the listener. The returned listener has
@@ -122,11 +110,9 @@ class BargeInListener internal constructor(
fun create(
context: Context,
vadEngine: VadEngine,
audioSessionIdProvider: () -> Int,
): BargeInListener = BargeInListener(
audioSource = AudioRecordSource(context.applicationContext),
vadEngine = vadEngine,
audioSessionIdProvider = audioSessionIdProvider,
)
}
@@ -239,9 +225,8 @@ class BargeInListener internal constructor(
return@launch
}
Log.i(TAG, "Barge-in AudioRecord reader started")
// Do not block generation-phase listening while waiting for an
// AudioTrack session that does not exist until playback. The
// effects attach races harmlessly beside the reader.
// Effects attach beside the reader so capture can begin even
// on devices that reject or omit the optional preprocessors.
effectsJob = launch { maybeAttachEffects() }
while (isActive) {
@@ -282,7 +267,7 @@ class BargeInListener internal constructor(
val gated = rmsGate.observe(
frame = frameBuffer,
rawSpeech = result.probability > 0f,
nowMs = System.currentTimeMillis(),
nowMs = nowMsProvider(),
playbackGraceMs = playbackGraceMs,
confirmedSpeech = result.isSpeech,
playbackActiveOverride = playbackActiveProvider?.invoke(),
@@ -368,14 +353,12 @@ class BargeInListener internal constructor(
}
private suspend fun maybeAttachEffects() {
val sessionId = awaitNonZeroSessionId()
val sessionId = audioSource.audioSessionId
if (sessionId == 0) {
Log.i(
TAG,
"AEC not attached — ExoPlayer audio session id was still 0 " +
"after ${AEC_SESSION_POLL_TIMEOUT_MS}ms poll; continuing " +
"without effects (mic-hardware AEC from VOICE_COMMUNICATION " +
"still in play)",
"AEC not attached — AudioRecord capture session id is 0; " +
"continuing without optional effects",
)
return
}
@@ -411,20 +394,6 @@ class BargeInListener internal constructor(
}
}
private suspend fun awaitNonZeroSessionId(): Int {
val immediate = audioSessionIdProvider()
if (immediate != 0) return immediate
var waited = 0L
while (waited < AEC_SESSION_POLL_TIMEOUT_MS) {
delay(AEC_SESSION_POLL_INTERVAL_MS)
waited += AEC_SESSION_POLL_INTERVAL_MS
val id = audioSessionIdProvider()
if (id != 0) return id
}
return 0
}
private fun releaseEffects() {
aec?.let {
runCatching { it.enabled = false }
@@ -445,6 +414,9 @@ class BargeInListener internal constructor(
* reader coroutine.
*/
internal interface AudioFrameSource {
/** Capture-session id used by Android audio preprocessors. */
val audioSessionId: Int
/**
* Allocate underlying native resources. Returns true on success.
* Returning false from here short-circuits the listener without any
@@ -481,6 +453,9 @@ class BargeInListener internal constructor(
private class AudioRecordSource(context: Context) : AudioFrameSource {
private var record: AudioRecord? = null
override val audioSessionId: Int
get() = record?.audioSessionId ?: 0
@SuppressLint("MissingPermission")
override fun initialize(): Boolean {
val sampleRate = 16_000
@@ -15,25 +15,22 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
/**
* Phase 3 — safety-rails `bridge-safety-rails`
*
* Canonical "turn the bridge off after idle" unit of work. Not a real
* Canonical timed-screen-expiry notification unit. Not a real
* `androidx.work.CoroutineWorker` — the project intentionally does not
* depend on androidx.work — but its shape mirrors one exactly: a single
* suspend [run] method that performs the work and returns.
*
* Why this pattern instead of dropping a WorkManager dep:
* - Auto-disable is a pure in-memory decision: the toggle lives in our
* own DataStore, no inter-process scheduling is required.
* - Capability expiry is persisted as absolute wall-clock timestamps;
* the in-process job exists only to prune promptly and notify.
* - Android's AlarmManager / WorkManager are needed when the work must
* survive process death. For bridge, process death already implies
* the service is disconnected and the master toggle re-evaluates
* fresh on the next launch. So a coroutine-owned `delay` does it.
* - Every command reschedules the timer, so the idle window is always
* reset against wall clock. No drift concerns.
* survive process death. Authorization itself does survive because the
* command boundary compares persisted expiry with the current clock.
* - Only timed screen inspection/control commands reset the timer.
*
* When WorkManager is added later (say, if notif-listener needs background-posted
* notifications on a schedule), this file is a natural upgrade point:
@@ -51,17 +48,10 @@ class AutoDisableWorker(private val context: Context) {
}
/**
* Execute the auto-disable: flip the master toggle off and post a
* one-shot "bridge paused" notification. Idempotent — safe to call
* twice (the second call just re-writes the same DataStore value
* and overrides the existing notification).
* Post a one-shot notification after timed screen authority is revoked.
* Idempotent — a repeated call replaces the existing notification.
*/
suspend fun run() {
try {
HermesAccessibilityService.setMasterEnabled(context, false)
} catch (t: Throwable) {
Log.w(TAG, "run: failed to flip master toggle", t)
}
postNotification()
}
@@ -92,8 +82,7 @@ class AutoDisableWorker(private val context: Context) {
.setContentTitle(context.getString(R.string.bridge_notification_auto_disabled_title))
.setContentText(context.getString(R.string.bridge_notification_auto_disabled_body))
.setStyle(NotificationCompat.BigTextStyle().bigText(
"Hermes bridge was idle for too long, so device control has been turned off " +
"automatically. Open the Bridge tab to turn it back on if you still need it."
context.getString(R.string.bridge_notification_auto_disabled_body)
))
.setContentIntent(tapPending)
.setAutoCancel(true)
@@ -115,7 +104,7 @@ class AutoDisableWorker(private val context: Context) {
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Fires once when the bridge auto-disables after being idle."
description = "Fires once when timed Bridge screen access expires after idle."
setShowBadge(false)
}
nm.createNotificationChannel(channel)
@@ -0,0 +1,113 @@
package com.hermesandroid.relay.bridge
import kotlinx.serialization.Serializable
/** Stable, auditable authority groups for every phone-side Bridge command. */
@Serializable
enum class BridgeCapability(val wireId: String, val timed: Boolean) {
DEVICE_INFO("device_info", false),
CONTACTS_READ("contacts_read", false),
LOCATION_READ("location_read", false),
CLIPBOARD_READ("clipboard_read", false),
CLIPBOARD_WRITE("clipboard_write", false),
MEDIA_CONTROL("media_control", false),
COMMUNICATIONS("communications", false),
OUTBOUND_SHARING("outbound_sharing", false),
SCREEN_INSPECTION("screen_inspection", true),
SCREEN_CONTROL("screen_control", true),
}
enum class BridgeCapabilityGrant { EXEMPT, PERMANENT, TIMED }
data class BridgeCommandAuthority(
val capability: BridgeCapability? = null,
val grant: BridgeCapabilityGrant,
)
/**
* Closed command registry. Authorization is resolved from both path and HTTP
* method so method-split commands such as clipboard read/write cannot share a
* grant accidentally. Unknown paths and method combinations return null and
* must be denied by the command boundary.
*
* Composite Python tools (android_navigate/android_macro) do not get a broad
* grant: every primitive route they dispatch is checked here independently.
*/
object BridgeCommandRegistry {
private data class Key(val method: String, val path: String)
private fun permanent(capability: BridgeCapability) =
BridgeCommandAuthority(capability, BridgeCapabilityGrant.PERMANENT)
private fun timed(capability: BridgeCapability) =
BridgeCommandAuthority(capability, BridgeCapabilityGrant.TIMED)
private val exempt = BridgeCommandAuthority(grant = BridgeCapabilityGrant.EXEMPT)
private val routes: Map<Key, BridgeCommandAuthority> = buildMap {
fun route(method: String, path: String, authority: BridgeCommandAuthority) {
put(Key(method, path), authority)
}
route("GET", "/ping", exempt)
route("POST", "/setup", exempt)
route("POST", "/wait", exempt)
route("GET", "/current_app", permanent(BridgeCapability.DEVICE_INFO))
route("GET", "/get_apps", permanent(BridgeCapability.DEVICE_INFO))
route("GET", "/apps", permanent(BridgeCapability.DEVICE_INFO))
route("POST", "/search_contacts", permanent(BridgeCapability.CONTACTS_READ))
route("GET", "/location", permanent(BridgeCapability.LOCATION_READ))
route("GET", "/clipboard", permanent(BridgeCapability.CLIPBOARD_READ))
route("POST", "/clipboard", permanent(BridgeCapability.CLIPBOARD_WRITE))
route("POST", "/media", permanent(BridgeCapability.MEDIA_CONTROL))
route("POST", "/call", permanent(BridgeCapability.COMMUNICATIONS))
route("POST", "/send_sms", permanent(BridgeCapability.COMMUNICATIONS))
route("POST", "/share_media", permanent(BridgeCapability.OUTBOUND_SHARING))
route("POST", "/send_mms", permanent(BridgeCapability.OUTBOUND_SHARING))
listOf("/screen", "/screenshot", "/screen_hash", "/events").forEach {
route("GET", it, timed(BridgeCapability.SCREEN_INSPECTION))
}
listOf("/find_nodes", "/describe_node", "/diff_screen", "/events/stream").forEach {
route("POST", it, timed(BridgeCapability.SCREEN_INSPECTION))
}
listOf(
"/tap", "/tap_text", "/long_press", "/type", "/swipe", "/drag",
"/scroll", "/press_key", "/open_app", "/return_to_hermes",
"/send_intent", "/broadcast",
).forEach { route("POST", it, timed(BridgeCapability.SCREEN_CONTROL)) }
}
fun resolve(path: String, method: String): BridgeCommandAuthority? =
routes[Key(method.trim().uppercase(), path.trim())]
fun registeredRoutes(): Set<Pair<String, String>> =
routes.keys.mapTo(linkedSetOf()) { it.method to it.path }
}
@Serializable
data class BridgeCapabilityPolicy(
val schemaVersion: Int = CURRENT_SCHEMA_VERSION,
val permanentGrants: Set<BridgeCapability> = emptySet(),
val timedExpiriesMs: Map<BridgeCapability, Long> = emptyMap(),
) {
companion object {
const val CURRENT_SCHEMA_VERSION = 1
/** Explicit sentinel for a user-selected "Until turned off" lease. */
const val NEVER_EXPIRES_AT_MS: Long = Long.MAX_VALUE
}
fun allows(capability: BridgeCapability, nowMs: Long): Boolean =
if (capability.timed) {
(timedExpiriesMs[capability] ?: 0L) > nowMs
} else {
capability in permanentGrants
}
fun expiryFor(capability: BridgeCapability): Long? = timedExpiriesMs[capability]
fun isUnlimited(capability: BridgeCapability): Boolean =
timedExpiriesMs[capability] == NEVER_EXPIRES_AT_MS
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetySettings
import com.hermesandroid.relay.data.BridgeCapabilityPolicyRepository
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -14,6 +15,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
@@ -25,8 +28,8 @@ import java.util.concurrent.atomic.AtomicLong
/**
* Phase 3 — safety-rails `bridge-safety-rails`
*
* Central enforcement point for Tier 5 safety: per-app blocklist, destructive
* verb confirmation, and idle-based auto-disable. Owned as a singleton-per-
* Central enforcement point for Tier 5 safety: connection-scoped capabilities,
* per-app blocklist, destructive confirmation, and timed screen access. Owned as a singleton-per-
* process by [ConnectionViewModel] and injected into [BridgeCommandHandler].
*
* # Integration surface
@@ -47,9 +50,9 @@ import java.util.concurrent.atomic.AtomicLong
* reacts, which is exactly the UX we want (the server sees a slow
* response, not a denial race).
*
* - [rescheduleAutoDisable] — every accepted command bumps the idle timer
* forward; after [BridgeSafetySettings.autoDisableMinutes] of silence
* the master toggle flips off and a one-shot notification fires.
* - [rescheduleAutoDisable] — accepted timed screen commands bump the idle
* expiry forward; after [BridgeSafetySettings.autoDisableMinutes] of
* silence only timed screen authority is revoked and a notification fires.
* [cancelAutoDisable] cancels the pending timer (called when the master
* toggle flips off manually, so we don't race the timer against the
* user).
@@ -71,15 +74,14 @@ import java.util.concurrent.atomic.AtomicLong
* The Android app does not depend on androidx.work. [AutoDisableWorker]
* documents the canonical pattern, but the live path is a coroutine
* `Job` owned by this manager, delayed by the configured minutes. This is
* acceptable because we are the in-memory owner of the master-toggle flow
* — no inter-process or cross-restart scheduling is needed. On process
* death the master toggle is simply evaluated fresh from DataStore, and
* any command not explicitly sent within the idle window never actually
* happens because the app isn't running.
* acceptable because authorization stores an absolute expiry in DataStore.
* After process death or reconnect, the command boundary compares that expiry
* to wall clock and denies stale authority even if the notification job did not run.
*/
class BridgeSafetyManager(
context: Context,
private val scope: CoroutineScope,
private val activeConnectionId: StateFlow<String?>,
) {
companion object {
private const val TAG = "BridgeSafetyMgr"
@@ -94,10 +96,14 @@ class BridgeSafetyManager(
*/
fun peek(): BridgeSafetyManager? = INSTANCE
fun install(context: Context, scope: CoroutineScope): BridgeSafetyManager {
fun install(
context: Context,
scope: CoroutineScope,
activeConnectionId: StateFlow<String?>,
): BridgeSafetyManager {
val existing = INSTANCE
if (existing != null) return existing
val created = BridgeSafetyManager(context.applicationContext, scope)
val created = BridgeSafetyManager(context.applicationContext, scope, activeConnectionId)
INSTANCE = created
return created
}
@@ -105,6 +111,10 @@ class BridgeSafetyManager(
private val appContext: Context = context.applicationContext
private val prefsRepo = BridgeSafetyPreferencesRepository(appContext)
private val capabilityRepo = BridgeCapabilityPolicyRepository(appContext)
private val _activeCapabilityPolicy = MutableStateFlow(BridgeCapabilityPolicy())
val activeCapabilityPolicy: StateFlow<BridgeCapabilityPolicy> =
_activeCapabilityPolicy.asStateFlow()
/** Latest settings snapshot — UI + checks read this via [settings]. */
private val _settings = MutableStateFlow(BridgeSafetySettings())
@@ -140,12 +150,12 @@ class BridgeSafetyManager(
private val pendingConfirmations = ConcurrentHashMap<Long, PendingConfirmation>()
private val nextRequestId = AtomicLong(0L)
/** Coroutine job that fires auto-disable after idle. */
/** Coroutine job that prunes timed screen authority after idle. */
@Volatile
private var autoDisableJob: Job? = null
/**
* Remaining time (epoch millis) for the current auto-disable job, or
* Remaining time (epoch millis) for current timed screen authority, or
* null when idle. BridgeSafetySummaryCard reads this as a countdown.
*/
private val _autoDisableAtMs = MutableStateFlow<Long?>(null)
@@ -167,6 +177,100 @@ class BridgeSafetyManager(
trustedHydrated = true
}
}
scope.launch {
activeConnectionId.collectLatest { connectionId ->
schedulePersistedExpiry(connectionId)
capabilityRepo.policy(connectionId).collect { policy ->
_activeCapabilityPolicy.value = policy
}
}
}
}
data class CapabilityAuthorization(
val allowed: Boolean,
val authority: BridgeCommandAuthority? = null,
val errorCode: String? = null,
)
fun capabilityPolicy(connectionId: String?): Flow<BridgeCapabilityPolicy> =
capabilityRepo.policy(connectionId)
suspend fun authorizeCapability(
path: String,
method: String,
nowMs: Long = System.currentTimeMillis(),
): CapabilityAuthorization {
val authority = BridgeCommandRegistry.resolve(path, method)
?: return CapabilityAuthorization(false, errorCode = "unknown_bridge_command")
if (authority.grant == BridgeCapabilityGrant.EXEMPT) {
return CapabilityAuthorization(true, authority)
}
val connectionId = activeConnectionId.value
?: return CapabilityAuthorization(false, authority, "bridge_policy_unbound")
val capability = authority.capability
?: return CapabilityAuthorization(false, authority, "bridge_policy_invalid")
val policy = capabilityRepo.snapshot(connectionId)
return if (policy.allows(capability, nowMs)) {
CapabilityAuthorization(true, authority)
} else {
CapabilityAuthorization(
false,
authority,
if (capability.timed) "bridge_capability_expired" else "bridge_capability_denied",
)
}
}
suspend fun setPermanentCapability(
connectionId: String?,
capability: BridgeCapability,
allowed: Boolean,
) {
capabilityRepo.setPermanent(connectionId, capability, allowed)
}
suspend fun replacePermanentCapabilities(
connectionId: String?,
capabilities: Set<BridgeCapability>,
) {
capabilityRepo.replacePermanent(connectionId, capabilities)
}
suspend fun setTimedCapability(
connectionId: String?,
capability: BridgeCapability,
allowed: Boolean,
) {
if (!allowed) {
capabilityRepo.revoke(connectionId, capability)
if (capability == BridgeCapability.SCREEN_CONTROL) {
prefsRepo.setUnattendedAccessEnabled(false)
}
schedulePersistedExpiry(connectionId)
return
}
val fireAt = System.currentTimeMillis() + currentSettings().autoDisableMinutes * 60_000L
capabilityRepo.grantTimed(connectionId, capability, fireAt)
schedulePersistedExpiry(connectionId)
}
suspend fun replaceTimedCapabilities(
connectionId: String?,
capabilities: Set<BridgeCapability>,
durationMinutes: Int,
unlimited: Boolean = false,
) {
val fireAt = if (unlimited) {
BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
} else {
System.currentTimeMillis() + durationMinutes * 60_000L
}
capabilityRepo.replaceTimed(connectionId, capabilities, fireAt)
if (BridgeCapability.SCREEN_CONTROL !in capabilities) {
prefsRepo.setUnattendedAccessEnabled(false)
}
schedulePersistedExpiry(connectionId)
}
// ── Blocklist ────────────────────────────────────────────────────────
@@ -307,26 +411,35 @@ class BridgeSafetyManager(
pending.deferred.complete(allowed)
}
// ── Auto-disable timer ───────────────────────────────────────────────
// ── Timed screen-access expiry ──────────────────────────────────────
/**
* Cancel any pending timer and arm a fresh one. Called on every accepted
* bridge command — an actively-used bridge never auto-disables.
* Refresh active timed grants and arm their shared idle expiry. Permanent
* capability activity never calls this method.
*/
fun rescheduleAutoDisable() {
val connectionId = activeConnectionId.value ?: return
val minutes = _settings.value.autoDisableMinutes
val delayMs = minutes * 60_000L
val fireAt = System.currentTimeMillis() + delayMs
val fireAt = System.currentTimeMillis() + minutes * 60_000L
autoDisableJob?.cancel()
_autoDisableAtMs.value = fireAt
autoDisableJob = (scope + SupervisorJob()).launch {
try {
val snapshot = capabilityRepo.snapshot(connectionId)
val nowMs = System.currentTimeMillis()
val finite = snapshot.timedExpiriesMs.filterValues {
it != BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS && it > nowMs
}
if (finite.isEmpty()) {
_autoDisableAtMs.value = null
return@launch
}
capabilityRepo.refreshActiveTimed(connectionId, fireAt)
_autoDisableAtMs.value = fireAt
val delayMs = (fireAt - System.currentTimeMillis()).coerceAtLeast(0L)
delay(delayMs)
Log.i(TAG, "Auto-disable fired after $minutes min of idle")
// Hand off to the canonical worker so both code paths look
// identical from a behavioral standpoint (notification +
// master-toggle flip).
Log.i(TAG, "Timed Bridge capabilities expired after $minutes min of idle")
capabilityRepo.pruneExpired(connectionId, System.currentTimeMillis())
clearUnattendedIfControlEnded(connectionId)
AutoDisableWorker(appContext).run()
} catch (_: Throwable) {
// Cancellation is expected on reschedule — swallow quietly.
@@ -342,6 +455,48 @@ class BridgeSafetyManager(
_autoDisableAtMs.value = null
}
fun revokeTimedCapabilities() {
val connectionId = activeConnectionId.value ?: return
cancelAutoDisable()
scope.launch {
capabilityRepo.revokeTimed(connectionId)
prefsRepo.setUnattendedAccessEnabled(false)
}
}
private suspend fun schedulePersistedExpiry(connectionId: String?) {
autoDisableJob?.cancel()
val policy = capabilityRepo.snapshot(connectionId)
val nextExpiry = policy.timedExpiriesMs.values
.filter { it != BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS }
.maxOrNull()
if (nextExpiry == null) {
_autoDisableAtMs.value = null
return
}
if (nextExpiry <= System.currentTimeMillis()) {
capabilityRepo.pruneExpired(connectionId, System.currentTimeMillis())
clearUnattendedIfControlEnded(connectionId)
_autoDisableAtMs.value = null
return
}
_autoDisableAtMs.value = nextExpiry
autoDisableJob = (scope + SupervisorJob()).launch {
delay((nextExpiry - System.currentTimeMillis()).coerceAtLeast(0L))
capabilityRepo.pruneExpired(connectionId, System.currentTimeMillis())
clearUnattendedIfControlEnded(connectionId)
AutoDisableWorker(appContext).run()
if (activeConnectionId.value == connectionId) _autoDisableAtMs.value = null
}
}
private suspend fun clearUnattendedIfControlEnded(connectionId: String?) {
val policy = capabilityRepo.snapshot(connectionId)
if (!policy.allows(BridgeCapability.SCREEN_CONTROL, System.currentTimeMillis())) {
prefsRepo.setUnattendedAccessEnabled(false)
}
}
// ── Internals ────────────────────────────────────────────────────────
/**
@@ -242,11 +242,9 @@ object UnattendedAccessManager {
* returns [WakeOutcome.Success] / [SuccessNoKeyguardChange] /
* [KeyguardBlocked] depending on the dismiss attempt outcome.
*
* The wake lock auto-releases via the platform's 30s timeout — we
* don't release explicitly per call because the bridge command may
* take several gestures to complete and we want one continuous
* wake-up, not a stutter. [release] is provided for the master
* toggle off path.
* The caller must pair each successful acquire with [releaseAfterAction].
* The platform's 30s timeout remains a crash/stall backstop, not the normal
* lifetime. Nested or concurrent commands share the ref-counted lock.
*
* # Compatibility shim
*
@@ -300,6 +298,22 @@ object UnattendedAccessManager {
return requestDismiss()
}
/** Release one command's ownership without disturbing concurrent actions. */
fun releaseAfterAction() {
synchronized(countLock) {
if (lockCount <= 0) return
lockCount -= 1
if (lockCount == 0) {
val lock = wakeLock ?: return
try {
if (lock.isHeld) lock.release()
} catch (t: Throwable) {
Log.w(TAG, "wakeLock.release threw: ${t.message}")
}
}
}
}
/**
* Synchronous keyguard dismiss attempt. Returns:
* - [WakeOutcome.SuccessNoKeyguardChange] when there's no keyguard
@@ -0,0 +1,182 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.util.UUID
/** Connection-scoped Bridge authority. Missing, malformed, or future schemas deny all. */
class BridgeCapabilityPolicyRepository(private val context: Context) {
companion object {
private val KEY_POLICIES = stringPreferencesKey("bridge_capability_policies_v1")
}
@Serializable
private data class StoredPolicies(
val schemaVersion: Int = BridgeCapabilityPolicy.CURRENT_SCHEMA_VERSION,
val installId: String = "",
val byConnection: Map<String, BridgeCapabilityPolicy> = emptyMap(),
)
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val installId: String = localInstallId(context)
fun policy(connectionId: String?): Flow<BridgeCapabilityPolicy> =
context.relayDataStore.data.map { prefs ->
readPolicies(prefs[KEY_POLICIES])[connectionId.normalizedPolicyKey()]
?.takeIf { it.schemaVersion == BridgeCapabilityPolicy.CURRENT_SCHEMA_VERSION }
?: BridgeCapabilityPolicy()
}
suspend fun snapshot(connectionId: String?): BridgeCapabilityPolicy =
policy(connectionId).first()
suspend fun setPermanent(connectionId: String?, capability: BridgeCapability, allowed: Boolean) {
require(!capability.timed) { "Timed capabilities require an expiry" }
update(connectionId) { current ->
current.copy(
permanentGrants = if (allowed) {
current.permanentGrants + capability
} else {
current.permanentGrants - capability
},
)
}
}
suspend fun replacePermanent(
connectionId: String?,
capabilities: Set<BridgeCapability>,
) {
require(capabilities.none { it.timed }) { "Timed capabilities require an expiry" }
update(connectionId) { current -> current.copy(permanentGrants = capabilities) }
}
suspend fun grantTimed(
connectionId: String?,
capability: BridgeCapability,
expiresAtMs: Long,
nowMs: Long = System.currentTimeMillis(),
) {
require(capability.timed) { "Permanent capabilities do not accept an expiry" }
update(connectionId) { current ->
current.copy(
timedExpiriesMs = (
current.timedExpiriesMs.filterValues { it > nowMs }.keys + capability
)
.associateWith { expiresAtMs },
)
}
}
suspend fun revoke(connectionId: String?, capability: BridgeCapability) {
update(connectionId) { current ->
current.copy(
permanentGrants = current.permanentGrants - capability,
timedExpiriesMs = current.timedExpiriesMs - capability,
)
}
}
suspend fun revokeTimed(connectionId: String?) {
update(connectionId) { it.copy(timedExpiriesMs = emptyMap()) }
}
suspend fun replaceTimed(
connectionId: String?,
capabilities: Set<BridgeCapability>,
expiresAtMs: Long,
) {
require(capabilities.all { it.timed }) { "Permanent capabilities cannot be timed" }
update(connectionId) { current ->
current.copy(timedExpiriesMs = capabilities.associateWith { expiresAtMs })
}
}
suspend fun refreshActiveTimed(connectionId: String?, expiresAtMs: Long) {
update(connectionId) { current ->
current.copy(
timedExpiriesMs = current.timedExpiriesMs.mapNotNull { (capability, currentExpiry) ->
when {
currentExpiry == BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS ->
capability to currentExpiry
currentExpiry > System.currentTimeMillis() -> capability to expiresAtMs
else -> null
}
}.toMap(),
)
}
}
suspend fun pruneExpired(connectionId: String?, nowMs: Long) {
update(connectionId) { current ->
current.copy(timedExpiriesMs = current.timedExpiriesMs.filterValues { it > nowMs })
}
}
suspend fun clearConnection(connectionId: String) {
val key = connectionId.normalizedPolicyKey()
context.relayDataStore.edit { prefs ->
val current = readPolicies(prefs[KEY_POLICIES]).toMutableMap()
current.remove(key)
prefs[KEY_POLICIES] = json.encodeToString(
StoredPolicies(installId = installId, byConnection = current),
)
}
}
private suspend fun update(
connectionId: String?,
transform: (BridgeCapabilityPolicy) -> BridgeCapabilityPolicy,
) {
val key = connectionId.normalizedPolicyKey()
context.relayDataStore.edit { prefs ->
val current = readPolicies(prefs[KEY_POLICIES]).toMutableMap()
current[key] = transform(current[key] ?: BridgeCapabilityPolicy())
prefs[KEY_POLICIES] = json.encodeToString(
StoredPolicies(installId = installId, byConnection = current),
)
}
}
private fun readPolicies(raw: String?): Map<String, BridgeCapabilityPolicy> {
if (raw.isNullOrBlank()) return emptyMap()
val stored = runCatching { json.decodeFromString<StoredPolicies>(raw) }.getOrNull()
?: return emptyMap()
if (stored.schemaVersion != BridgeCapabilityPolicy.CURRENT_SCHEMA_VERSION ||
stored.installId != installId
) return emptyMap()
return stored.byConnection
}
private fun String?.normalizedPolicyKey(): String =
this?.trim()?.takeIf { it.isNotEmpty() } ?: "__unbound__"
private fun localInstallId(context: Context): String {
val file = File(context.noBackupFilesDir, "bridge-policy-install-id")
return runCatching {
if (file.isFile) {
file.readText().trim().takeIf { it.isNotEmpty() }
} else {
null
} ?: UUID.randomUUID().toString().also { id ->
file.parentFile?.mkdirs()
file.writeText(id)
}
}.getOrElse {
// An unavailable no-backup fence must never make restored grants
// usable. This process-only value causes every persisted read to
// mismatch and therefore deny.
"unavailable-${UUID.randomUUID()}"
}
}
}
@@ -70,7 +70,11 @@ data class BridgeSettings(
class BridgePreferencesRepository(private val context: Context) {
companion object {
private val KEY_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
// v2 is deliberately separate. Older APKs know only the legacy key
// and therefore remain disabled after a downgrade instead of treating
// the new granular grants as blanket authority.
private val KEY_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled_v2")
private val KEY_LEGACY_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
private val KEY_ACTIVITY_LOG = stringPreferencesKey("bridge_activity_log")
/** Hard cap on persisted entries. See file-level KDoc for rationale. */
@@ -99,7 +103,10 @@ class BridgePreferencesRepository(private val context: Context) {
}
suspend fun setMasterEnabled(enabled: Boolean) {
context.relayDataStore.edit { it[KEY_MASTER_ENABLED] = enabled }
context.relayDataStore.edit {
it[KEY_MASTER_ENABLED] = enabled
it[KEY_LEGACY_MASTER_ENABLED] = false
}
}
/**
@@ -29,10 +29,9 @@ import kotlinx.serialization.json.Json
* appear in `/tap_text` or `/type` payloads. Seeded with a set of verbs
* that carry irreversible or high-stakes consequences. Editable.
*
* - [autoDisableMinutes] — idle timeout after which the master toggle
* auto-flips to false. Rescheduled on every command so an active agent
* never triggers it; a runaway agent that stops sending commands for
* this long loses bridge access automatically.
* - [autoDisableMinutes] — idle timeout for timed screen inspection and
* control grants. Only accepted timed commands refresh it; permanent
* read/action grants neither expire nor keep screen authority alive.
*
* - [statusOverlayEnabled] — opt-in floating-dot indicator (like the
* screen-recording red dot) that's visible while bridge is active.
@@ -258,6 +258,7 @@ class BridgeCommandHandler(
private val pendingActivities =
java.util.concurrent.ConcurrentHashMap<String, PendingActivity>()
private val unattendedWakeRequests = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
// === END v0.4.1 polish ===
private val json = Json {
@@ -302,6 +303,8 @@ class BridgeCommandHandler(
put("error", t.message ?: "unknown executor error")
}
)
} finally {
releaseUnattendedWake(requestId)
}
}
}
@@ -396,6 +399,8 @@ class BridgeCommandHandler(
errorCode = "dispatch_exception",
resultJson = null,
)
} finally {
releaseUnattendedWake(requestId)
}
val resultJson = sink.get()
@@ -421,6 +426,54 @@ class BridgeCommandHandler(
method: String,
body: JsonObject,
) {
// Resolve path + method through the closed capability registry before
// any wake, confirmation, event read, executor, or run-tracker effect.
val registeredAuthority =
com.hermesandroid.relay.bridge.BridgeCommandRegistry.resolve(path, method)
val capabilityAuthorization = when {
registeredAuthority == null -> BridgeSafetyManager.CapabilityAuthorization(
allowed = false,
errorCode = "unknown_bridge_command",
)
!BuildFlavor.isSideload && registeredAuthority.grant !=
com.hermesandroid.relay.bridge.BridgeCapabilityGrant.EXEMPT ->
BridgeSafetyManager.CapabilityAuthorization(
allowed = false,
authority = registeredAuthority,
errorCode = "device_control_sideload_only",
)
registeredAuthority.grant ==
com.hermesandroid.relay.bridge.BridgeCapabilityGrant.EXEMPT ->
BridgeSafetyManager.CapabilityAuthorization(true, registeredAuthority)
else -> safetyManager?.authorizeCapability(path, method)
?: BridgeSafetyManager.CapabilityAuthorization(
allowed = false,
authority = registeredAuthority,
errorCode = "bridge_policy_unavailable",
)
}
if (!capabilityAuthorization.allowed) {
return respond(
requestId,
403,
buildJsonObject {
put(
"error",
if (capabilityAuthorization.errorCode == "device_control_sideload_only") {
"Device Control is not included in the Google Play build."
} else {
"Bridge capability is not granted for this connection."
},
)
put("error_code", capabilityAuthorization.errorCode ?: "bridge_capability_denied")
capabilityAuthorization.authority?.capability?.let {
put("capability", it.wireId)
}
put("required_action", "Review Bridge > Safety & capabilities on the phone")
},
)
}
// === v0.4.1 polish: keep auto-return idle timer alive ===
// Any non-polling bridge command during a run is evidence the
// agent is still working — reset BridgeRunTracker's idle timer
@@ -475,44 +528,6 @@ class BridgeCommandHandler(
return
}
// === PHASE3-event-stream: B1 android_events read-only polling ===
// /events is a read-only peek at the EventStore ring buffer. The
// buffer lives in our own process so there's no safety gate —
// the agent already opted into streaming via /events/stream
// which IS gated. This mirrors the /ping early-return path so
// polling works even when the service is transiently unbound.
if (path == "/events") {
val limitRaw = body["limit"]?.jsonPrimitive?.content?.toIntOrNull() ?: 50
val limit = limitRaw.coerceIn(1, EventStore.MAX_ENTRIES)
val since = body["since"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
val entries = EventStore.recent(limit = limit, since = since)
val arr: JsonArray = buildJsonArray {
for (e in entries) {
add(
buildJsonObject {
put("timestamp", e.timestamp)
put("event_type", e.eventType)
e.packageName?.let { put("package_name", it) }
e.className?.let { put("class_name", it) }
e.text?.let { put("text", it) }
e.contentDescription?.let { put("content_description", it) }
put("source", e.source)
}
)
}
}
respond(
requestId, 200,
buildJsonObject {
put("entries", arr)
put("count", entries.size)
put("streaming", EventStore.isStreaming)
}
)
return
}
// === END PHASE3-event-stream ===
// /setup exists on the relay as a legacy bridge HTTP route, but
// android_setup() in plugin/tools/android_tool.py is host-side
// only (it just writes ANDROID_BRIDGE_TOKEN to ~/.hermes/.env)
@@ -576,10 +591,7 @@ class BridgeCommandHandler(
}
)
if (!service.isMasterEnabled() &&
path != "/current_app" &&
path != "/return_to_hermes"
) {
if (!service.isMasterEnabled()) {
// Crystal-clear error text + structured error_code. Bailey hit
// 2026-04-15: when the phone was paired + a11y granted but
// master toggle flipped off, the agent read the shorter
@@ -649,9 +661,13 @@ class BridgeCommandHandler(
}
}
// Reschedule the idle auto-disable timer on every accepted
// command. Safe to call even when no timer is currently armed.
safetyManager?.rescheduleAutoDisable()
// Permanent capabilities never keep screen control armed. Only an
// accepted timed inspection/control command refreshes the timer.
if (capabilityAuthorization.authority?.grant ==
com.hermesandroid.relay.bridge.BridgeCapabilityGrant.TIMED
) {
safetyManager?.rescheduleAutoDisable()
}
// === END PHASE3-safety-rails ===
// === v0.4.1 unattended-access wake + keyguard dismiss ===
@@ -673,6 +689,9 @@ class BridgeCommandHandler(
if (!isReadOnlyRoute) {
val outcome = runCatching { UnattendedAccessManager.acquireForAction() }
.getOrDefault(UnattendedAccessManager.WakeOutcome.Disabled)
if (outcome != UnattendedAccessManager.WakeOutcome.Disabled) {
unattendedWakeRequests += requestId
}
if (outcome == UnattendedAccessManager.WakeOutcome.KeyguardBlocked) {
respond(
requestId, 423,
@@ -705,6 +724,30 @@ class BridgeCommandHandler(
val executor = service.actionExecutor
when (path) {
"/events" -> {
val limitRaw = body["limit"]?.jsonPrimitive?.content?.toIntOrNull() ?: 50
val limit = limitRaw.coerceIn(1, EventStore.MAX_ENTRIES)
val since = body["since"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
val entries = EventStore.recent(limit = limit, since = since)
val arr: JsonArray = buildJsonArray {
for (e in entries) {
add(buildJsonObject {
put("timestamp", e.timestamp)
put("event_type", e.eventType)
e.packageName?.let { put("package_name", it) }
e.className?.let { put("class_name", it) }
e.text?.let { put("text", it) }
e.contentDescription?.let { put("content_description", it) }
put("source", e.source)
})
}
}
respond(requestId, 200, buildJsonObject {
put("entries", arr)
put("count", entries.size)
put("streaming", EventStore.isStreaming)
})
}
"/current_app" -> respond(
requestId, 200,
buildJsonObject {
@@ -2417,6 +2460,12 @@ class BridgeCommandHandler(
}
multiplexer.send(envelope)
}
private fun releaseUnattendedWake(requestId: String) {
if (unattendedWakeRequests.remove(requestId)) {
UnattendedAccessManager.releaseAfterAction()
}
}
}
// LocalDispatchResult moved to network.shared (ADR 34 fence): it is a passive
@@ -3345,6 +3345,7 @@ class ChatHandler {
.filterNot { msg ->
msg.matchesIdentity(messageId) &&
msg.role == MessageRole.ASSISTANT &&
msg.badges.isEmpty() &&
msg.toolCalls.isEmpty() &&
msg.backgroundTask == null &&
msg.thinkingContent.isBlank() &&
@@ -703,7 +703,9 @@ class GatewayChatClient(
if (!turn.cancelled) {
turn.disarmWatchdog()
turn.tracer.done("resume-rejected")
turn.callbacks.onError(e.message ?: "Hermes could not resume this session")
turn.callbacks.onResumeFailure(
e.message ?: "Hermes could not resume this session",
)
}
} catch (e: GatewayAttachmentPreflightException) {
if (activeTurn === turn) activeTurn = null
@@ -2477,20 +2479,29 @@ class GatewayChatClient(
}
val model = info.stringField("model")?.takeIf { it.isNotBlank() }
val provider = info.stringField("provider")?.takeIf { it.isNotBlank() }
model?.let { _serverModel.value = it }
provider?.let { _serverProvider.value = it }
if (model != null && provider != null) {
_serverModelIdentity.value = GatewayModelIdentity(model = model, provider = provider)
if (info.containsKey("model")) {
// session.info/session.resume is an identity snapshot. An absent
// provider must clear the prior session's provider instead of
// making a resumed turn look coherently bound to stale state.
_serverModel.value = model
_serverProvider.value = provider
_serverModelIdentity.value = if (model != null && provider != null) {
GatewayModelIdentity(model = model, provider = provider)
} else {
null
}
}
// reasoning effort: ignore "" (reasoning disabled) so it can't clobber
// the chip; display mode is config.get-only, not here.
val reasoningEffort = info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
reasoningEffort?.let { _serverReasoningEffort.value = it }
if (model != null && provider != null && reasoningEffort != null) {
_serverReasoningIdentity.value = GatewayReasoningIdentity(
if (info.containsKey("model")) {
_serverReasoningIdentity.value = if (
model != null && provider != null && reasoningEffort != null
) GatewayReasoningIdentity(
identity = GatewayModelIdentity(model = model, provider = provider),
effort = reasoningEffort,
)
) else null
}
// credential_warning: present only when the provider key is missing/
// invalid. ABSENT means healthy — clear to null so it self-resolves.
@@ -2643,14 +2654,15 @@ class GatewayChatClient(
)
val result = resumed.getOrNull()
val resumeError = resumed.exceptionOrNull()
if ((resumeError as? GatewayRpcException)?.code == 4130) {
throw GatewayAuthoritativeResumeException(
resumeError.message ?: "Session transcript exceeds the configured resume limit",
)
}
val live = result?.stringField("session_id")
if (live != null) {
requireConfirmedSessionProfile(result, requestedProfile)
try {
requireConfirmedSessionProfile(result, requestedProfile)
} catch (error: GatewayPreflightException) {
throw GatewayAuthoritativeResumeException(
error.message ?: "Hermes resumed this session in a different profile",
)
}
liveSessionId = live
storedSessionId = requestedStoredId
liveSessionProfile = requestedProfile
@@ -2658,10 +2670,8 @@ class GatewayChatClient(
applySessionResultInfo(result)
return
}
Log.w(
TAG,
"session.resume failed for $requestedStoredId — creating fresh " +
"(${resumed.exceptionOrNull()?.message})",
throw GatewayAuthoritativeResumeException(
resumeError?.message ?: "Hermes could not resume this session",
)
}
@@ -3790,6 +3800,8 @@ class GatewayChatClient(
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
onResumeFailure = { v -> callbackDispatcher { callbacks.onResumeFailure(v) } },
onFailure = { v -> callbackDispatcher { callbacks.onFailure(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
@@ -260,6 +260,13 @@ class GatewayEventMapper(
}
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
if (failed) {
callbacks.onFailure(
GatewayTurnFailure(
error = error?.takeIf { it.isNotBlank() }
?: text.orEmpty().ifBlank { "Turn failed" },
recoverable = payload.boolean("recoverable") == true,
),
)
callbacks.onStatusUpdate(
ERROR_STATUS_KIND,
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
@@ -550,6 +550,12 @@ data class GatewaySessionModel(
val fast: Boolean? = null,
)
/** Structured terminal failure carried by Gateway `message.complete`. */
data class GatewayTurnFailure(
val error: String,
val recoverable: Boolean,
)
/** Result of the gateway `config.get {key:"reasoning"}` RPC. */
data class GatewayReasoningSettings(
val effort: String,
@@ -618,6 +624,10 @@ class GatewayTurnCallbacks(
val onInteractionRequest: (GatewayAsk) -> Unit,
/** Server declared a pending interaction expired; clear only the matching card. */
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
/** Existing durable session could not be rebound; no prompt was submitted. */
val onResumeFailure: (String) -> Unit = { _ -> },
/** Terminal `message.complete {status:"error"}` without prose inspection. */
val onFailure: (GatewayTurnFailure) -> Unit = { _ -> },
/**
* Gateway `status.update` lifecycle line — model fallback, retries, and
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
@@ -142,8 +142,8 @@ internal class HermesRuntimeBinder(
voiceHandoffReporter = connection::recordVoiceHandoff,
bargeInPreferences = BargeInPreferencesRepository(application),
vadEngineFactory = { VadEngine(application) },
bargeInListenerFactory = { vad, audioSessionIdProvider ->
BargeInListener.create(application, vad, audioSessionIdProvider)
bargeInListenerFactory = { vad ->
BargeInListener.create(application, vad)
},
)
@@ -1387,12 +1387,22 @@ fun RelayApp() {
}
val masterEnabled by masterEnabledFlow.collectAsState(initial = false)
val unattendedEnabled by unattendedEnabledFlow.collectAsState(initial = false)
val activeBridgePolicy by (connectionViewModel.bridgeSafety?.activeCapabilityPolicy
?: remember { kotlinx.coroutines.flow.MutableStateFlow(
com.hermesandroid.relay.bridge.BridgeCapabilityPolicy(),
) })
.collectAsState()
val timedScreenControlActive = activeBridgePolicy.allows(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
System.currentTimeMillis(),
)
// Sideload-only: googlePlay has no wake lock and the unattended
// flag never gets written there — gating here is defence in depth
// and makes the check cheap via R8 in release builds.
val showUnattendedBanner = BuildFlavor.isSideload &&
masterEnabled &&
unattendedEnabled &&
timedScreenControlActive &&
!suppressGlobalChrome &&
!showStartupSphere &&
!voiceUiState.voiceMode
@@ -1628,7 +1638,7 @@ fun RelayApp() {
?: AgentDisplay.displayModelName(serverModelName)
?: stringResource(R.string.status_model_pending)
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
if (unattendedEnabled) stringResource(R.string.status_safety_unattended)
if (unattendedEnabled && timedScreenControlActive) stringResource(R.string.status_safety_unattended)
else stringResource(R.string.status_safety_on)
} else {
stringResource(R.string.status_profile_format, profileLabel)
@@ -2316,6 +2326,7 @@ fun RelayApp() {
composable(Screen.BridgeSafetySettings.route) {
if (BuildFlavor.isSideload) {
BridgeSafetySettingsScreen(
connectionId = activeConnectionId,
onBack = { navController.popBackStack() }
)
} else {
@@ -0,0 +1,632 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
@Composable
fun BridgeAgentAccessCard(
policy: BridgeCapabilityPolicy,
nowMs: Long,
onSetUp: () -> Unit,
onManage: () -> Unit,
onAllowScreen: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasGrant = policy.hasAnyGrant(nowMs)
val preset = policy.displayPreset()
val timed = policy.activeTimedCapabilities(nowMs)
val screenUnlimited = timed.any(policy::isUnlimited)
val nextExpiry = policy.timedExpiriesMs.filterValues {
it > nowMs && it != BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
}.values.maxOrNull()
val screenActive = timed.isNotEmpty()
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.bridge_access_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp).weight(1f),
)
AccessStatePill(
text = when (preset) {
BridgeAccessPreset.READ_ONLY -> stringResource(R.string.bridge_access_preset_read_only)
BridgeAccessPreset.READ_CONFIRMED -> stringResource(R.string.bridge_access_preset_confirmed_short)
BridgeAccessPreset.CUSTOM -> stringResource(R.string.bridge_access_preset_custom)
null -> stringResource(R.string.bridge_access_not_set_up)
},
)
}
Text(
text = stringResource(R.string.bridge_access_summary_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!hasGrant) {
Button(onClick = onSetUp, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.bridge_access_set_up))
}
} else {
AccessSummaryRow(
title = stringResource(R.string.bridge_access_always),
subtitle = stringResource(
R.string.bridge_access_enabled_count,
policy.permanentGrants.size,
BridgeCapability.entries.count { !it.timed },
),
trailing = policy.permanentGrants.size.toString(),
onClick = onManage,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
AccessSummaryRow(
title = stringResource(R.string.bridge_access_screen),
subtitle = if (screenActive) {
if (screenUnlimited) {
stringResource(R.string.bridge_access_screen_unlimited)
} else {
stringResource(R.string.bridge_access_screen_active)
}
} else {
stringResource(R.string.bridge_access_screen_off)
},
trailing = if (screenUnlimited) {
stringResource(R.string.bridge_access_until_off_short)
} else {
nextExpiry?.let { formatRemaining(it - nowMs) }
?: stringResource(R.string.bridge_access_allow_duration)
},
onClick = onAllowScreen,
)
}
}
}
}
@Composable
fun BridgeAndroidAccessSummaryCard(
summary: BridgeAndroidAccessSummary,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onToggle),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = if (summary.allReady) Icons.Filled.CheckCircle else Icons.Filled.Security,
contentDescription = null,
tint = if (summary.allReady) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.bridge_android_access_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = when {
summary.required.isEmpty() -> stringResource(R.string.bridge_android_access_none)
summary.allReady -> stringResource(R.string.bridge_android_access_ready)
else -> stringResource(
R.string.bridge_android_access_missing,
summary.ready.size,
summary.required.size,
summary.missing.size,
)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = if (expanded) {
stringResource(R.string.bridge_android_access_hide)
} else {
stringResource(R.string.bridge_android_access_review)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
}
@Composable
fun BridgeSelectedAndroidAccessCard(
summary: BridgeAndroidAccessSummary,
onOpenAccessibility: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenOverlay: () -> Unit,
modifier: Modifier = Modifier,
) {
if (summary.missing.isEmpty()) return
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
stringResource(R.string.bridge_android_selected_needs),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
stringResource(R.string.bridge_android_selected_needs_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
summary.missing.forEach { requirement ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(requirement.labelResource()),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = when (requirement) {
BridgeAndroidRequirement.ACCESSIBILITY -> onOpenAccessibility
BridgeAndroidRequirement.OVERLAY -> onOpenOverlay
else -> onOpenAppSettings
},
) {
Text(stringResource(R.string.bridge_android_open_settings))
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BridgeAccessSetupSheet(
selected: BridgeAccessPreset,
onSelected: (BridgeAccessPreset) -> Unit,
onDismiss: () -> Unit,
onContinue: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(600.dp)
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.bridge_access_choose_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.bridge_access_choose_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PresetChoice(
title = stringResource(R.string.bridge_access_preset_read_only),
description = stringResource(R.string.bridge_access_preset_read_only_desc),
selected = selected == BridgeAccessPreset.READ_ONLY,
recommended = true,
onClick = { onSelected(BridgeAccessPreset.READ_ONLY) },
)
PresetChoice(
title = stringResource(R.string.bridge_access_preset_confirmed),
description = stringResource(R.string.bridge_access_preset_confirmed_desc),
selected = selected == BridgeAccessPreset.READ_CONFIRMED,
onClick = { onSelected(BridgeAccessPreset.READ_CONFIRMED) },
)
PresetChoice(
title = stringResource(R.string.bridge_access_preset_custom),
description = stringResource(R.string.bridge_access_preset_custom_desc),
selected = selected == BridgeAccessPreset.CUSTOM,
onClick = { onSelected(BridgeAccessPreset.CUSTOM) },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.bridge_cancel)) }
Button(onClick = onContinue) { Text(stringResource(R.string.bridge_access_continue)) }
}
Spacer(Modifier.size(4.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BridgeTimedAccessSheet(
inspectEnabled: Boolean,
controlEnabled: Boolean,
durationMinutes: Int,
unlimited: Boolean,
accessibilityReady: Boolean,
overlayReady: Boolean,
currentlyActive: Boolean,
onInspectChanged: (Boolean) -> Unit,
onControlChanged: (Boolean) -> Unit,
onDurationChanged: (Int) -> Unit,
onUnlimitedChanged: (Boolean) -> Unit,
onOpenAccessibility: () -> Unit,
onOpenOverlay: () -> Unit,
onDismiss: () -> Unit,
onAllow: () -> Unit,
onEndNow: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(740.dp)
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.bridge_timed_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.bridge_timed_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.bridge_timed_lifetime_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(5, 30, 120).forEach { minutes ->
FilterChip(
selected = !unlimited && durationMinutes == minutes,
onClick = {
onUnlimitedChanged(false)
onDurationChanged(minutes)
},
label = { Text(formatIdleDuration(minutes)) },
)
}
}
FilterChip(
selected = unlimited,
onClick = { onUnlimitedChanged(true) },
label = { Text(stringResource(R.string.bridge_timed_until_off)) },
)
Text(
text = if (unlimited) {
stringResource(R.string.bridge_timed_unlimited_warning)
} else {
stringResource(R.string.bridge_timed_idle_explainer, formatDuration(durationMinutes))
},
style = MaterialTheme.typography.bodySmall,
color = if (unlimited) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Text(
stringResource(R.string.bridge_timed_scope_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
TimedChoice(
title = stringResource(R.string.bss_capability_screen_inspection),
description = stringResource(R.string.bridge_timed_inspection_desc),
checked = inspectEnabled,
onCheckedChange = onInspectChanged,
)
TimedChoice(
title = stringResource(R.string.bss_capability_screen_control),
description = stringResource(R.string.bridge_timed_control_desc),
checked = controlEnabled,
onCheckedChange = onControlChanged,
)
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
stringResource(R.string.bridge_timed_prerequisites),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
PrerequisiteRow(
stringResource(R.string.bpc_accessibility),
accessibilityReady,
onOpenAccessibility,
)
if (controlEnabled) {
PrerequisiteRow(
stringResource(R.string.bpc_overlay),
overlayReady,
onOpenOverlay,
)
}
Text(
stringResource(R.string.bridge_timed_capture_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (currentlyActive) {
TextButton(onClick = onEndNow) {
Text(stringResource(R.string.bridge_timed_end_now))
}
} else {
Spacer(Modifier.size(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.bridge_cancel)) }
Button(
onClick = onAllow,
enabled = (inspectEnabled || controlEnabled) &&
accessibilityReady && (!controlEnabled || overlayReady),
) {
Text(stringResource(R.string.bridge_timed_allow))
}
}
}
Spacer(Modifier.size(4.dp))
}
Spacer(Modifier.size(12.dp))
}
}
}
@Composable
private fun AccessSummaryRow(
title: String,
subtitle: String,
trailing: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(trailing, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
@Composable
private fun PresetChoice(
title: String,
description: String,
selected: Boolean,
recommended: Boolean = false,
onClick: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
RadioButton(selected = selected, onClick = null)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (recommended) {
Text(
stringResource(R.string.bridge_access_recommended),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
@Composable
private fun TimedChoice(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun PrerequisiteRow(label: String, ready: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (ready) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
Text(label, modifier = Modifier.padding(start = 8.dp).weight(1f))
Text(
if (ready) stringResource(R.string.bridge_android_ready_short)
else stringResource(R.string.bridge_android_missing_short),
style = MaterialTheme.typography.labelLarge,
color = if (ready) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = stringResource(R.string.bridge_android_open_settings),
modifier = Modifier.padding(start = 4.dp),
)
}
}
@Composable
private fun AccessStatePill(text: String) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
private fun formatRemaining(remainingMs: Long): String {
val totalSeconds = (remainingMs.coerceAtLeast(0L) / 1_000L).toInt()
return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
}
private fun formatDuration(minutes: Int): String =
if (minutes == 120) "2 hr" else "$minutes min"
private fun formatIdleDuration(minutes: Int): String =
if (minutes == 120) "2 hr idle" else "$minutes min idle"
private fun BridgeAndroidRequirement.labelResource(): Int = when (this) {
BridgeAndroidRequirement.ACCESSIBILITY -> R.string.bpc_accessibility
BridgeAndroidRequirement.CONTACTS -> R.string.bpc_contacts
BridgeAndroidRequirement.LOCATION -> R.string.bpc_location
BridgeAndroidRequirement.SMS -> R.string.bpc_sms
BridgeAndroidRequirement.PHONE -> R.string.bpc_phone
BridgeAndroidRequirement.OVERLAY -> R.string.bpc_overlay
}
@@ -0,0 +1,97 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
enum class BridgeAccessPreset {
READ_ONLY,
READ_CONFIRMED,
CUSTOM,
}
val READ_ONLY_BRIDGE_CAPABILITIES: Set<BridgeCapability> = setOf(
BridgeCapability.DEVICE_INFO,
BridgeCapability.CONTACTS_READ,
BridgeCapability.LOCATION_READ,
BridgeCapability.CLIPBOARD_READ,
)
val READ_CONFIRMED_BRIDGE_CAPABILITIES: Set<BridgeCapability> =
READ_ONLY_BRIDGE_CAPABILITIES + setOf(
BridgeCapability.COMMUNICATIONS,
BridgeCapability.OUTBOUND_SHARING,
)
enum class BridgeAndroidRequirement {
ACCESSIBILITY,
CONTACTS,
LOCATION,
SMS,
PHONE,
OVERLAY,
}
data class BridgeAndroidAccessSummary(
val required: Set<BridgeAndroidRequirement>,
val ready: Set<BridgeAndroidRequirement>,
) {
val missing: Set<BridgeAndroidRequirement> get() = required - ready
val allReady: Boolean get() = missing.isEmpty()
}
fun BridgeCapabilityPolicy.hasAnyGrant(nowMs: Long): Boolean =
permanentGrants.isNotEmpty() || timedExpiriesMs.any { (capability, expiry) ->
capability.timed && expiry > nowMs
}
fun BridgeCapabilityPolicy.activeTimedCapabilities(nowMs: Long): Set<BridgeCapability> =
timedExpiriesMs.filterValues { it > nowMs }.keys
fun BridgeCapabilityPolicy.displayPreset(): BridgeAccessPreset? = when (permanentGrants) {
READ_ONLY_BRIDGE_CAPABILITIES -> BridgeAccessPreset.READ_ONLY
READ_CONFIRMED_BRIDGE_CAPABILITIES -> BridgeAccessPreset.READ_CONFIRMED
else -> if (permanentGrants.isEmpty()) null else BridgeAccessPreset.CUSTOM
}
fun bridgeAndroidAccessSummary(
policy: BridgeCapabilityPolicy,
status: BridgePermissionStatus,
nowMs: Long,
): BridgeAndroidAccessSummary {
val timed = policy.activeTimedCapabilities(nowMs)
val required = buildSet {
// Current BridgeCommandHandler is service-owned even for passive
// commands. Keep this visible until non-screen executors are split.
if (policy.permanentGrants.isNotEmpty() || timed.isNotEmpty()) {
add(BridgeAndroidRequirement.ACCESSIBILITY)
}
if (BridgeCapability.CONTACTS_READ in policy.permanentGrants) {
add(BridgeAndroidRequirement.CONTACTS)
}
if (BridgeCapability.LOCATION_READ in policy.permanentGrants) {
add(BridgeAndroidRequirement.LOCATION)
}
if (BridgeCapability.COMMUNICATIONS in policy.permanentGrants) {
add(BridgeAndroidRequirement.SMS)
add(BridgeAndroidRequirement.PHONE)
}
if (BridgeCapability.SCREEN_CONTROL in timed ||
BridgeCapability.COMMUNICATIONS in policy.permanentGrants ||
BridgeCapability.OUTBOUND_SHARING in policy.permanentGrants
) {
add(BridgeAndroidRequirement.OVERLAY)
}
}
val ready = required.filterTo(linkedSetOf()) { requirement ->
when (requirement) {
BridgeAndroidRequirement.ACCESSIBILITY -> status.accessibilityServiceEnabled
BridgeAndroidRequirement.CONTACTS -> status.contactsPermitted
BridgeAndroidRequirement.LOCATION -> status.locationPermitted
BridgeAndroidRequirement.SMS -> status.smsPermitted
BridgeAndroidRequirement.PHONE -> status.phonePermitted
BridgeAndroidRequirement.OVERLAY -> status.overlayPermitted
}
}
return BridgeAndroidAccessSummary(required = required, ready = ready)
}
@@ -40,8 +40,8 @@ import com.hermesandroid.relay.data.DEFAULT_DESTRUCTIVE_VERBS
*
* - Blocklist count ("12 apps blocked")
* - Destructive-verb count ("12 verbs need confirmation")
* - Auto-disable window ("Auto-off after 30 min idle")
* - Auto-disable countdown when a timer is active
* - Timed screen-access window
* - Timed screen-access countdown when active
*
* Tap → navigate to [BridgeSafetySettingsScreen].
*
@@ -53,6 +53,8 @@ import com.hermesandroid.relay.data.DEFAULT_DESTRUCTIVE_VERBS
fun BridgeSafetySummaryCard(
settings: BridgeSafetySettings,
autoDisableAtMs: Long? = null,
screenAccessActive: Boolean = false,
screenAccessUnlimited: Boolean = false,
onManage: () -> Unit,
) {
// Tick a local clock every second when a countdown is active so the
@@ -114,14 +116,18 @@ fun BridgeSafetySummaryCard(
value = "${settings.destructiveVerbs.size}",
)
SafetySummaryRow(
label = stringResource(R.string.bssc_auto_disable),
value = if (autoDisableAtMs != null) {
label = stringResource(R.string.bridge_access_screen),
value = if (screenAccessUnlimited) {
stringResource(R.string.bridge_access_until_off_short)
} else if (!screenAccessActive) {
stringResource(R.string.bmt_off)
} else if (autoDisableAtMs != null) {
val remainMs = (autoDisableAtMs - nowMs).coerceAtLeast(0L)
val remainMin = (remainMs / 60_000L).toInt()
val remainSec = ((remainMs % 60_000L) / 1000L).toInt()
"in ${remainMin}:${remainSec.toString().padStart(2, '0')}"
} else {
"${settings.autoDisableMinutes} min"
stringResource(R.string.bridge_access_screen_active)
},
)
@@ -183,6 +189,7 @@ private fun BridgeSafetySummaryCardPreview_Countdown() {
destructiveVerbs = DEFAULT_DESTRUCTIVE_VERBS,
),
autoDisableAtMs = System.currentTimeMillis() + 12 * 60_000L + 34_000L,
screenAccessActive = true,
onManage = {},
)
}
@@ -0,0 +1,147 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.ChatFailureNotice
@Composable
fun ChatFailurePanel(
failure: ChatFailureNotice,
routeLabel: String,
onDetails: () -> Unit,
onRetry: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 8.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.Top) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(top = 2.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.chat_failure_title),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
failureIdentity(routeLabel, failure.model, failure.provider)
.takeIf { it.isNotBlank() }
?.let { identity ->
Text(
text = identity,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.78f),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
}
if (failure.recoverable) {
TextButton(onClick = onRetry) {
Text(stringResource(R.string.chat_retry))
}
}
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.chat_dismiss))
}
}
}
}
}
@Composable
fun ChatFailureDetailsDialog(
failure: ChatFailureNotice,
routeLabel: String,
onCopy: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_failure_details_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
failureIdentity(routeLabel, failure.model, failure.provider)
.takeIf { it.isNotBlank() }
?.let { identity ->
Text(text = identity, style = MaterialTheme.typography.labelLarge)
}
Text(
text = stringResource(R.string.chat_failure_details_guidance),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
) {
SelectionContainer {
Text(
text = failure.rawError,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onCopy) {
Text(stringResource(R.string.chat_failure_copy_details))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.common_close))
}
},
)
}
internal fun failureIdentity(route: String, model: String?, provider: String?): String =
listOfNotNull(
route.takeIf { it.isNotBlank() },
provider?.trim()?.takeIf { it.isNotEmpty() },
model?.trim()?.takeIf { it.isNotEmpty() },
).distinct().joinToString(" · ")
@@ -64,6 +64,7 @@ import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
@@ -197,6 +198,13 @@ fun ChatInputBar(
ChatInputTrailing.QUEUE,
)
// Enter only means "send" when a physical keyboard is attached (see
// the key handler below). Read the configuration here, in the composable
// scope, and capture it for the non-composable onPreviewKeyEvent lambda.
val keyboardAttached =
LocalConfiguration.current.keyboard !=
android.content.res.Configuration.KEYBOARD_NOKEYS
// Keep the last caption around so the AnimatedVisibility exit doesn't
// flash an empty line while collapsing.
var lastCaption by remember { mutableStateOf<String?>(null) }
@@ -375,10 +383,18 @@ fun ChatInputBar(
val native = event.nativeKeyEvent
val isEnter = native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
// Enter only means "send" when a physical keyboard is
// attached. IME-dispatched Enter (commitText or a
// synthesized KEYCODE_ENTER) must always fall through
// so the soft keyboard's return key inserts a newline
// instead of sending (issue #367). Key events alone
// cannot distinguish physical vs IME origin — deviceId
// is 0 or -1 depending on the IME — so gate on the
// hardware keyboard configuration (read above).
val isSubmitShortcut = native.isCtrlPressed || native.isMetaPressed
if (native.action != android.view.KeyEvent.ACTION_DOWN || !isEnter) {
false
} else if (isSubmitShortcut || (physicalEnterSends && !native.isShiftPressed)) {
} else if (isSubmitShortcut || (keyboardAttached && physicalEnterSends && !native.isShiftPressed)) {
if (canSubmit) onSend()
true
} else {
@@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import kotlinx.coroutines.delay
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
@@ -55,9 +54,6 @@ private const val SPHERE_TIME_UNITS_PER_SEC = 1f
private const val SPHERE_TWO_PI = 6.2832f
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
// Idle cadence: this delay plus the next frame wait nets a ~33ms period (~30fps).
private const val SPHERE_IDLE_FRAME_INTERVAL_MS = 25L
@Composable
fun MorphingSphere(
modifier: Modifier = Modifier,
@@ -108,19 +104,14 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// Continuous motion is driven by a manual frame loop rather than
// rememberInfiniteTransition so the redraw rate can follow the orb's
// activity. An infinite transition pins the Canvas at the display refresh
// (120Hz) forever — even when Idle — which needlessly drains battery and,
// on Android 15, makes the platform log `setRequestedFrameRate` on every
// frame. Here we advance every frame while ACTIVE (full-smoothness
// thinking/streaming/voice pulse) and throttle to ~30fps while Idle, where
// the slower cadence is imperceptible for the chunky ASCII glyphs.
// dt-based accumulation keeps the animation speed identical at either rate.
// Continuous motion runs only for active agent/voice states. Idle is a
// stable frame: the 58x34 text grid is expensive enough that even a
// throttled cosmetic drift dominated measured screen-on CPU. Active states
// retain full display-rate motion and dt-based timing.
val animatedTime = remember { mutableFloatStateOf(0f) }
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
val driveAnimation = fixedTime == null || fixedColorPhase == null
val fullFrameRate = state != SphereState.Idle || effVoiceMode
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
if (driveAnimation) {
LaunchedEffect(fullFrameRate) {
var lastNanos = withFrameNanos { it }
@@ -133,7 +124,6 @@ fun MorphingSphere(
animatedColorPhase.floatValue =
(animatedColorPhase.floatValue + dtSec * SPHERE_COLOR_RADIANS_PER_SEC) %
SPHERE_TWO_PI
if (!fullFrameRate) delay(SPHERE_IDLE_FRAME_INTERVAL_MS)
}
}
}
@@ -146,6 +136,7 @@ fun MorphingSphere(
// Cache covers the ~25 distinct glyphs across charSets/dataChars/debrisChars.
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
val glyphStrings = remember { HashMap<Char, String>(32) }
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
val canvasW = size.width
@@ -176,7 +167,8 @@ fun MorphingSphere(
)
forEachSphereCell(frame) { cell ->
val layout = textMeasurer.measure(cell.char.toString(), style)
val glyph = glyphStrings.getOrPut(cell.char) { cell.char.toString() }
val layout = textMeasurer.measure(glyph, style)
// Legacy Paint used y as baseline (`row*cellH + cellH*0.8f`).
// Compose `drawText` uses top-left — offset by firstBaseline to match.
val px = cell.col * cellW
@@ -737,7 +737,7 @@ fun SessionDrawerContent(
actionsEnabled = !provisional,
isActive = !showAllProfiles && session.sessionId == currentSessionId,
activityState = activityState,
animationEnabled = animationEnabled,
animationEnabled = animationEnabled && isOpen,
pinned = session.pinned,
archived = session.archived,
archiveSupported = archiveSupported,
@@ -70,6 +70,8 @@ fun UnattendedAccessRow(
// should reflect that reality — otherwise users flip it and see no
// observable change, which reads as a broken control.
masterEnabled: Boolean = true,
screenControlAvailable: Boolean = true,
screenAccessUnlimited: Boolean = false,
) {
var showWarning by remember { mutableStateOf(false) }
var pendingEnableAfterWarning by remember { mutableStateOf(false) }
@@ -77,7 +79,7 @@ fun UnattendedAccessRow(
// "Effectively on" — the persisted preference AND the master gate.
// Drives the keyguard warning (no point showing it when master is
// off — the feature isn't active regardless of lock state).
val effectivelyOn = enabled && masterEnabled
val effectivelyOn = enabled && masterEnabled && screenControlAvailable
Card(
modifier = modifier.fillMaxWidth(),
@@ -104,6 +106,7 @@ fun UnattendedAccessRow(
Text(
text = when {
!masterEnabled -> stringResource(R.string.unattended_requires_master)
!screenControlAvailable -> stringResource(R.string.unattended_requires_timed_control)
enabled -> stringResource(R.string.unattended_on)
else -> stringResource(R.string.unattended_off)
},
@@ -113,7 +116,7 @@ fun UnattendedAccessRow(
}
Switch(
checked = enabled,
enabled = masterEnabled,
enabled = masterEnabled && screenControlAvailable,
onCheckedChange = { wantsOn ->
if (wantsOn && !warningSeen) {
// First enable → show the scary dialog and
@@ -129,7 +132,13 @@ fun UnattendedAccessRow(
}
Text(
text = stringResource(R.string.unattended_description),
text = stringResource(
if (screenAccessUnlimited) {
R.string.unattended_description_unlimited
} else {
R.string.unattended_description
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -190,17 +190,23 @@ fun VoiceWaveform(
// Three phase accumulators driven by a single per-frame ticker. Phase
// velocity scales with the current amplitude so the wave visibly surges
// when the user speaks instead of running on its own fixed clock.
val phases = rememberAmplitudeDrivenPhases(phaseDurationsMs, displayAmplitude)
val waitingForOutputAudio = state == VoiceState.Speaking && !outputAudioActive
val processing = state == VoiceState.Transcribing ||
state == VoiceState.Thinking ||
waitingForOutputAudio
val waveformMotionActive = compactBars || state == VoiceState.Listening ||
(state == VoiceState.Speaking && outputAudioActive)
val phases = rememberAmplitudeDrivenPhases(
phaseDurationsMs,
displayAmplitude,
active = waveformMotionActive,
)
val waveformUnfold by animateFloatAsState(
targetValue = if (processing) 0f else 1f,
animationSpec = tween(durationMillis = 360),
label = "waveformUnfold",
)
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
val spinnerPhase = rememberProcessingSpinnerPhase(active = processing)
val canvasModifier = if (compactBars) {
modifier.height(height)
@@ -361,10 +367,12 @@ fun VoiceWaveform(
private fun rememberAmplitudeDrivenPhases(
baseDurationsMs: IntArray,
amplitude: Float,
active: Boolean,
): FloatArray {
val ampRef = rememberUpdatedState(amplitude)
var phases by remember { mutableStateOf(FloatArray(baseDurationsMs.size)) }
LaunchedEffect(Unit) {
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
val twoPi = (2f * PI).toFloat()
// Precompute base angular velocities (rad/s) so we don't divide on
// every frame. Each wave's full cycle at silence = durationMs.
@@ -400,9 +408,9 @@ private fun rememberAmplitudeDrivenPhases(
@Composable
private fun rememberProcessingSpinnerPhase(active: Boolean): Float {
val activeRef = rememberUpdatedState(active)
var phase by remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
var prevNanos = 0L
while (true) {
withFrameNanos { nanos ->
@@ -412,9 +420,7 @@ private fun rememberProcessingSpinnerPhase(active: Boolean): Float {
}
val dtSec = (nanos - prevNanos) / 1_000_000_000f
prevNanos = nanos
if (activeRef.value) {
phase = (phase + dtSec * 220f) % 360f
}
phase = (phase + dtSec * 220f) % 360f
}
}
}
@@ -61,6 +61,9 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetySettings
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.bridge.BridgeSafetyManager
import com.hermesandroid.relay.data.DEFAULT_BLOCKLIST
import com.hermesandroid.relay.data.MAX_AUTO_DISABLE_MINUTES
import com.hermesandroid.relay.data.MAX_CONFIRMATION_TIMEOUT_SECONDS
@@ -85,11 +88,18 @@ import kotlinx.coroutines.launch
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
fun BridgeSafetySettingsScreen(
connectionId: String? = null,
onBack: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val repo = remember { BridgeSafetyPreferencesRepository(context) }
val settings by repo.settings.collectAsState(initial = BridgeSafetySettings())
val safetyManager = BridgeSafetyManager.peek()
val capabilityPolicy by (safetyManager?.capabilityPolicy(connectionId)
?: remember { kotlinx.coroutines.flow.MutableStateFlow(BridgeCapabilityPolicy()) })
.collectAsState(initial = BridgeCapabilityPolicy())
// Overlay-permission live check — recompute on resume so returning
// from Settings flips the switch's availability without nav churn.
@@ -140,6 +150,22 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CapabilityGrantCards(
policy = capabilityPolicy,
timerMinutes = settings.autoDisableMinutes,
enabled = safetyManager != null && connectionId != null,
onPermanentChanged = { capability, allowed ->
scope.launch {
safetyManager?.setPermanentCapability(connectionId, capability, allowed)
}
},
onTimedChanged = { capability, allowed ->
scope.launch {
safetyManager?.setTimedCapability(connectionId, capability, allowed)
}
},
)
// ── Blocklist ───────────────────────────────────────────────
SectionCard(title = stringResource(R.string.bss_blocked_apps)) {
Text(
@@ -359,6 +385,152 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
}
}
@Composable
internal fun CapabilityGrantCards(
policy: BridgeCapabilityPolicy,
timerMinutes: Int,
enabled: Boolean,
onPermanentChanged: (BridgeCapability, Boolean) -> Unit,
onTimedChanged: (BridgeCapability, Boolean) -> Unit,
) {
SectionCard(title = stringResource(R.string.bridge_access_read_group)) {
Text(
text = stringResource(R.string.bridge_access_read_group_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!enabled) {
Text(
text = stringResource(R.string.bss_capabilities_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
Spacer(Modifier.size(8.dp))
listOf(
BridgeCapability.DEVICE_INFO,
BridgeCapability.CONTACTS_READ,
BridgeCapability.LOCATION_READ,
BridgeCapability.CLIPBOARD_READ,
).forEach { capability ->
val allowed = capability in policy.permanentGrants
CapabilityRow(
capability = capability,
checked = allowed,
stateLabel = stringResource(
if (allowed) R.string.bss_capability_always else R.string.bss_capability_never,
),
enabled = enabled,
onCheckedChange = { onPermanentChanged(capability, it) },
)
}
}
SectionCard(title = stringResource(R.string.bridge_access_actions_group)) {
Text(
text = stringResource(R.string.bridge_access_actions_group_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
listOf(
BridgeCapability.CLIPBOARD_WRITE,
BridgeCapability.MEDIA_CONTROL,
BridgeCapability.COMMUNICATIONS,
BridgeCapability.OUTBOUND_SHARING,
).forEach { capability ->
val allowed = capability in policy.permanentGrants
CapabilityRow(
capability = capability,
checked = allowed,
stateLabel = stringResource(
if (allowed) R.string.bss_capability_always else R.string.bss_capability_never,
),
enabled = enabled,
onCheckedChange = { onPermanentChanged(capability, it) },
)
}
}
SectionCard(title = stringResource(R.string.bss_timed_capabilities_title)) {
val now = System.currentTimeMillis()
val activeScreenCapabilities = BridgeCapability.entries
.filter { it.timed && policy.allows(it, now) }
val hasUnlimited = activeScreenCapabilities.any(policy::isUnlimited)
Text(
text = when {
hasUnlimited -> stringResource(R.string.bss_screen_access_unlimited_desc)
activeScreenCapabilities.isNotEmpty() ->
stringResource(R.string.bss_timed_capabilities_desc, timerMinutes)
else -> stringResource(R.string.bss_screen_access_off_desc, timerMinutes)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
BridgeCapability.entries.filter { it.timed }.forEach { capability ->
val active = (policy.expiryFor(capability) ?: 0L) > now
CapabilityRow(
capability = capability,
checked = active,
stateLabel = when {
policy.isUnlimited(capability) ->
stringResource(R.string.bridge_timed_until_off)
active -> stringResource(R.string.bss_capability_timed_on)
else -> stringResource(R.string.bss_capability_timed_off)
},
enabled = enabled,
onCheckedChange = { onTimedChanged(capability, it) },
)
}
}
}
@Composable
private fun CapabilityRow(
capability: BridgeCapability,
checked: Boolean,
stateLabel: String,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(vertical = 6.dp)) {
Text(
text = stringResource(capability.titleResource()),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stateLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
)
}
}
private fun BridgeCapability.titleResource(): Int = when (this) {
BridgeCapability.DEVICE_INFO -> R.string.bss_capability_device_info
BridgeCapability.CONTACTS_READ -> R.string.bss_capability_contacts
BridgeCapability.LOCATION_READ -> R.string.bss_capability_location
BridgeCapability.CLIPBOARD_READ -> R.string.bss_capability_clipboard_read
BridgeCapability.CLIPBOARD_WRITE -> R.string.bss_capability_clipboard_write
BridgeCapability.MEDIA_CONTROL -> R.string.bss_capability_media
BridgeCapability.COMMUNICATIONS -> R.string.bss_capability_communications
BridgeCapability.OUTBOUND_SHARING -> R.string.bss_capability_sharing
BridgeCapability.SCREEN_INSPECTION -> R.string.bss_capability_screen_inspection
BridgeCapability.SCREEN_CONTROL -> R.string.bss_capability_screen_control
}
@Composable
private fun SectionCard(title: String, content: @Composable () -> Unit) {
Card(
@@ -44,6 +44,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -61,12 +62,23 @@ import androidx.lifecycle.viewmodel.compose.viewModel
// === PHASE3-safety-rails: safety summary card ===
import com.hermesandroid.relay.bridge.BridgeSafetyManager
import com.hermesandroid.relay.data.BridgeSafetySettings
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.ui.components.BridgeSafetySummaryCard
// === END PHASE3-safety-rails ===
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.components.BridgeActivityLog
import com.hermesandroid.relay.ui.components.BridgeMasterToggle
import com.hermesandroid.relay.ui.components.BridgePermissionChecklist
import com.hermesandroid.relay.ui.components.BridgeAccessPreset
import com.hermesandroid.relay.ui.components.BridgeAccessSetupSheet
import com.hermesandroid.relay.ui.components.BridgeAgentAccessCard
import com.hermesandroid.relay.ui.components.BridgeAndroidAccessSummaryCard
import com.hermesandroid.relay.ui.components.BridgeTimedAccessSheet
import com.hermesandroid.relay.ui.components.BridgeSelectedAndroidAccessCard
import com.hermesandroid.relay.ui.components.READ_CONFIRMED_BRIDGE_CAPABILITIES
import com.hermesandroid.relay.ui.components.READ_ONLY_BRIDGE_CAPABILITIES
import com.hermesandroid.relay.ui.components.activeTimedCapabilities
import com.hermesandroid.relay.ui.components.bridgeAndroidAccessSummary
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayHeroPanel
import com.hermesandroid.relay.ui.components.RelayReturnStrip
@@ -84,13 +96,15 @@ import kotlinx.coroutines.launch
* Bridge tab — phase 3 Wave 1 rewrite (Agent bridge-ui, `bridge-screen-ui`).
*
* Replaces the Phase 0 "Coming Soon" placeholder with the real control
* surface described in `Plans/Phase 3 — Bridge Channel.md` §5. Four stacked
* cards in a verticalScroll column:
* surface described in `Plans/Phase 3 — Bridge Channel.md` §5. The main page
* is a summary-first cockpit with complete drill-downs:
*
* 1. [BridgeMasterToggle] — "Allow Agent Control" + live status
* 2. [BridgePermissionChecklist] — accessibility / capture / overlay / notif
* 3. [BridgeActivityLog] — scrollable recent-command history
* 4. Safety placeholder — stub owned by Agent safety-rails in Wave 2
* 1. [BridgeMasterToggle] — global kill switch + live device status
* 2. [BridgeAgentAccessCard] — permanent and screen-access policy posture
* 3. Unattended access — single authoritative sideload control
* 4. Android readiness summary — selected requirements + expandable full matrix
* 5. Advanced safety controls — complete power-user controls
* 6. [BridgeActivityLog] — scrollable recent-command history
*
* State comes from [BridgeViewModel] which in turn reads from the
* [com.hermesandroid.relay.data.BridgePreferencesRepository] DataStore for
@@ -151,6 +165,9 @@ fun BridgeScreen(
// === END PHASE3-safety-rails-followup ===
val context = LocalContext.current
val accessScope = androidx.compose.runtime.rememberCoroutineScope()
val accessSavedMessage = stringResource(R.string.bridge_access_saved_review_android)
val timedAccessEndedMessage = stringResource(R.string.bridge_timed_ended_snackbar)
// Result callback used by every runtime-permission launcher on this screen.
// If the user has permanently denied the permission (two declines on
@@ -197,6 +214,63 @@ fun BridgeScreen(
val trustedVerbs by (safetyManager?.trustedDestructiveVerbs
?: remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) })
.collectAsState()
val activeConnectionId by (connectionViewModel?.activeConnectionId
?: remember { kotlinx.coroutines.flow.MutableStateFlow<String?>(null) })
.collectAsState()
val activeCapabilityPolicy by (safetyManager?.activeCapabilityPolicy
?: remember { kotlinx.coroutines.flow.MutableStateFlow(BridgeCapabilityPolicy()) })
.collectAsState()
val screenControlAvailable = activeCapabilityPolicy.allows(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
System.currentTimeMillis(),
)
val screenControlUnlimited = activeCapabilityPolicy.isUnlimited(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
)
val screenAccessActive = activeCapabilityPolicy.activeTimedCapabilities(
System.currentTimeMillis(),
).isNotEmpty()
val anyScreenAccessUnlimited = activeCapabilityPolicy.activeTimedCapabilities(
System.currentTimeMillis(),
).any(activeCapabilityPolicy::isUnlimited)
val overlayRequired = screenControlAvailable ||
com.hermesandroid.relay.bridge.BridgeCapability.COMMUNICATIONS in
activeCapabilityPolicy.permanentGrants ||
com.hermesandroid.relay.bridge.BridgeCapability.OUTBOUND_SHARING in
activeCapabilityPolicy.permanentGrants
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
if (autoDisableAtMs != null) {
LaunchedEffect(autoDisableAtMs) {
while (true) {
nowMs = System.currentTimeMillis()
kotlinx.coroutines.delay(1_000L)
}
}
}
val androidAccessSummary = bridgeAndroidAccessSummary(
policy = activeCapabilityPolicy,
status = permissionStatus,
nowMs = nowMs,
)
var permissionsExpanded by remember { mutableStateOf(false) }
var showSetupSheet by remember { mutableStateOf(false) }
var selectedPreset by remember { mutableStateOf(BridgeAccessPreset.READ_ONLY) }
var showTimedSheet by remember { mutableStateOf(false) }
var timedInspect by remember { mutableStateOf(true) }
var timedControl by remember { mutableStateOf(true) }
var timedMinutes by remember { mutableStateOf(safetySettings.autoDisableMinutes) }
var timedUnlimited by remember { mutableStateOf(false) }
val timedCurrentlyActive = activeCapabilityPolicy.activeTimedCapabilities(nowMs).isNotEmpty()
fun openTimedSheet() {
val current = activeCapabilityPolicy.activeTimedCapabilities(nowMs)
timedInspect = if (current.isEmpty()) true else
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_INSPECTION in current
timedControl = if (current.isEmpty()) true else
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL in current
timedMinutes = safetySettings.autoDisableMinutes
timedUnlimited = current.any(activeCapabilityPolicy::isUnlimited)
showTimedSheet = true
}
// === END PHASE3-safety-rails ===
// Re-run permission + system-status probes whenever the screen resumes.
@@ -328,6 +402,7 @@ fun BridgeScreen(
// needed and the nag would confuse users + reviewers.
if (BuildFlavor.isSideload &&
masterToggle &&
overlayRequired &&
!permissionStatus.overlayPermitted
) {
OverlayPermissionNagCard(
@@ -397,58 +472,20 @@ fun BridgeScreen(
stringResource(R.string.bridge_enable_bridge_mode),
)
// 2. Permissions — prerequisites come before advanced features.
BridgePermissionChecklist(
status = permissionStatus,
// === PHASE3-safety-rails-followup: in-app permission Test handlers ===
onTestAccessibility = { viewModel.testAccessibilityService() },
onTestScreenCapture = { viewModel.testScreenCapture() },
onTestOverlay = { viewModel.testOverlayPermission() },
// === END PHASE3-safety-rails-followup ===
// === PHASE3-bridge-ui-followup: extended interactions ===
onRequestScreenCapture = { viewModel.requestScreenCapture() },
onTestNotificationListener = { viewModel.testNotificationListener() },
// === END PHASE3-bridge-ui-followup ===
onRequestNotifications = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Same "tap always goes to Settings" treatment as the
// other runtime-permission rows. The master-toggle
// auto-request path (BridgeMasterToggle onToggle) still
// uses the launcher directly since that's a programmatic
// flow where a dialog is appropriate.
{ openAppDetailsSettings(context) }
} else null,
// === v0.4.1 polish: runtime-permission row taps go to Settings ====
// Earlier iteration tried launcher.launch(permission) with a
// post-denial fallback to app-details Settings. That
// fallback depended on (context as? Activity) succeeding
// which quietly returned null in the Compose context chain
// — so taps after a permanent denial were a silent no-op.
// Simpler + matches user expectation: tap ALWAYS opens the
// app-details Settings page. Parity with Accessibility /
// Notification Listener / Overlay rows which also open
// Settings unconditionally. First-time grant is one extra
// tap vs. a system dialog — acceptable trade-off for
// "tap always does something visible".
onRequestMicrophone = { openAppDetailsSettings(context) },
onRequestCamera = { openAppDetailsSettings(context) },
onRequestContacts = { openAppDetailsSettings(context) },
onRequestSms = { openAppDetailsSettings(context) },
onRequestPhone = { openAppDetailsSettings(context) },
onRequestLocation = { openAppDetailsSettings(context) },
// === END v0.4.1 polish ====
// 2. Capability policy stays visible directly under the master.
// Detailed toggles remain one tap away on the full safety screen.
BridgeAgentAccessCard(
policy = activeCapabilityPolicy,
nowMs = nowMs,
onSetUp = { showSetupSheet = true },
onManage = onNavigateToBridgeSafety,
onAllowScreen = { openTimedSheet() },
)
// 3. Advanced section — unattended access + safety. Sideload
// only — these features don't exist on googlePlay (no wake
// lock, no destructive-verb routes).
// 3. One authoritative unattended control, kept beside the
// access policy it extends. Do not duplicate this state inside
// Agent access or Advanced: one switch owns one mode.
if (BuildFlavor.isSideload) {
AdvancedSectionHeader()
// 4. Unattended access — a SUB-FEATURE of the master
// toggle. Gated: Switch is non-interactive when the
// master toggle is off so users can't flip it and
// observe nothing happening (the acquire path
// short-circuits when master is off anyway).
UnattendedAccessRow(
enabled = unattendedEnabled,
warningSeen = unattendedWarningSeen,
@@ -456,15 +493,76 @@ fun BridgeScreen(
onToggle = { viewModel.setUnattendedAccessEnabled(it) },
onWarningSeen = { viewModel.markUnattendedWarningSeen() },
masterEnabled = masterToggle,
screenControlAvailable = screenControlAvailable,
screenAccessUnlimited = screenControlUnlimited,
)
}
// 5. Safety summary — auto-disable, destructive verbs,
// blocklist. Belongs adjacent to unattended because
// they share the "advanced / opt-in / sideload-only"
// mental model.
// 4. Android permission state is summarized against the selected
// policy. Expanding preserves the complete existing matrix and
// every Settings/Test action—no power-user surface is removed.
BridgeAndroidAccessSummaryCard(
summary = androidAccessSummary,
expanded = permissionsExpanded,
onToggle = { permissionsExpanded = !permissionsExpanded },
)
if (permissionsExpanded) {
BridgeSelectedAndroidAccessCard(
summary = androidAccessSummary,
onOpenAccessibility = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
},
onOpenAppSettings = { openAppDetailsSettings(context) },
onOpenOverlay = {
runCatching {
context.startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}"),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
)
}
},
)
BridgePermissionChecklist(
status = permissionStatus,
onTestAccessibility = { viewModel.testAccessibilityService() },
onTestScreenCapture = { viewModel.testScreenCapture() },
onTestOverlay = { viewModel.testOverlayPermission() },
onRequestScreenCapture = { viewModel.requestScreenCapture() },
onTestNotificationListener = { viewModel.testNotificationListener() },
onRequestNotifications = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
) {
{ openAppDetailsSettings(context) }
} else null,
onRequestMicrophone = { openAppDetailsSettings(context) },
onRequestCamera = { openAppDetailsSettings(context) },
onRequestContacts = { openAppDetailsSettings(context) },
onRequestSms = { openAppDetailsSettings(context) },
onRequestPhone = { openAppDetailsSettings(context) },
onRequestLocation = { openAppDetailsSettings(context) },
)
}
// 5. Advanced safety controls. Sideload only — these features
// don't exist on googlePlay (no destructive-verb routes).
if (BuildFlavor.isSideload) {
AdvancedSectionHeader()
// Safety summary — auto-disable, destructive verbs,
// blocklist, and the complete granular editor.
BridgeSafetySummaryCard(
settings = safetySettings,
autoDisableAtMs = autoDisableAtMs,
screenAccessActive = screenAccessActive,
screenAccessUnlimited = anyScreenAccessUnlimited,
onManage = onNavigateToBridgeSafety,
)
@@ -492,6 +590,102 @@ fun BridgeScreen(
Spacer(modifier = Modifier.height(16.dp))
}
}
if (showSetupSheet) {
BridgeAccessSetupSheet(
selected = selectedPreset,
onSelected = { selectedPreset = it },
onDismiss = { showSetupSheet = false },
onContinue = {
when (selectedPreset) {
BridgeAccessPreset.CUSTOM -> {
showSetupSheet = false
onNavigateToBridgeSafety()
}
BridgeAccessPreset.READ_ONLY,
BridgeAccessPreset.READ_CONFIRMED -> accessScope.launch {
val grants = if (selectedPreset == BridgeAccessPreset.READ_ONLY) {
READ_ONLY_BRIDGE_CAPABILITIES
} else {
READ_CONFIRMED_BRIDGE_CAPABILITIES
}
safetyManager?.replacePermanentCapabilities(activeConnectionId, grants)
showSetupSheet = false
permissionsExpanded = true
snackbarHost.showSnackbar(accessSavedMessage)
}
}
},
)
}
if (showTimedSheet) {
BridgeTimedAccessSheet(
inspectEnabled = timedInspect,
controlEnabled = timedControl,
durationMinutes = timedMinutes,
unlimited = timedUnlimited,
accessibilityReady = permissionStatus.accessibilityServiceEnabled,
overlayReady = permissionStatus.overlayPermitted,
currentlyActive = timedCurrentlyActive,
onInspectChanged = { timedInspect = it },
onControlChanged = { timedControl = it },
onDurationChanged = { timedMinutes = it },
onUnlimitedChanged = { timedUnlimited = it },
onOpenAccessibility = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
},
onOpenOverlay = {
runCatching {
context.startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}"),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
)
}
},
onDismiss = { showTimedSheet = false },
onAllow = {
accessScope.launch {
val capabilities = buildSet {
if (timedInspect) add(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_INSPECTION,
)
if (timedControl) add(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
)
}
viewModel.setTimedAccessMinutes(timedMinutes)
safetyManager?.replaceTimedCapabilities(
activeConnectionId,
capabilities,
timedMinutes,
unlimited = timedUnlimited,
)
showTimedSheet = false
}
},
onEndNow = {
accessScope.launch {
safetyManager?.replaceTimedCapabilities(
activeConnectionId,
emptySet(),
timedMinutes,
)
viewModel.setUnattendedAccessEnabled(false)
showTimedSheet = false
snackbarHost.showSnackbar(timedAccessEndedMessage)
}
},
)
}
}
/**
@@ -187,6 +187,11 @@ import com.hermesandroid.relay.ui.components.BackgroundTaskCard
import com.hermesandroid.relay.ui.components.LocalRelayServerImageResolver
import com.hermesandroid.relay.ui.components.RelayServerImageResolver
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatFailureDetailsDialog
import com.hermesandroid.relay.ui.components.ChatFailurePanel
import com.hermesandroid.relay.viewmodel.ChatFailureRoute
import com.hermesandroid.relay.viewmodel.ChatFailureNotice
import com.hermesandroid.relay.viewmodel.scopedChatFailure
import com.hermesandroid.relay.ui.components.ConversationVoiceDock
import com.hermesandroid.relay.ui.components.CleanChatMode
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
@@ -848,6 +853,21 @@ fun ChatScreen(
val serverAutoTitles by chatViewModel.serverAutoTitles.collectAsState()
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val structuredChatFailure by chatViewModel.chatFailure.collectAsState()
val visibleChatFailure = scopedChatFailure(
structuredChatFailure,
currentSessionId,
) ?: error?.let { rawError ->
ChatFailureNotice(
sessionId = currentSessionId,
turnId = "transport-error",
rawError = rawError,
route = null,
)
}
var showChatFailureDetails by rememberSaveable(visibleChatFailure?.turnId) {
mutableStateOf(false)
}
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
@@ -2934,32 +2954,6 @@ fun ChatScreen(
// are reached from Settings (Settings → Hermes management / Bridge);
// Terminal + Settings remain quick icons in the top app bar above.
// Error banner with retry
AnimatedVisibility(visible = error != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
TextButton(onClick = { chatViewModel.retryLastMessage() }) {
Text(stringResource(R.string.chat_retry))
}
TextButton(onClick = { chatViewModel.clearError() }) {
Text(stringResource(R.string.chat_dismiss))
}
}
}
// Loading history indicator — only when there's nothing already on
// screen. During a profile/session switch the previous transcript is
// held visible while the new history loads (see
@@ -4135,6 +4129,48 @@ fun ChatScreen(
null
}
visibleChatFailure?.let { failure ->
val failureRouteLabel = when (failure.route) {
ChatFailureRoute.GATEWAY ->
stringResource(R.string.chat_failure_route_gateway)
ChatFailureRoute.API_FALLBACK ->
stringResource(R.string.chat_failure_route_api)
null -> ""
}
ChatFailurePanel(
failure = failure,
routeLabel = failureRouteLabel,
onDetails = { showChatFailureDetails = true },
onRetry = { chatViewModel.retryLastMessage() },
onDismiss = chatViewModel::dismissChatFailure,
)
if (showChatFailureDetails) {
ChatFailureDetailsDialog(
failure = failure,
routeLabel = failureRouteLabel,
onCopy = {
val details = buildString {
append(failureRouteLabel)
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
append("\n\n")
append(failure.rawError)
}
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Hermes response failure", details)),
)
snackbarHostState.showSnackbar(
message = context.getString(R.string.chat_copied_to_clipboard),
duration = SnackbarDuration.Short,
)
}
},
onDismiss = { showChatFailureDetails = false },
)
}
}
ChatInputBar(
value = inputText,
onValueChange = { inputText = it },
@@ -75,7 +75,7 @@ import kotlinx.coroutines.launch
*
* 4. **`masterToggle`** write path — flipping the master switch persists via
* [BridgePreferencesRepository.setMasterEnabled]. accessibility's service reads the
* same DataStore key (`bridge_master_enabled`) and treats it as the
* same DataStore key (`bridge_master_enabled_v2`) and treats it as the
* runtime disable switch — when false, the service should ignore all
* incoming `bridge.command` envelopes. bridge-ui does not wire the service lifecycle
* to this toggle; that's accessibility's call.
@@ -230,20 +230,24 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application)
// === END PHASE3-bridge-ui-followup ===
// === PHASE3-safety-rails: foreground service lifecycle ===
// Start/stop BridgeForegroundService based on the master toggle.
// distinctUntilChanged prevents re-firing the startForegroundService
// intent on every DataStore tick. Cancel the safety manager's
// auto-disable timer when the user flips the toggle off manually
// (otherwise we race a pending timer against the user).
// Start/stop BridgeForegroundService from the persisted settings Flow,
// not masterToggle's synthetic initial=false StateFlow value. Using the
// synthetic value here could revoke an unlimited lease during startup
// before DataStore reported that Master was actually persisted ON.
// distinctUntilChanged prevents duplicate service work. Cancel the safety manager's
// timed screen grants when the user flips the master off manually
// (otherwise stale authority could revive when master is re-enabled).
viewModelScope.launch {
masterToggle.collect { enabled ->
prefsRepo.settings
.map { it.masterEnabled }
.distinctUntilChanged()
.collect { enabled ->
val ctx = getApplication<Application>()
if (enabled) {
runCatching { BridgeForegroundService.start(ctx) }
BridgeSafetyManager.peek()?.rescheduleAutoDisable()
} else {
runCatching { BridgeForegroundService.stop(ctx) }
BridgeSafetyManager.peek()?.cancelAutoDisable()
BridgeSafetyManager.peek()?.revokeTimedCapabilities()
// Force the overlay chip off even if the user hasn't
// explicitly disabled it — no point showing "bridge
// active" when the toggle is off.
@@ -252,13 +256,9 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application)
// drop it on toggle-off so the row goes back to red
// and the next bridge enable prompts for fresh consent.
MediaProjectionHolder.revoke()
// v0.4.1: drop the unattended-access wake lock immediately
// when the master toggle drops. The unattended toggle
// itself may still be persisted ON in DataStore — that's
// intentional, the user's "I want unattended when bridge
// is on" preference shouldn't be cleared by every bridge
// toggle cycle — but the wake lock is meaningless
// without an active bridge.
// Drop the unattended wake lock immediately. The safety
// manager also clears the persisted unattended preference,
// so re-enabling Master cannot silently revive it.
UnattendedAccessManager.release()
}
}
@@ -334,6 +334,12 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application)
}
}
fun setTimedAccessMinutes(minutes: Int) {
viewModelScope.launch {
safetyPrefsRepo.setAutoDisableMinutes(minutes)
}
}
/**
* Latch the "user has seen the warning" sentinel so the scary
* dialog never appears again after the first dismissal. Called
@@ -186,6 +186,50 @@ data class ContextWindowUsage(
get() = if (maxTokens > 0) (usedTokens.toFloat() / maxTokens).coerceIn(0f, 1f) else 0f
}
enum class ChatFailureRoute { GATEWAY, API_FALLBACK }
/** Turn-scoped failure presentation derived from transport-owned signals. */
data class ChatFailureNotice(
val sessionId: String?,
val turnId: String,
val rawError: String,
val route: ChatFailureRoute?,
val model: String? = null,
val provider: String? = null,
val recoverable: Boolean = true,
)
internal fun scopedChatFailure(
failure: ChatFailureNotice?,
currentSessionId: String?,
): ChatFailureNotice? = failure?.takeIf { it.sessionId == currentSessionId }
internal fun recordChatFailureDiagnostic(
failure: ChatFailureNotice,
liveSessionId: String? = null,
) {
val detail = buildString {
failure.model?.takeIf { it.isNotBlank() }?.let { append("model=$it; ") }
failure.provider?.takeIf { it.isNotBlank() }?.let { append("provider=$it; ") }
failure.sessionId?.takeIf { it.isNotBlank() }?.let { append("stored_session=$it; ") }
liveSessionId?.takeIf { it.isNotBlank() }?.let { append("live_session=$it; ") }
append("error=${failure.rawError}")
}
DiagnosticsLog.record(
category = DiagnosticCategory.Session,
severity = DiagnosticSeverity.Error,
title = "Hermes chat response failed",
detail = detail,
operation = "chat response",
endpointRole = when (failure.route) {
ChatFailureRoute.GATEWAY -> "gateway"
ChatFailureRoute.API_FALLBACK -> "api fallback"
null -> "chat"
},
suggestion = "Compare the same stored session in another Hermes client.",
)
}
/**
* A successful Sessions SSE turn still needs the server-authoritative transcript
* because that transport does not stream every persisted message boundary.
@@ -500,6 +544,12 @@ class ChatViewModel : ViewModel() {
private fun emitError(t: Throwable?, context: String?) {
val human = classifyError(t, context = context, ctx = appContext)
// ChatHandler.error now owns an in-layout recovery panel at the
// composer edge. Keep classification/diagnostics, but never stack an
// app-wide snackbar over the same failure.
if (context == "send_message" && chatHandler?.error?.value != null) {
return
}
// Cold-start / reconnect bootstrap (session-list load, session create)
// runs without the user asking and on every reconnect. A "can't reach
// the server" failure there is non-actionable noise — the themed
@@ -1839,6 +1889,7 @@ class ChatViewModel : ViewModel() {
}
gatewayClient = client
if (changed) {
dismissChatFailure()
resetApprovalModeState()
_messageReactionsSupported.value = true
gatewayProcessSource = client?.let(::GatewayChatProcessSource)
@@ -3101,6 +3152,8 @@ class ChatViewModel : ViewModel() {
private val _emptyError = MutableStateFlow<String?>(null)
private val _emptySessionId = MutableStateFlow<String?>(null)
private val _emptyTurnStatus = MutableStateFlow<String?>(null)
private val _chatFailure = MutableStateFlow<ChatFailureNotice?>(null)
val chatFailure: StateFlow<ChatFailureNotice?> = _chatFailure.asStateFlow()
// Delegated to ChatHandler
val messages: StateFlow<List<ChatMessage>>
@@ -3139,6 +3192,19 @@ class ChatViewModel : ViewModel() {
val currentSessionId: StateFlow<String?>
get() = chatHandler?.currentSessionId ?: _emptySessionId
fun dismissChatFailure() {
_chatFailure.value = null
chatHandler?.clearError()
}
private fun publishChatFailure(
failure: ChatFailureNotice,
liveSessionId: String? = null,
) {
_chatFailure.value = failure
recordChatFailureDiagnostic(failure, liveSessionId)
}
/**
* Inject an agent-initiated ("proactive") message into the active session
* so it continues that conversation (the `phone` platform's
@@ -3550,6 +3616,7 @@ class ChatViewModel : ViewModel() {
persistLastSession: Boolean = true,
) {
val handler = chatHandler ?: return
dismissChatFailure()
val isInitialContextBinding = activeProfileContextKey == null
handler.activeAgentName = currentAgentDisplayName()
if (
@@ -4081,6 +4148,7 @@ class ChatViewModel : ViewModel() {
fun switchSession(sessionId: String) {
val handler = chatHandler ?: return
dismissChatFailure()
clearOpenedSessionOwner()
if (streamingEndpoint != "gateway" && apiClient == null) return
pendingThread = null
@@ -4361,6 +4429,10 @@ class ChatViewModel : ViewModel() {
if (streamingEndpoint != "gateway" && client == null) return
if (streamingEndpoint == "gateway" && gatewayClient == null && client == null) return
// A new user action owns the recovery surface. The failed transcript
// row remains in history; only the composer-attached notice retires.
dismissChatFailure()
// Server slash commands (gateway transport only) execute via
// slash.exec / command.dispatch instead of becoming a prompt.
if (activeStream == null && maybeHandleServerSlashCommand(text.trim())) return
@@ -7626,6 +7698,8 @@ class ChatViewModel : ViewModel() {
if (turnErrored) {
finalizeFailedTurnSideEffects(handler, currentMessageId)
} else {
_chatFailure.value = null
handler.clearError()
finalizeTurnSideEffects(handler, currentMessageId)
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
}
@@ -7785,17 +7859,34 @@ class ChatViewModel : ViewModel() {
startCheckpointHistoryRecovery(handler, checkpoint, errorMsg)
} else {
AppAnalytics.onStreamError()
handler.markError(currentMessageId)
handler.onStreamError(errorMsg)
emitError(Exception(errorMsg), context = "send_message")
publishChatFailure(
ChatFailureNotice(
sessionId = errorSessionId,
turnId = currentMessageId,
rawError = errorMsg,
route = ChatFailureRoute.GATEWAY,
),
)
clearTurnCheckpoint()
}
} else {
AppAnalytics.onStreamError()
handler.markError(currentMessageId)
handler.onStreamError(errorMsg)
// Keep the in-place error banner AND push to the global
// snackbar — classifier wraps the string into a throwable
// so context-specific copy kicks in for send_message.
emitError(Exception(errorMsg), context = "send_message")
publishChatFailure(
ChatFailureNotice(
sessionId = errorSessionId,
turnId = currentMessageId,
rawError = errorMsg,
route = if (dispatchedSseEndpoint == null && activeStreamIsGateway) {
ChatFailureRoute.GATEWAY
} else {
ChatFailureRoute.API_FALLBACK
},
),
)
// Recover the server-authoritative transcript on a gateway/
// sessions error: a turn can fail on the CLIENT (mid-turn route
// switch, watchdog timeout) AFTER the server already finished it
@@ -7832,7 +7923,18 @@ class ChatViewModel : ViewModel() {
handler.updateDeliveryStatus(userMessageId, MessageDeliveryStatus.FAILED)
AppAnalytics.onStreamError()
handler.onStreamError(errorMsg)
emitError(error, context = "send_message")
publishChatFailure(
ChatFailureNotice(
sessionId = handler.currentSessionId.value,
turnId = currentMessageId,
rawError = errorMsg,
route = if (streamingEndpoint == "gateway") {
ChatFailureRoute.GATEWAY
} else {
ChatFailureRoute.API_FALLBACK
},
),
)
activeStream = null
removeQueuedMessagesForOwner(userMessageId)
_steerableTurn.value = false
@@ -8169,6 +8271,31 @@ class ChatViewModel : ViewModel() {
onInteractionExpired = { expiry ->
expirePendingAsk(expiry)
},
onResumeFailure = { reason ->
_steerableTurn.value = false
onPreflightErrorCb(Exception(reason))
},
onFailure = { failure ->
val confirmedModel = gateway.serverModel.value
?.takeIf { it.isNotBlank() }
?: modelOverride
val confirmedProvider = gateway.serverProvider.value
?.takeIf { it.isNotBlank() }
?: providerOverride
val storedSession = handler.currentSessionId.value
publishChatFailure(
ChatFailureNotice(
sessionId = storedSession,
turnId = currentMessageId,
rawError = failure.error,
route = ChatFailureRoute.GATEWAY,
model = confirmedModel,
provider = confirmedProvider,
recoverable = failure.recoverable,
),
liveSessionId = storedSession?.let(gateway::currentLiveSessionId),
)
},
onStatusUpdate = { kind, text ->
handler.setTurnStatus(text, kind)
// The server prefixes terminal failures with ❌ —
@@ -8319,7 +8446,7 @@ class ChatViewModel : ViewModel() {
}
fun clearError() {
chatHandler?.clearError()
dismissChatFailure()
}
fun retryLastMessage() {
@@ -2381,6 +2381,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
if (BuildFlavor.isSideload) com.hermesandroid.relay.bridge.BridgeSafetyManager.install(
context = application,
scope = viewModelScope,
activeConnectionId = connectionStore.activeConnectionId,
).also {
com.hermesandroid.relay.bridge.BridgeStatusOverlay.install(application)
} else null
@@ -3496,6 +3497,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
profileController.profileSessionStore.clearConnection(connectionId)
profileController.profileDisplayAliasStore.clearConnection(connectionId)
profileController.profileIconStore.clearConnection(connectionId)
com.hermesandroid.relay.data.BridgeCapabilityPolicyRepository(getApplication())
.clearConnection(connectionId)
}
private suspend fun readStoredDeviceIdForRemoval(
@@ -961,7 +961,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var bargeInPreferences: BargeInPreferencesRepository? = null
private var vadEngineFactory: (() -> VadEngine)? = null
private var bargeInListenerFactory: ((VadEngine, () -> Int) -> BargeInListener)? = null
private var bargeInListenerFactory: ((VadEngine) -> BargeInListener)? = null
private var bargeInPreferencesJob: Job? = null
/**
@@ -1090,7 +1090,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// must be complete; we log and disable barge-in if not. ===
bargeInPreferences: BargeInPreferencesRepository? = null,
vadEngineFactory: (() -> VadEngine)? = null,
bargeInListenerFactory: ((VadEngine, () -> Int) -> BargeInListener)? = null,
bargeInListenerFactory: ((VadEngine) -> BargeInListener)? = null,
// === 2026-04-17 fix: persist interactionMode across app restarts ===
// Optional so pre-fix call sites keep compiling. When non-null, the
// VM subscribes to the settings flow and mirrors `interactionMode`
@@ -3405,9 +3405,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
responseText = "",
)
}
beginBargeInTurnIfEnabled(
audioSessionIdProvider = { realtimePcmPlayer?.audioSessionId ?: 0 },
)
beginBargeInTurnIfEnabled()
}
// Per-turn event state is hoisted to fields so one session-lived callback
@@ -4171,9 +4169,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
responseText = "",
)
}
beginBargeInTurnIfEnabled(
audioSessionIdProvider = { realtimePcmPlayer?.audioSessionId ?: 0 },
)
beginBargeInTurnIfEnabled()
val deliveryResult = CompletableDeferred<Result<Unit>>()
val queued = channel.trySend(
RealtimeTurnInput(
@@ -5082,9 +5078,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
val firstAudioChunk = bargeInStarted.compareAndSet(false, true)
if (firstAudioChunk) {
if (bargeInListener == null) {
startBargeInListenerIfEnabled(
audioSessionIdProvider = { pcmPlayer.audioSessionId },
)
startBargeInListenerIfEnabled()
}
// Freeze quiet-room calibration before the first PCM write can
// reach AudioTrack and leak speaker output into the noise floor.
@@ -5584,32 +5578,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* listener's internal poll loop can watch it flip from 0 to non-zero
* as playback begins.
*/
private fun beginBargeInTurnIfEnabled(
audioSessionIdProvider: (() -> Int)? = null,
) {
private fun beginBargeInTurnIfEnabled() {
val previousReader = stopBargeInListener()
val epoch = bargeInTurnEpoch.incrementAndGet()
activeBargeInTurnEpoch = epoch
if (previousReader == null) {
startBargeInListenerIfEnabled(
audioSessionIdProvider = audioSessionIdProvider,
epoch = epoch,
)
startBargeInListenerIfEnabled(epoch = epoch)
} else {
viewModelScope.launch {
previousReader.join()
if (activeBargeInTurnEpoch == epoch) {
startBargeInListenerIfEnabled(
audioSessionIdProvider = audioSessionIdProvider,
epoch = epoch,
)
startBargeInListenerIfEnabled(epoch = epoch)
}
}
}
}
private fun startBargeInListenerIfEnabled(
audioSessionIdProvider: (() -> Int)? = null,
epoch: Long = bargeInTurnEpoch.incrementAndGet(),
) {
// Already running → no-op. One listener spans generation and playback,
@@ -5634,17 +5619,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return
}
val sessionProvider: () -> Int = if (audioSessionIdProvider != null) {
audioSessionIdProvider
} else {
val p = player
if (p == null) {
Log.i(TAG, "Barge-in listener skipped; legacy player not ready")
return
}
fun(): Int { return p.audioSessionId }
}
val vad = try {
vadFactory().also { it.setSensitivity(prefs.sensitivity) }
} catch (t: Throwable) {
@@ -5654,7 +5628,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
bargeInVadEngine = vad
val listener = try {
factory(vad, sessionProvider)
factory(vad)
} catch (t: Throwable) {
Log.w(TAG, "BargeInListener construction failed; skipping barge-in: ${t.message}")
try { vad.close() } catch (_: Throwable) { /* ignore */ }
@@ -5682,8 +5656,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
Log.i(
TAG,
"Starting barge-in listener; sensitivity=${prefs.sensitivity} " +
"source=${if (audioSessionIdProvider != null) "realtime_pcm" else "legacy_player"} " +
"session=${sessionProvider()}",
"effects=capture_session",
)
try {
listener.start(viewModelScope)
@@ -68,12 +68,16 @@ class SherpaWakeWordDetector(
),
)
private val stream: OnlineStream = spotter.createStream()
private var normalizedSamples = FloatArray(0)
private var closed = false
override fun accept(samples: ShortArray, count: Int): Boolean {
if (closed || count <= 0) return false
val normalized = FloatArray(count) { index -> samples[index] / 32768.0f }
stream.acceptWaveform(normalized, sampleRate = 16_000)
if (normalizedSamples.size != count) normalizedSamples = FloatArray(count)
for (index in 0 until count) {
normalizedSamples[index] = samples[index] / 32768.0f
}
stream.acceptWaveform(normalizedSamples, sampleRate = 16_000)
var detected = false
while (spotter.isReady(stream)) {
spotter.decode(stream)
+95 -5
View File
@@ -121,6 +121,13 @@
<string name="chat_share_conversation">Compartilhar conversa</string>
<string name="chat_retry">Tentar novamente</string>
<string name="chat_dismiss">Dispensar</string>
<string name="chat_failure_title">O Hermes não conseguiu concluir esta resposta</string>
<string name="chat_failure_details">Detalhes</string>
<string name="chat_failure_details_title">Falha na resposta</string>
<string name="chat_failure_details_guidance">O Hermes relatou este erro. O app não mudará de rota nem de modelo automaticamente.</string>
<string name="chat_failure_copy_details">Copiar detalhes</string>
<string name="chat_failure_route_gateway">Gateway</string>
<string name="chat_failure_route_api">API alternativa</string>
<string name="chat_open_settings">Abrir Configurações</string>
<string name="chat_connect_hermes">Conectar ao Hermes</string>
<string name="chat_try_demo">Experimentar a demonstração</string>
@@ -519,7 +526,7 @@
<string name="bssc_safety">Segurança</string>
<string name="bssc_blocked_apps">Apps nos quais o agente não pode tocar</string>
<string name="bssc_destructive_verbs">Palavras que sempre exigem confirmação</string>
<string name="bssc_auto_disable">Desliga automaticamente quando ocioso</string>
<string name="bssc_auto_disable">Acesso temporário à tela</string>
<string name="bssc_guardrails_hint">Estas proteções mantêm o Hermes dentro dos limites. Toque para ajustar.</string>
<string name="bssc_manage">Gerenciar</string>
<!-- BridgeActivityLog -->
@@ -878,7 +885,7 @@
<string name="bridge_settings">Configurações</string>
<string name="bridge_paired_title">O Bridge do celular está pareado</string>
<string name="bridge_staged_title">Os controles do Bridge estão preparados</string>
<string name="bridge_paired_subtitle">Terminal, voz, notificações, mídia e controles avançados do celular compartilham esta permissão.</string>
<string name="bridge_paired_subtitle">O pareamento Relay está ativo. Os comandos do celular também seguem o acesso do agente abaixo.</string>
<string name="bridge_staged_subtitle">Pareie o Relay para receber comandos do Bridge. Você ainda pode configurar permissões e segurança antes do pareamento.</string>
<string name="bridge_relay_label">relay</string>
<string name="bridge_safety_label">segurança</string>
@@ -943,7 +950,7 @@
<string name="bss_add_verb_cd">Adicionar verbo</string>
<string name="bss_remove_cd">Remover</string>
<string name="bss_auto_disable">Desativar automaticamente após ociosidade</string>
<string name="bss_auto_disable_desc">A chave mestra é desligada após esta quantidade de minutos sem comandos do Bridge. Reprogramada a cada comando.</string>
<string name="bss_auto_disable_desc">O acesso temporário à tela expira após estes minutos ocioso. Somente inspeção ou controle da tela reinicia o temporizador.</string>
<string name="bss_auto_disable_value">%1$d min</string>
<string name="bss_status_overlay">Sobreposição de status</string>
<string name="bss_show_floating">Mostrar indicador flutuante</string>
@@ -3147,8 +3154,8 @@
<string name="whats_new_title">Novidades</string>
<string name="whats_new_subtitle">Destaques da versão mais recente</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Bridge desativado automaticamente</string>
<string name="bridge_notification_auto_disabled_body">Pausado após ficar ocioso — toque para reativar na aba Bridge.</string>
<string name="bridge_notification_auto_disabled_title">Acesso temporário à tela expirou</string>
<string name="bridge_notification_auto_disabled_body">Inspeção e controle da tela foram desativados após ociosidade. As outras permissões não mudaram.</string>
<string name="bridge_notification_control_title">O agente Hermes controla o dispositivo</string>
<string name="bridge_notification_control_body">O Bridge está ativo — toque em Desativar para interromper a qualquer momento.</string>
<string name="gateway_keepalive_title">Conexão com o Hermes ativa</string>
@@ -4011,4 +4018,87 @@
<string name="host_resource_memory_elevated">A memória do servidor está baixa.</string>
<string name="host_resource_disk_critical">O armazenamento do servidor está criticamente baixo; mensagens e configurações podem não ser salvas.</string>
<string name="host_resource_disk_elevated">O armazenamento do servidor está baixo.</string>
<string name="bss_capabilities_title">Permissões de recursos</string>
<string name="bss_capabilities_desc">Sempre/Nunca vale apenas para esta conexão Hermes. A chave mestra e as permissões do Android continuam prevalecendo.</string>
<string name="bss_capabilities_unavailable">Selecione e conecte uma conexão Hermes antes de alterar permissões.</string>
<string name="bss_timed_capabilities_title">Acesso à tela</string>
<string name="bss_timed_capabilities_desc">Inspeção e controle da tela expiram após %1$d minutos ocioso. Permissões permanentes não prolongam o temporizador.</string>
<string name="bss_capability_always">Sempre, enquanto a chave mestra estiver ligada</string>
<string name="bss_capability_never">Nunca</string>
<string name="bss_capability_timed_on">Acesso temporário ativo</string>
<string name="bss_capability_timed_off">Acesso temporário desativado</string>
<string name="bss_capability_device_info">Informações do dispositivo e apps</string>
<string name="bss_capability_contacts">Ler contatos</string>
<string name="bss_capability_location">Ler localização</string>
<string name="bss_capability_clipboard_read">Ler área de transferência</string>
<string name="bss_capability_clipboard_write">Gravar na área de transferência</string>
<string name="bss_capability_media">Controlar reprodução de mídia</string>
<string name="bss_capability_communications">Enviar SMS e fazer chamadas</string>
<string name="bss_capability_sharing">Compartilhar mídia e compor MMS</string>
<string name="bss_capability_screen_inspection">Inspecionar tela e interface</string>
<string name="bss_capability_screen_control">Controlar apps e navegação</string>
<string name="unattended_requires_timed_control">Exige controle da tela — permita primeiro em Acesso do agente.</string>
<string name="bridge_access_title">Acesso do agente</string>
<string name="bridge_access_not_set_up">Não configurado</string>
<string name="bridge_access_summary_desc">Escolha o que o Hermes pode usar nesta conexão.</string>
<string name="bridge_access_set_up">Configurar acesso</string>
<string name="bridge_access_always">Sempre disponível</string>
<string name="bridge_access_enabled_count">%1$d de %2$d recursos ativados</string>
<string name="bridge_access_screen">Acesso à tela</string>
<string name="bridge_access_screen_active">Inspeção ou controle temporariamente ativo</string>
<string name="bridge_access_screen_off">Inspeção e controle desativados</string>
<string name="bridge_access_allow_duration">Permitir…</string>
<string name="bridge_access_unattended_on">Pode ativar a tela durante esta sessão</string>
<string name="bridge_access_unattended_available">Disponível enquanto o acesso à tela estiver ativo</string>
<string name="bridge_access_unattended_needs_screen">Requer acesso temporário à tela</string>
<string name="bridge_access_preset_read_only">Somente leitura</string>
<string name="bridge_access_preset_confirmed_short">Leitura + ações</string>
<string name="bridge_access_preset_confirmed">Leitura + ações confirmadas</string>
<string name="bridge_access_preset_custom">Personalizado</string>
<string name="bridge_access_choose_title">Escolher acesso do agente</string>
<string name="bridge_access_choose_desc">Comece com uma predefinição e ajuste recursos individuais depois.</string>
<string name="bridge_access_preset_read_only_desc">Informações do dispositivo, contatos, localização e leitura da área de transferência</string>
<string name="bridge_access_preset_confirmed_desc">Também permite chamadas, SMS e compartilhamento com confirmação</string>
<string name="bridge_access_preset_custom_desc">Escolha cada recurso</string>
<string name="bridge_access_recommended">Recomendado</string>
<string name="bridge_access_temporary_title">Acesso temporário à tela</string>
<string name="bridge_access_temporary_desc">Inspeção e controle da tela sempre expiram após inatividade.</string>
<string name="bridge_access_continue">Continuar</string>
<string name="bridge_access_saved_review_android">Acesso salvo. Revise o acesso Android necessário antes de ativar a chave mestra.</string>
<string name="bridge_access_read_group">Acesso de leitura</string>
<string name="bridge_access_read_group_desc">Escolha quais dados do dispositivo o Hermes pode ler com a chave mestra ativa.</string>
<string name="bridge_access_actions_group">Ações</string>
<string name="bridge_access_actions_group_desc">Chamadas, SMS e compartilhamento ainda exigem confirmação.</string>
<string name="bridge_android_access_title">Acesso Android</string>
<string name="bridge_android_access_none">Nenhum acesso Android é necessário até você escolher recursos.</string>
<string name="bridge_android_access_ready">Todo o acesso Android necessário para recursos ativos está pronto.</string>
<string name="bridge_android_access_missing">%1$d de %2$d prontos · %3$d ausentes</string>
<string name="bridge_android_access_hide">Ocultar</string>
<string name="bridge_android_access_review">Revisar</string>
<string name="bridge_android_ready_short">Pronto</string>
<string name="bridge_android_missing_short">Ausente</string>
<string name="bridge_android_selected_needs">Necessário para o acesso selecionado</string>
<string name="bridge_android_selected_needs_desc">Estas permissões Android ainda estão ausentes. Recursos desativados não estão incluídos.</string>
<string name="bridge_android_open_settings">Abrir</string>
<string name="bridge_timed_title">Permitir acesso à tela</string>
<string name="bridge_timed_desc">Escolha o que o Hermes pode fazer e por quanto tempo ficará disponível.</string>
<string name="bridge_timed_inspection_desc">Árvore de acessibilidade, eventos da interface e capturas</string>
<string name="bridge_timed_control_desc">Tocar, digitar, deslizar, abrir apps e navegar</string>
<string name="bridge_timed_ends_title">Termina após inatividade</string>
<string name="bridge_timed_prerequisites">Pré-requisitos para acesso à tela</string>
<string name="bridge_timed_capture_note">O consentimento de captura é solicitado na primeira captura de tela.</string>
<string name="bridge_timed_allow">Permitir acesso</string>
<string name="bridge_timed_end_now">Encerrar agora</string>
<string name="bridge_timed_ended_snackbar">O acesso à tela terminou. As permissões permanentes continuam disponíveis.</string>
<string name="unattended_description_unlimited">Mantém este dispositivo disponível sem limite de inatividade. Continua ativo até encerrar o acesso à tela, desligar a chave mestra ou desativar aqui.</string>
<string name="bridge_access_screen_unlimited">Inspeção ou controle permanece ativo até ser desligado</string>
<string name="bridge_access_until_off_short">Até desligar</string>
<string name="bridge_access_unattended_unlimited">Sempre disponível até desligar o acesso à tela</string>
<string name="bridge_timed_lifetime_title">Como o acesso termina</string>
<string name="bridge_timed_scope_title">O que o Hermes pode fazer</string>
<string name="bridge_timed_until_off">Até ser desligado</string>
<string name="bridge_timed_idle_explainer">Sem limite fixo de sessão. Comandos ativos reiniciam o temporizador de %1$s.</string>
<string name="bridge_timed_unlimited_warning">Sem limite de inatividade. O acesso continua após inatividade e reconexão até ser encerrado, a chave mestra ser desligada ou a política mudar. Ideal para um dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">O acesso à tela está desligado. Novo acesso finito usa %1$d minutos ocioso por padrão.</string>
<string name="bss_screen_access_unlimited_desc">Pelo menos um recurso de tela permanece ativo até ser desligado explicitamente.</string>
</resources>
+95 -5
View File
@@ -135,6 +135,13 @@
<string name="chat_share_conversation">分享对话</string>
<string name="chat_retry">重试</string>
<string name="chat_dismiss">关闭</string>
<string name="chat_failure_title">Hermes 无法完成此回复</string>
<string name="chat_failure_details">详情</string>
<string name="chat_failure_details_title">回复失败</string>
<string name="chat_failure_details_guidance">Hermes 报告了此错误。应用不会自动切换路由或模型。</string>
<string name="chat_failure_copy_details">复制详情</string>
<string name="chat_failure_route_gateway">网关</string>
<string name="chat_failure_route_api">API 回退</string>
<string name="chat_open_settings">打开设置</string>
<string name="chat_connect_hermes">连接 Hermes</string>
<string name="chat_try_demo">体验演示</string>
@@ -550,7 +557,7 @@
<string name="bssc_safety">安全</string>
<string name="bssc_blocked_apps">代理无法触碰的应用</string>
<string name="bssc_destructive_verbs">始终需要确认的词语</string>
<string name="bssc_auto_disable">空闲时自动关闭</string>
<string name="bssc_auto_disable">限时屏幕访问</string>
<string name="bssc_guardrails_hint">这些护栏确保 Hermes 在边界内运行。点击调整。</string>
<string name="bssc_manage">管理</string>
@@ -925,7 +932,7 @@
<string name="bridge_settings">设置</string>
<string name="bridge_paired_title">手机 Bridge 已配对</string>
<string name="bridge_staged_title">Bridge 控制已就绪</string>
<string name="bridge_paired_subtitle">终端、语音、通知、媒体和高级手机控制共享此授权。</string>
<string name="bridge_paired_subtitle">Relay 配对已启用。手机命令还会遵循下方的代理访问设置。</string>
<string name="bridge_staged_subtitle">配对 Relay 以接收 Bridge 命令。你也可以在配对前配置权限和安全设置。</string>
<string name="bridge_relay_label">relay</string>
<string name="bridge_safety_label">安全</string>
@@ -992,7 +999,7 @@
<string name="bss_add_verb_cd">添加动词</string>
<string name="bss_remove_cd">移除</string>
<string name="bss_auto_disable">空闲后自动禁用</string>
<string name="bss_auto_disable_desc">主开关在无 Bridge 命令的此分钟数后关闭。每条命令都会重新计时。</string>
<string name="bss_auto_disable_desc">限时屏幕访问会在空闲达到此分钟数后到期。只有屏幕检查或控制会重置计时器。</string>
<string name="bss_auto_disable_value">%1$d 分钟</string>
<string name="bss_status_overlay">状态悬浮窗</string>
<string name="bss_show_floating">显示浮动指示器</string>
@@ -3250,8 +3257,8 @@
<string name="whats_new_title">更新内容</string>
<string name="whats_new_subtitle">最新版本亮点</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Bridge 已自动停用</string>
<string name="bridge_notification_auto_disabled_body">空闲后已暂停——点按可在 Bridge 页面重新启用。</string>
<string name="bridge_notification_auto_disabled_title">限时屏幕访问已到期</string>
<string name="bridge_notification_auto_disabled_body">空闲后,屏幕检查和控制已关闭。其他授权保持不变。</string>
<string name="bridge_notification_control_title">Hermes 代理正在控制设备</string>
<string name="bridge_notification_control_body">Bridge 正在运行——可随时点按“停用”停止。</string>
<string name="gateway_keepalive_title">Hermes 连接已启用</string>
@@ -4096,4 +4103,87 @@
<string name="host_resource_memory_elevated">主机内存不足。</string>
<string name="host_resource_disk_critical">主机存储空间严重不足,消息和设置可能无法保存。</string>
<string name="host_resource_disk_elevated">主机存储空间不足。</string>
<string name="bss_capabilities_title">功能授权</string>
<string name="bss_capabilities_desc">始终/从不仅适用于此 Hermes 连接。主开关和 Android 权限仍具有优先权。</string>
<string name="bss_capabilities_unavailable">请先选择并连接 Hermes 连接,然后再更改授权。</string>
<string name="bss_timed_capabilities_title">屏幕访问</string>
<string name="bss_timed_capabilities_desc">屏幕检查和控制在空闲 %1$d 分钟后到期。永久授权不会延长此计时器。</string>
<string name="bss_capability_always">主开关开启时始终允许</string>
<string name="bss_capability_never">从不</string>
<string name="bss_capability_timed_on">限时访问已启用</string>
<string name="bss_capability_timed_off">限时访问已关闭</string>
<string name="bss_capability_device_info">设备和应用信息</string>
<string name="bss_capability_contacts">读取联系人</string>
<string name="bss_capability_location">读取位置</string>
<string name="bss_capability_clipboard_read">读取剪贴板</string>
<string name="bss_capability_clipboard_write">写入剪贴板</string>
<string name="bss_capability_media">控制媒体播放</string>
<string name="bss_capability_communications">发送短信和拨打电话</string>
<string name="bss_capability_sharing">共享媒体和编写彩信</string>
<string name="bss_capability_screen_inspection">检查屏幕和界面</string>
<string name="bss_capability_screen_control">控制应用和导航</string>
<string name="unattended_requires_timed_control">需要屏幕控制,请先在“代理访问”中允许。</string>
<string name="bridge_access_title">代理访问</string>
<string name="bridge_access_not_set_up">未设置</string>
<string name="bridge_access_summary_desc">选择 Hermes 可在此连接中使用的功能。</string>
<string name="bridge_access_set_up">设置访问</string>
<string name="bridge_access_always">始终可用</string>
<string name="bridge_access_enabled_count">已启用 %1$d/%2$d 项功能</string>
<string name="bridge_access_screen">屏幕访问</string>
<string name="bridge_access_screen_active">检查或控制暂时处于启用状态</string>
<string name="bridge_access_screen_off">检查和控制已关闭</string>
<string name="bridge_access_allow_duration">允许…</string>
<string name="bridge_access_unattended_on">可在此会话期间唤醒屏幕</string>
<string name="bridge_access_unattended_available">屏幕访问有效时可用</string>
<string name="bridge_access_unattended_needs_screen">需要临时屏幕访问</string>
<string name="bridge_access_preset_read_only">只读</string>
<string name="bridge_access_preset_confirmed_short">读取 + 操作</string>
<string name="bridge_access_preset_confirmed">读取 + 确认后操作</string>
<string name="bridge_access_preset_custom">自定义</string>
<string name="bridge_access_choose_title">选择代理访问</string>
<string name="bridge_access_choose_desc">先选择预设,然后再调整各项功能。</string>
<string name="bridge_access_preset_read_only_desc">设备信息、联系人、位置和读取剪贴板</string>
<string name="bridge_access_preset_confirmed_desc">还允许在确认后拨打电话、发送短信和共享</string>
<string name="bridge_access_preset_custom_desc">自行选择每项功能</string>
<string name="bridge_access_recommended">推荐</string>
<string name="bridge_access_temporary_title">临时屏幕访问</string>
<string name="bridge_access_temporary_desc">屏幕检查和控制在空闲后始终会到期。</string>
<string name="bridge_access_continue">继续</string>
<string name="bridge_access_saved_review_android">访问设置已保存。启用主开关前,请检查所需的 Android 访问权限。</string>
<string name="bridge_access_read_group">读取访问</string>
<string name="bridge_access_read_group_desc">选择主开关开启时 Hermes 可以读取的设备数据。</string>
<string name="bridge_access_actions_group">操作</string>
<string name="bridge_access_actions_group_desc">即使启用,电话、短信和共享仍需确认。</string>
<string name="bridge_android_access_title">Android 访问</string>
<string name="bridge_android_access_none">选择功能前不需要 Android 访问权限。</string>
<string name="bridge_android_access_ready">活动功能所需的 Android 访问权限均已就绪。</string>
<string name="bridge_android_access_missing">已就绪 %1$d/%2$d · 缺少 %3$d</string>
<string name="bridge_android_access_hide">隐藏</string>
<string name="bridge_android_access_review">检查</string>
<string name="bridge_android_ready_short">就绪</string>
<string name="bridge_android_missing_short">缺少</string>
<string name="bridge_android_selected_needs">所选访问所需</string>
<string name="bridge_android_selected_needs_desc">仍缺少这些 Android 权限。未启用的功能不包括在内。</string>
<string name="bridge_android_open_settings">打开</string>
<string name="bridge_timed_title">允许屏幕访问</string>
<string name="bridge_timed_desc">选择 Hermes 可以执行的操作及其可用时长。</string>
<string name="bridge_timed_inspection_desc">无障碍树、界面事件和屏幕截图</string>
<string name="bridge_timed_control_desc">点按、输入、滑动、打开应用和导航</string>
<string name="bridge_timed_ends_title">空闲后结束</string>
<string name="bridge_timed_prerequisites">屏幕访问的前提条件</string>
<string name="bridge_timed_capture_note">首次使用屏幕截图时会请求屏幕捕获同意。</string>
<string name="bridge_timed_allow">允许访问</string>
<string name="bridge_timed_end_now">立即结束</string>
<string name="bridge_timed_ended_snackbar">屏幕访问已结束。永久授权仍然可用。</string>
<string name="unattended_description_unlimited">设备无空闲超时,始终保持可用。直到结束屏幕访问、关闭主开关或在此关闭为止。</string>
<string name="bridge_access_screen_unlimited">检查或控制将保持有效,直到关闭</string>
<string name="bridge_access_until_off_short">直到关闭</string>
<string name="bridge_access_unattended_unlimited">屏幕访问关闭前始终可用</string>
<string name="bridge_timed_lifetime_title">访问如何结束</string>
<string name="bridge_timed_scope_title">Hermes 可以执行的操作</string>
<string name="bridge_timed_until_off">直到关闭</string>
<string name="bridge_timed_idle_explainer">没有固定会话上限。活动屏幕命令会刷新 %1$s 空闲计时器。</string>
<string name="bridge_timed_unlimited_warning">无空闲超时。屏幕访问在空闲和重新连接后仍保持,直到结束访问、关闭主开关或更改策略。适合专用设备。</string>
<string name="bss_screen_access_off_desc">屏幕访问已关闭。新的有限访问默认使用 %1$d 分钟空闲限制。</string>
<string name="bss_screen_access_unlimited_desc">至少一项屏幕功能会保持有效,直到明确关闭。</string>
</resources>
+95 -5
View File
@@ -135,6 +135,13 @@
<string name="chat_share_conversation">Unterhaltung teilen</string>
<string name="chat_retry">Erneut versuchen</string>
<string name="chat_dismiss">Schließen</string>
<string name="chat_failure_title">Hermes konnte diese Antwort nicht abschließen</string>
<string name="chat_failure_details">Details</string>
<string name="chat_failure_details_title">Antwortfehler</string>
<string name="chat_failure_details_guidance">Hermes hat diesen Fehler gemeldet. Die App wechselt Routen oder Modelle nicht automatisch.</string>
<string name="chat_failure_copy_details">Details kopieren</string>
<string name="chat_failure_route_gateway">Gateway</string>
<string name="chat_failure_route_api">API-Fallback</string>
<string name="chat_open_settings">Einstellungen öffnen</string>
<string name="chat_connect_hermes">Hermes verbinden</string>
<string name="chat_try_demo">Demo ausprobieren</string>
@@ -550,7 +557,7 @@
<string name="bssc_safety">Sicherheit</string>
<string name="bssc_blocked_apps">Apps, die der Agent nicht bedienen darf</string>
<string name="bssc_destructive_verbs">Wörter, bei denen immer nachgefragt wird</string>
<string name="bssc_auto_disable">Schaltet sich bei Inaktivität aus</string>
<string name="bssc_auto_disable">Zeitgesteuerter Bildschirmzugriff</string>
<string name="bssc_guardrails_hint">Diese Schutzmechanismen halten Hermes in den Grenzen. Zum Anpassen tippen.</string>
<string name="bssc_manage">Verwalten</string>
@@ -928,7 +935,7 @@
<string name="bridge_settings">Einstellungen</string>
<string name="bridge_paired_title">Smartphone-Bridge ist gekoppelt</string>
<string name="bridge_staged_title">Bridge-Steuerung ist vorbereitet</string>
<string name="bridge_paired_subtitle">Terminal, Sprache, Benachrichtigungen, Medien und erweiterte Smartphone-Steuerung verwenden diese Berechtigung gemeinsam.</string>
<string name="bridge_paired_subtitle">Die Relay-Kopplung ist aktiv. Smartphone-Befehle folgen zusätzlich dem Agentenzugriff unten.</string>
<string name="bridge_staged_subtitle">Kopple Relay, um Bridge-Befehle zu empfangen. Berechtigungen und Sicherheit kannst du schon vor der Kopplung konfigurieren.</string>
<string name="bridge_relay_label">Relay</string>
<string name="bridge_safety_label">Sicherheit</string>
@@ -995,7 +1002,7 @@
<string name="bss_add_verb_cd">Verb hinzufügen</string>
<string name="bss_remove_cd">Entfernen</string>
<string name="bss_auto_disable">Bei Inaktivität automatisch deaktivieren</string>
<string name="bss_auto_disable_desc">Der Hauptschalter wird nach so vielen Minuten ohne Bridge-Befehle ausgeschaltet. Der Timer startet bei jedem Befehl neu.</string>
<string name="bss_auto_disable_desc">Der zeitgesteuerte Bildschirmzugriff läuft nach dieser Leerlaufzeit ab. Nur Bildschirmprüfung oder -steuerung setzt den Timer zurück.</string>
<string name="bss_auto_disable_value">%1$d Min.</string>
<string name="bss_status_overlay">Status-Overlay</string>
<string name="bss_show_floating">Schwebende Anzeige einblenden</string>
@@ -3318,8 +3325,8 @@
<string name="whats_new_title">Neuigkeiten</string>
<string name="whats_new_subtitle">Highlights der neuesten Version</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Bridge automatisch deaktiviert</string>
<string name="bridge_notification_auto_disabled_body">Nach Inaktivität pausiert — zum erneuten Aktivieren im Bridge-Tab tippen.</string>
<string name="bridge_notification_auto_disabled_title">Zeitgesteuerter Bildschirmzugriff abgelaufen</string>
<string name="bridge_notification_auto_disabled_body">Bildschirmprüfung und -steuerung sind nach Inaktivität aus. Andere Berechtigungen bleiben unverändert.</string>
<string name="bridge_notification_control_title">Hermes-Agent hat Gerätesteuerung</string>
<string name="bridge_notification_control_body">Bridge ist aktiv — tippe jederzeit zum Beenden auf Deaktivieren.</string>
<string name="gateway_keepalive_title">Hermes-Verbindung aktiv</string>
@@ -4171,4 +4178,87 @@
<string name="host_resource_memory_elevated">Der Arbeitsspeicher des Hosts wird knapp.</string>
<string name="host_resource_disk_critical">Der Speicherplatz des Hosts ist äußerst knapp; Nachrichten und Einstellungen können möglicherweise nicht gespeichert werden.</string>
<string name="host_resource_disk_elevated">Der Speicherplatz des Hosts wird knapp.</string>
<string name="bss_capabilities_title">Funktionsberechtigungen</string>
<string name="bss_capabilities_desc">Immer/Nie gilt nur für diese Hermes-Verbindung. Hauptschalter und Android-Berechtigungen haben weiterhin Vorrang.</string>
<string name="bss_capabilities_unavailable">Wähle und verbinde zuerst eine Hermes-Verbindung.</string>
<string name="bss_timed_capabilities_title">Bildschirmzugriff</string>
<string name="bss_timed_capabilities_desc">Bildschirmprüfung und -steuerung laufen nach %1$d Minuten Inaktivität ab. Dauerhafte Berechtigungen verlängern diesen Timer nicht.</string>
<string name="bss_capability_always">Immer, solange der Hauptschalter aktiv ist</string>
<string name="bss_capability_never">Nie</string>
<string name="bss_capability_timed_on">Zeitgesteuerter Zugriff aktiv</string>
<string name="bss_capability_timed_off">Zeitgesteuerter Zugriff aus</string>
<string name="bss_capability_device_info">Geräte- und App-Infos</string>
<string name="bss_capability_contacts">Kontakte lesen</string>
<string name="bss_capability_location">Standort lesen</string>
<string name="bss_capability_clipboard_read">Zwischenablage lesen</string>
<string name="bss_capability_clipboard_write">Zwischenablage schreiben</string>
<string name="bss_capability_media">Medienwiedergabe steuern</string>
<string name="bss_capability_communications">SMS senden und Anrufe tätigen</string>
<string name="bss_capability_sharing">Medien teilen und MMS verfassen</string>
<string name="bss_capability_screen_inspection">Bildschirm und UI prüfen</string>
<string name="bss_capability_screen_control">Apps und Navigation steuern</string>
<string name="unattended_requires_timed_control">Erfordert Bildschirmsteuerung — zuerst unter Agentenzugriff erlauben.</string>
<string name="bridge_access_title">Agentenzugriff</string>
<string name="bridge_access_not_set_up">Nicht eingerichtet</string>
<string name="bridge_access_summary_desc">Lege fest, was Hermes in dieser Verbindung verwenden darf.</string>
<string name="bridge_access_set_up">Zugriff einrichten</string>
<string name="bridge_access_always">Immer verfügbar</string>
<string name="bridge_access_enabled_count">%1$d von %2$d Funktionen aktiviert</string>
<string name="bridge_access_screen">Bildschirmzugriff</string>
<string name="bridge_access_screen_active">Prüfung oder Steuerung ist vorübergehend aktiv</string>
<string name="bridge_access_screen_off">Prüfung und Steuerung sind aus</string>
<string name="bridge_access_allow_duration">Erlauben…</string>
<string name="bridge_access_unattended_on">Kann den Bildschirm während dieser Sitzung wecken</string>
<string name="bridge_access_unattended_available">Verfügbar, solange Bildschirmzugriff aktiv ist</string>
<string name="bridge_access_unattended_needs_screen">Benötigt temporären Bildschirmzugriff</string>
<string name="bridge_access_preset_read_only">Nur Lesen</string>
<string name="bridge_access_preset_confirmed_short">Lesen + Aktionen</string>
<string name="bridge_access_preset_confirmed">Lesen + bestätigte Aktionen</string>
<string name="bridge_access_preset_custom">Benutzerdefiniert</string>
<string name="bridge_access_choose_title">Agentenzugriff wählen</string>
<string name="bridge_access_choose_desc">Beginne mit einer Vorlage und passe einzelne Funktionen danach an.</string>
<string name="bridge_access_preset_read_only_desc">Geräteinfos, Kontakte, Standort und Zwischenablage lesen</string>
<string name="bridge_access_preset_confirmed_desc">Erlaubt zusätzlich Anrufe, SMS und Teilen mit Bestätigung</string>
<string name="bridge_access_preset_custom_desc">Jede Funktion selbst auswählen</string>
<string name="bridge_access_recommended">Empfohlen</string>
<string name="bridge_access_temporary_title">Temporärer Bildschirmzugriff</string>
<string name="bridge_access_temporary_desc">Bildschirmprüfung und -steuerung laufen nach Inaktivität immer ab.</string>
<string name="bridge_access_continue">Weiter</string>
<string name="bridge_access_saved_review_android">Zugriff gespeichert. Prüfe vor dem Aktivieren des Hauptschalters die benötigten Android-Berechtigungen.</string>
<string name="bridge_access_read_group">Lesezugriff</string>
<string name="bridge_access_read_group_desc">Wähle, welche Gerätedaten Hermes bei aktivem Hauptschalter lesen darf.</string>
<string name="bridge_access_actions_group">Aktionen</string>
<string name="bridge_access_actions_group_desc">Anrufe, SMS und Teilen erfordern auch aktiviert eine Bestätigung.</string>
<string name="bridge_android_access_title">Android-Zugriff</string>
<string name="bridge_android_access_none">Bis zur Auswahl von Funktionen ist kein Android-Zugriff nötig.</string>
<string name="bridge_android_access_ready">Alle für aktive Funktionen benötigten Android-Berechtigungen sind bereit.</string>
<string name="bridge_android_access_missing">%1$d von %2$d bereit · %3$d fehlen</string>
<string name="bridge_android_access_hide">Ausblenden</string>
<string name="bridge_android_access_review">Prüfen</string>
<string name="bridge_android_ready_short">Bereit</string>
<string name="bridge_android_missing_short">Fehlt</string>
<string name="bridge_android_selected_needs">Für den gewählten Zugriff erforderlich</string>
<string name="bridge_android_selected_needs_desc">Diese Android-Berechtigungen fehlen noch. Deaktivierte Funktionen sind nicht enthalten.</string>
<string name="bridge_android_open_settings">Öffnen</string>
<string name="bridge_timed_title">Bildschirmzugriff erlauben</string>
<string name="bridge_timed_desc">Wähle, was Hermes tun darf und wie lange es verfügbar bleibt.</string>
<string name="bridge_timed_inspection_desc">Bedienungshilfen-Baum, UI-Ereignisse und Screenshots</string>
<string name="bridge_timed_control_desc">Tippen, schreiben, wischen, Apps öffnen und navigieren</string>
<string name="bridge_timed_ends_title">Endet nach Inaktivität</string>
<string name="bridge_timed_prerequisites">Voraussetzungen für Bildschirmzugriff</string>
<string name="bridge_timed_capture_note">Die Bildschirmaufnahme wird beim ersten Screenshot angefragt.</string>
<string name="bridge_timed_allow">Zugriff erlauben</string>
<string name="bridge_timed_end_now">Jetzt beenden</string>
<string name="bridge_timed_ended_snackbar">Bildschirmzugriff beendet. Dauerhafte Berechtigungen bleiben verfügbar.</string>
<string name="unattended_description_unlimited">Hält dieses Gerät ohne Leerlaufzeitlimit verfügbar. Bleibt aktiv, bis Bildschirmzugriff oder Hauptschalter beendet oder diese Option ausgeschaltet wird.</string>
<string name="bridge_access_screen_unlimited">Prüfung oder Steuerung bleibt bis zum Ausschalten aktiv</string>
<string name="bridge_access_until_off_short">Bis Aus</string>
<string name="bridge_access_unattended_unlimited">Immer verfügbar, bis Bildschirmzugriff ausgeschaltet wird</string>
<string name="bridge_timed_lifetime_title">So endet der Zugriff</string>
<string name="bridge_timed_scope_title">Was Hermes tun darf</string>
<string name="bridge_timed_until_off">Bis zum Ausschalten</string>
<string name="bridge_timed_idle_explainer">Kein festes Sitzungslimit. Aktive Bildschirmbefehle erneuern den Leerlauf-Timer von %1$s.</string>
<string name="bridge_timed_unlimited_warning">Kein Leerlaufzeitlimit. Bildschirmzugriff bleibt bei Inaktivität und Wiederverbindung aktiv, bis er beendet, der Hauptschalter deaktiviert oder die Richtlinie geändert wird. Für ein dediziertes Gerät.</string>
<string name="bss_screen_access_off_desc">Bildschirmzugriff ist aus. Neuer begrenzter Zugriff verwendet standardmäßig %1$d Minuten Leerlauf.</string>
<string name="bss_screen_access_unlimited_desc">Mindestens eine Bildschirmfunktion bleibt bis zum ausdrücklichen Ausschalten aktiv.</string>
</resources>
+95 -5
View File
@@ -112,6 +112,13 @@
<string name="chat_share_conversation">Compartir conversación</string>
<string name="chat_retry">Reintentar</string>
<string name="chat_dismiss">Descartar</string>
<string name="chat_failure_title">Hermes no pudo completar esta respuesta</string>
<string name="chat_failure_details">Detalles</string>
<string name="chat_failure_details_title">Error de respuesta</string>
<string name="chat_failure_details_guidance">Hermes informó de este error. La aplicación no cambiará automáticamente de ruta ni de modelo.</string>
<string name="chat_failure_copy_details">Copiar detalles</string>
<string name="chat_failure_route_gateway">Gateway</string>
<string name="chat_failure_route_api">API alternativa</string>
<string name="chat_open_settings">Abrir configuración</string>
<string name="chat_connect_hermes">Conectar Hermes</string>
<string name="chat_try_demo">Pruebe la demostración</string>
@@ -497,7 +504,7 @@
<string name="bssc_safety">Seguridad</string>
<string name="bssc_blocked_apps">Aplicaciones que el agente no puede tocar</string>
<string name="bssc_destructive_verbs">Palabras que siempre preguntan primero</string>
<string name="bssc_auto_disable">Se apaga solo cuando está inactivo</string>
<string name="bssc_auto_disable">Acceso de pantalla temporizado</string>
<string name="bssc_guardrails_hint">Estas barandillas mantienen a Hermes dentro de los límites. Toque para ajustar.</string>
<string name="bssc_manage">Administrar</string>
<string name="bal_activity_log">Registro de actividad</string>
@@ -841,7 +848,7 @@
<string name="bridge_settings">Ajustes</string>
<string name="bridge_paired_title">El teléfono bridge está emparejado</string>
<string name="bridge_staged_title">Los controles Bridge están preparados</string>
<string name="bridge_paired_subtitle">Los controles de terminal, voz, notificaciones, medios y teléfonos avanzados comparten esta subvención.</string>
<string name="bridge_paired_subtitle">El emparejamiento Relay está activo. Los comandos del teléfono también respetan el acceso del agente inferior.</string>
<string name="bridge_staged_subtitle">Empareje Relay para recibir comandos bridge. Aún puedes configurar permisos y seguridad antes del emparejamiento.</string>
<string name="bridge_relay_label">relay</string>
<string name="bridge_safety_label">seguridad</string>
@@ -904,7 +911,7 @@
<string name="bss_add_verb_cd">Agregar verbo</string>
<string name="bss_remove_cd">Eliminar</string>
<string name="bss_auto_disable">Desactivación automática después de inactividad</string>
<string name="bss_auto_disable_desc">El interruptor maestro se apaga después de tantos minutos sin comandos bridge. Reprogramado en cada comando.</string>
<string name="bss_auto_disable_desc">El acceso de pantalla temporizado vence tras estos minutos de inactividad. Solo la inspección o el control de pantalla reinician el temporizador.</string>
<string name="bss_auto_disable_value">%1$d mín.</string>
<string name="bss_status_overlay">Superposición de estado</string>
<string name="bss_show_floating">Mostrar indicador flotante</string>
@@ -2975,8 +2982,8 @@
<string name="whats_new_no_notes">No hay notas de versión disponibles</string>
<string name="whats_new_title">¿Qué hay de nuevo?</string>
<string name="whats_new_subtitle">Novedades de la última versión</string>
<string name="bridge_notification_auto_disabled_title">Bridge auto deshabilitado</string>
<string name="bridge_notification_auto_disabled_body">En pausa después de inactivo: toque para volver a habilitarlo en la pestaña Bridge.</string>
<string name="bridge_notification_auto_disabled_title">Acceso de pantalla temporizado vencido</string>
<string name="bridge_notification_auto_disabled_body">La inspección y el control de pantalla están desactivados tras la inactividad. Los demás permisos no cambian.</string>
<string name="bridge_notification_control_title">El agente Hermes tiene control del dispositivo.</string>
<string name="bridge_notification_control_body">Bridge está activo: toque Desactivar para detenerlo en cualquier momento.</string>
<string name="gateway_keepalive_title">Conexión Hermes activa</string>
@@ -3856,4 +3863,87 @@
<string name="host_resource_memory_elevated">La memoria del servidor se está agotando.</string>
<string name="host_resource_disk_critical">El almacenamiento del servidor está casi agotado; puede que los mensajes y ajustes no se guarden.</string>
<string name="host_resource_disk_elevated">El almacenamiento del servidor se está agotando.</string>
<string name="bss_capabilities_title">Permisos de capacidades</string>
<string name="bss_capabilities_desc">Siempre/Nunca solo se aplica a esta conexión de Hermes. El interruptor maestro y los permisos de Android siguen teniendo prioridad.</string>
<string name="bss_capabilities_unavailable">Selecciona y conecta una conexión de Hermes antes de cambiar permisos.</string>
<string name="bss_timed_capabilities_title">Acceso a la pantalla</string>
<string name="bss_timed_capabilities_desc">La inspección y el control de pantalla vencen tras %1$d minutos de inactividad. Los permisos permanentes no amplían este tiempo.</string>
<string name="bss_capability_always">Siempre, mientras el interruptor maestro esté activo</string>
<string name="bss_capability_never">Nunca</string>
<string name="bss_capability_timed_on">Acceso temporizado activo</string>
<string name="bss_capability_timed_off">Acceso temporizado desactivado</string>
<string name="bss_capability_device_info">Información del dispositivo y apps</string>
<string name="bss_capability_contacts">Leer contactos</string>
<string name="bss_capability_location">Leer ubicación</string>
<string name="bss_capability_clipboard_read">Leer portapapeles</string>
<string name="bss_capability_clipboard_write">Escribir en el portapapeles</string>
<string name="bss_capability_media">Controlar reproducción multimedia</string>
<string name="bss_capability_communications">Enviar SMS y hacer llamadas</string>
<string name="bss_capability_sharing">Compartir archivos y redactar MMS</string>
<string name="bss_capability_screen_inspection">Inspeccionar pantalla e interfaz</string>
<string name="bss_capability_screen_control">Controlar apps y navegación</string>
<string name="unattended_requires_timed_control">Requiere control de pantalla; permítelo primero en Acceso del agente.</string>
<string name="bridge_access_title">Acceso del agente</string>
<string name="bridge_access_not_set_up">Sin configurar</string>
<string name="bridge_access_summary_desc">Elige qué puede usar Hermes en esta conexión.</string>
<string name="bridge_access_set_up">Configurar acceso</string>
<string name="bridge_access_always">Siempre disponible</string>
<string name="bridge_access_enabled_count">%1$d de %2$d capacidades activadas</string>
<string name="bridge_access_screen">Acceso a la pantalla</string>
<string name="bridge_access_screen_active">La inspección o el control están activos temporalmente</string>
<string name="bridge_access_screen_off">La inspección y el control están desactivados</string>
<string name="bridge_access_allow_duration">Permitir…</string>
<string name="bridge_access_unattended_on">Puede activar la pantalla durante esta sesión</string>
<string name="bridge_access_unattended_available">Disponible mientras el acceso a pantalla esté activo</string>
<string name="bridge_access_unattended_needs_screen">Necesita acceso temporal a la pantalla</string>
<string name="bridge_access_preset_read_only">Solo lectura</string>
<string name="bridge_access_preset_confirmed_short">Lectura + acciones</string>
<string name="bridge_access_preset_confirmed">Lectura + acciones confirmadas</string>
<string name="bridge_access_preset_custom">Personalizado</string>
<string name="bridge_access_choose_title">Elegir acceso del agente</string>
<string name="bridge_access_choose_desc">Empieza con un ajuste y luego modifica capacidades individuales.</string>
<string name="bridge_access_preset_read_only_desc">Información del dispositivo, contactos, ubicación y lectura del portapapeles</string>
<string name="bridge_access_preset_confirmed_desc">También permite llamadas, SMS y compartir con confirmación</string>
<string name="bridge_access_preset_custom_desc">Elige cada capacidad</string>
<string name="bridge_access_recommended">Recomendado</string>
<string name="bridge_access_temporary_title">Acceso temporal a la pantalla</string>
<string name="bridge_access_temporary_desc">La inspección y el control de pantalla siempre vencen tras la inactividad.</string>
<string name="bridge_access_continue">Continuar</string>
<string name="bridge_access_saved_review_android">Acceso guardado. Revisa el acceso de Android necesario antes de activar el interruptor maestro.</string>
<string name="bridge_access_read_group">Acceso de lectura</string>
<string name="bridge_access_read_group_desc">Elige qué datos del dispositivo puede leer Hermes con el interruptor maestro activo.</string>
<string name="bridge_access_actions_group">Acciones</string>
<string name="bridge_access_actions_group_desc">Las llamadas, SMS y el uso compartido siguen requiriendo confirmación.</string>
<string name="bridge_android_access_title">Acceso de Android</string>
<string name="bridge_android_access_none">No se necesita acceso de Android hasta que elijas capacidades.</string>
<string name="bridge_android_access_ready">Todo el acceso de Android necesario para las capacidades activas está listo.</string>
<string name="bridge_android_access_missing">%1$d de %2$d listos · faltan %3$d</string>
<string name="bridge_android_access_hide">Ocultar</string>
<string name="bridge_android_access_review">Revisar</string>
<string name="bridge_android_ready_short">Listo</string>
<string name="bridge_android_missing_short">Falta</string>
<string name="bridge_android_selected_needs">Necesario para el acceso seleccionado</string>
<string name="bridge_android_selected_needs_desc">Aún faltan estos permisos de Android. No se incluyen capacidades desactivadas.</string>
<string name="bridge_android_open_settings">Abrir</string>
<string name="bridge_timed_title">Permitir acceso a la pantalla</string>
<string name="bridge_timed_desc">Elige qué puede hacer Hermes y cuánto tiempo seguirá disponible.</string>
<string name="bridge_timed_inspection_desc">Árbol de accesibilidad, eventos de interfaz y capturas</string>
<string name="bridge_timed_control_desc">Tocar, escribir, deslizar, abrir apps y navegar</string>
<string name="bridge_timed_ends_title">Finaliza tras la inactividad</string>
<string name="bridge_timed_prerequisites">Requisitos para el acceso a pantalla</string>
<string name="bridge_timed_capture_note">El consentimiento de captura se solicita al usar la primera captura.</string>
<string name="bridge_timed_allow">Permitir acceso</string>
<string name="bridge_timed_end_now">Finalizar ahora</string>
<string name="bridge_timed_ended_snackbar">El acceso a pantalla terminó. Los permisos permanentes siguen disponibles.</string>
<string name="unattended_description_unlimited">Mantiene este dispositivo disponible sin límite de inactividad. Sigue activo hasta finalizar el acceso a pantalla, desactivar el interruptor maestro o apagarlo aquí.</string>
<string name="bridge_access_screen_unlimited">La inspección o el control siguen activos hasta desactivarlos</string>
<string name="bridge_access_until_off_short">Hasta apagar</string>
<string name="bridge_access_unattended_unlimited">Siempre disponible hasta desactivar el acceso a pantalla</string>
<string name="bridge_timed_lifetime_title">Cómo finaliza el acceso</string>
<string name="bridge_timed_scope_title">Qué puede hacer Hermes</string>
<string name="bridge_timed_until_off">Hasta desactivarlo</string>
<string name="bridge_timed_idle_explainer">Sin límite fijo de sesión. Los comandos activos reinician el temporizador de inactividad de %1$s.</string>
<string name="bridge_timed_unlimited_warning">Sin límite de inactividad. El acceso continúa tras inactividad y reconexión hasta finalizarlo, desactivar el interruptor maestro o cambiar la política. Ideal para un dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">El acceso a pantalla está desactivado. El acceso finito nuevo usa %1$d minutos de inactividad por defecto.</string>
<string name="bss_screen_access_unlimited_desc">Al menos una capacidad de pantalla permanece activa hasta desactivarla explícitamente.</string>
</resources>
+95 -5
View File
@@ -135,6 +135,13 @@
<string name="chat_share_conversation">会話を共有する</string>
<string name="chat_retry">リトライ</string>
<string name="chat_dismiss">却下する</string>
<string name="chat_failure_title">Hermes はこの応答を完了できませんでした</string>
<string name="chat_failure_details">詳細</string>
<string name="chat_failure_details_title">応答エラー</string>
<string name="chat_failure_details_guidance">Hermes からこのエラーが報告されました。アプリがルートやモデルを自動的に切り替えることはありません。</string>
<string name="chat_failure_copy_details">詳細をコピー</string>
<string name="chat_failure_route_gateway">ゲートウェイ</string>
<string name="chat_failure_route_api">API フォールバック</string>
<string name="chat_open_settings">設定を開く</string>
<string name="chat_connect_hermes">Hermesを接続してください</string>
<string name="chat_try_demo">デモを試してみる</string>
@@ -550,7 +557,7 @@
<string name="bssc_safety">安全性</string>
<string name="bssc_blocked_apps">エージェントがアクセスできないアプリ</string>
<string name="bssc_destructive_verbs">いつも最初に尋ねられる言葉</string>
<string name="bssc_auto_disable">アイドル時は自動的にオフになります</string>
<string name="bssc_auto_disable">時間制限付き画面アクセス</string>
<string name="bssc_guardrails_hint">これらのガードレールは Hermes を境界内に保ちます。タップして調整します。</string>
<string name="bssc_manage">管理</string>
@@ -941,7 +948,7 @@
<string name="bridge_settings">設定</string>
<string name="bridge_paired_title">電話Bridgeがペアリングされています</string>
<string name="bridge_staged_title">Bridge コントロールはステージングされています</string>
<string name="bridge_paired_subtitle">端末、音声、通知、メディア、および高度な電話制御がこの許可を共有します。</string>
<string name="bridge_paired_subtitle">Relay のペアリングは有効です。スマートフォンのコマンドには下のエージェントアクセスも適用されます。</string>
<string name="bridge_staged_subtitle">Relay をペアにしてBridge コマンドを受信します。ペアリングする前に権限と安全性を設定することもできます。</string>
<string name="bridge_relay_label">Relay</string>
<string name="bridge_safety_label">安全性</string>
@@ -1008,7 +1015,7 @@
<string name="bss_add_verb_cd">動詞を追加</string>
<string name="bss_remove_cd">削除</string>
<string name="bss_auto_disable">アイドル後に自動無効化</string>
<string name="bss_auto_disable_desc">Bridge コマンドがない状態でこの時間が経過すると、マスター トグルがオフになります。コマンドごとにスケジュールが変更されます。</string>
<string name="bss_auto_disable_desc">画面アクセスは、このアイドル時間の後に期限切れになります。画面の検査または操作だけがタイマーをリセットします。</string>
<string name="bss_auto_disable_value">%1$d分</string>
<string name="bss_status_overlay">ステータスオーバーレイ</string>
<string name="bss_show_floating">フローティングインジケーターを表示</string>
@@ -3328,8 +3335,8 @@
<string name="whats_new_title">新機能</string>
<string name="whats_new_subtitle">最新リリースのハイライト</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Bridge 自動無効化</string>
<string name="bridge_notification_auto_disabled_body">アイドル後に一時停止 — タップして、Bridge タブで再度有効にします。</string>
<string name="bridge_notification_auto_disabled_title">時間制限付き画面アクセスの期限切れ</string>
<string name="bridge_notification_auto_disabled_body">操作がなかったため、画面の検査と操作をオフにしました。他の許可は変更されません。</string>
<string name="bridge_notification_control_title">Hermes エージェントはデバイスを制御できます</string>
<string name="bridge_notification_control_body">Bridge はアクティブです。[無効にする] をタップすると、いつでも停止できます。</string>
<string name="gateway_keepalive_title">Hermes 接続がアクティブです</string>
@@ -4169,4 +4176,87 @@
<string name="host_resource_memory_elevated">ホストのメモリが不足しています。</string>
<string name="host_resource_disk_critical">ホストのストレージが極端に不足しています。メッセージや設定を保存できない可能性があります。</string>
<string name="host_resource_disk_elevated">ホストのストレージが不足しています。</string>
<string name="bss_capabilities_title">機能の許可</string>
<string name="bss_capabilities_desc">常時/拒否の設定は、この Hermes 接続だけに適用されます。マスタースイッチと Android の権限が常に優先されます。</string>
<string name="bss_capabilities_unavailable">許可を変更する前に Hermes 接続を選択して接続してください。</string>
<string name="bss_timed_capabilities_title">画面アクセス</string>
<string name="bss_timed_capabilities_desc">画面の検査と操作は、%1$d 分間操作がないと期限切れになります。永続的な許可ではタイマーは延長されません。</string>
<string name="bss_capability_always">マスタースイッチがオンの間は常時</string>
<string name="bss_capability_never">拒否</string>
<string name="bss_capability_timed_on">時間制限付きアクセスは有効</string>
<string name="bss_capability_timed_off">時間制限付きアクセスはオフ</string>
<string name="bss_capability_device_info">端末とアプリの情報</string>
<string name="bss_capability_contacts">連絡先を読む</string>
<string name="bss_capability_location">位置情報を読む</string>
<string name="bss_capability_clipboard_read">クリップボードを読む</string>
<string name="bss_capability_clipboard_write">クリップボードに書き込む</string>
<string name="bss_capability_media">メディア再生を操作</string>
<string name="bss_capability_communications">SMS 送信と通話</string>
<string name="bss_capability_sharing">メディア共有と MMS 作成</string>
<string name="bss_capability_screen_inspection">画面と UI を検査</string>
<string name="bss_capability_screen_control">アプリとナビゲーションを操作</string>
<string name="unattended_requires_timed_control">画面操作が必要です。先に「エージェントのアクセス」で許可してください。</string>
<string name="bridge_access_title">エージェントのアクセス</string>
<string name="bridge_access_not_set_up">未設定</string>
<string name="bridge_access_summary_desc">この Hermes 接続で使用できる機能を選びます。</string>
<string name="bridge_access_set_up">アクセスを設定</string>
<string name="bridge_access_always">常時利用可能</string>
<string name="bridge_access_enabled_count">%2$d 件中 %1$d 件を有効化</string>
<string name="bridge_access_screen">画面アクセス</string>
<string name="bridge_access_screen_active">検査または操作が一時的に有効です</string>
<string name="bridge_access_screen_off">検査と操作はオフです</string>
<string name="bridge_access_allow_duration">許可…</string>
<string name="bridge_access_unattended_on">このセッション中に画面を起動できます</string>
<string name="bridge_access_unattended_available">画面アクセスが有効な間に利用できます</string>
<string name="bridge_access_unattended_needs_screen">一時的な画面アクセスが必要です</string>
<string name="bridge_access_preset_read_only">読み取り専用</string>
<string name="bridge_access_preset_confirmed_short">読み取り + 操作</string>
<string name="bridge_access_preset_confirmed">読み取り + 確認付き操作</string>
<string name="bridge_access_preset_custom">カスタム</string>
<string name="bridge_access_choose_title">エージェントのアクセスを選択</string>
<string name="bridge_access_choose_desc">プリセットから始め、後で個別の機能を調整できます。</string>
<string name="bridge_access_preset_read_only_desc">端末情報、連絡先、位置情報、クリップボードの読み取り</string>
<string name="bridge_access_preset_confirmed_desc">確認付きで通話、SMS、共有も許可します</string>
<string name="bridge_access_preset_custom_desc">各機能を自分で選択します</string>
<string name="bridge_access_recommended">推奨</string>
<string name="bridge_access_temporary_title">一時的な画面アクセス</string>
<string name="bridge_access_temporary_desc">画面の検査と操作は、操作がないと必ず期限切れになります。</string>
<string name="bridge_access_continue">続行</string>
<string name="bridge_access_saved_review_android">アクセスを保存しました。マスタースイッチを有効にする前に必要な Android アクセスを確認してください。</string>
<string name="bridge_access_read_group">読み取りアクセス</string>
<string name="bridge_access_read_group_desc">マスタースイッチがオンのときに Hermes が読み取れる端末データを選びます。</string>
<string name="bridge_access_actions_group">操作</string>
<string name="bridge_access_actions_group_desc">通話、SMS、共有は有効でも確認が必要です。</string>
<string name="bridge_android_access_title">Android アクセス</string>
<string name="bridge_android_access_none">機能を選択するまで Android アクセスは必要ありません。</string>
<string name="bridge_android_access_ready">有効な機能に必要な Android アクセスはすべて準備済みです。</string>
<string name="bridge_android_access_missing">%2$d 件中 %1$d 件が準備済み · %3$d 件不足</string>
<string name="bridge_android_access_hide">隠す</string>
<string name="bridge_android_access_review">確認</string>
<string name="bridge_android_ready_short">準備済み</string>
<string name="bridge_android_missing_short">不足</string>
<string name="bridge_android_selected_needs">選択したアクセスに必要</string>
<string name="bridge_android_selected_needs_desc">次の Android 権限が不足しています。無効な機能は含まれません。</string>
<string name="bridge_android_open_settings">開く</string>
<string name="bridge_timed_title">画面アクセスを許可</string>
<string name="bridge_timed_desc">Hermes ができることと利用時間を選びます。</string>
<string name="bridge_timed_inspection_desc">アクセシビリティツリー、UI イベント、スクリーンショット</string>
<string name="bridge_timed_control_desc">タップ、入力、スワイプ、アプリ起動、ナビゲーション</string>
<string name="bridge_timed_ends_title">操作がないと終了</string>
<string name="bridge_timed_prerequisites">画面アクセスの前提条件</string>
<string name="bridge_timed_capture_note">最初のスクリーンショット使用時に画面キャプチャの同意を求めます。</string>
<string name="bridge_timed_allow">アクセスを許可</string>
<string name="bridge_timed_end_now">今すぐ終了</string>
<string name="bridge_timed_ended_snackbar">画面アクセスが終了しました。永続的な許可は引き続き利用できます。</string>
<string name="unattended_description_unlimited">アイドル制限なしで端末を利用可能にします。画面アクセスの終了、マスタースイッチの無効化、またはここでオフにするまで有効です。</string>
<string name="bridge_access_screen_unlimited">検査または操作はオフにするまで有効です</string>
<string name="bridge_access_until_off_short">オフまで</string>
<string name="bridge_access_unattended_unlimited">画面アクセスをオフにするまで常時利用可能</string>
<string name="bridge_timed_lifetime_title">アクセスの終了方法</string>
<string name="bridge_timed_scope_title">Hermes ができること</string>
<string name="bridge_timed_until_off">オフにするまで</string>
<string name="bridge_timed_idle_explainer">固定のセッション上限はありません。画面操作中は %1$s のアイドルタイマーが更新されます。</string>
<string name="bridge_timed_unlimited_warning">アイドルタイムアウトはありません。終了、マスター無効化、またはポリシー変更まで、非操作時や再接続後も画面アクセスが続きます。専用端末向けです。</string>
<string name="bss_screen_access_off_desc">画面アクセスはオフです。新しい有限アクセスの既定アイドル制限は %1$d 分です。</string>
<string name="bss_screen_access_unlimited_desc">少なくとも 1 つの画面機能が明示的にオフにするまで有効です。</string>
</resources>
+95 -5
View File
@@ -129,6 +129,13 @@
<string name="chat_share_conversation">Поделиться беседой</string>
<string name="chat_retry">Повторить</string>
<string name="chat_dismiss">Отменить</string>
<string name="chat_failure_title">Hermes не смог завершить ответ</string>
<string name="chat_failure_details">Подробности</string>
<string name="chat_failure_details_title">Ошибка ответа</string>
<string name="chat_failure_details_guidance">Hermes сообщил об этой ошибке. Приложение не будет автоматически менять маршрут или модель.</string>
<string name="chat_failure_copy_details">Копировать подробности</string>
<string name="chat_failure_route_gateway">Gateway</string>
<string name="chat_failure_route_api">Резервный API</string>
<string name="chat_open_settings">Открыть настройки</string>
<string name="chat_connect_hermes">Подключить Гермес</string>
<string name="chat_try_demo">Попробовать демо</string>
@@ -540,7 +547,7 @@
<string name="bssc_safety">Безопасность</string>
<string name="bssc_blocked_apps">Приложения, к которым агент не может получить доступ</string>
<string name="bssc_destructive_verbs">Слова, которые всегда спрашивают сначала</string>
<string name="bssc_auto_disable">Отключается само при бездействии</string>
<string name="bssc_auto_disable">Временный доступ к экрану</string>
<string name="bssc_guardrails_hint">Эти ограничения удерживают Гермеса в рамках. Нажмите, чтобы отрегулировать.</string>
<string name="bssc_manage">Управление</string>
<string name="bal_activity_log">Журнал активности</string>
@@ -949,7 +956,7 @@
<string name="bridge_settings">Настройки</string>
<string name="bridge_paired_title">Мост телефона сопряжен</string>
<string name="bridge_staged_title">Управление мостом подготовлено</string>
<string name="bridge_paired_subtitle">Терминал, голос, уведомления, медиа и расширенные функции телефона используют этот доступ.</string>
<string name="bridge_paired_subtitle">Сопряжение Relay активно. Команды телефона также учитывают доступ агента ниже.</string>
<string name="bridge_staged_subtitle">Сопряжьте плагин Relay для получения команд моста. Вы можете настроить разрешения и безопасность перед сопряжением.</string>
<string name="bridge_relay_label">Relay</string>
<string name="bridge_safety_label">безопасность</string>
@@ -1012,7 +1019,7 @@
<string name="bss_add_verb_cd">Добавить глагол</string>
<string name="bss_remove_cd">Удалить</string>
<string name="bss_auto_disable">Автоотключение после бездействия</string>
<string name="bss_auto_disable_desc">Главный переключатель отключается после этого количества минут без команд моста. Перезапланировывается при каждой команде.</string>
<string name="bss_auto_disable_desc">Временный доступ к экрану истекает после указанного периода бездействия. Таймер сбрасывают только просмотр или управление экраном.</string>
<string name="bss_auto_disable_value">%1$d мин</string>
<string name="bss_status_overlay">Индикатор состояния</string>
<string name="bss_show_floating">Показать плавающий индикатор</string>
@@ -3174,8 +3181,8 @@
<string name="whats_new_no_notes">Примечания к выпуску недоступны</string>
<string name="whats_new_title">Что нового</string>
<string name="whats_new_subtitle">Последние обновления</string>
<string name="bridge_notification_auto_disabled_title">Автоматическое отключение моста</string>
<string name="bridge_notification_auto_disabled_body">Приостановлено после бездействия — нажмите, чтобы снова включить на вкладке «Мост».</string>
<string name="bridge_notification_auto_disabled_title">Временный доступ к экрану истек</string>
<string name="bridge_notification_auto_disabled_body">Просмотр и управление экраном отключены после бездействия. Другие разрешения не изменены.</string>
<string name="bridge_notification_control_title">Агент Гермес имеет управление устройством</string>
<string name="bridge_notification_control_body">Мост активен — нажмите «Отключить», чтобы остановить в любое время.</string>
<string name="gateway_keepalive_title">Подключение Гермеса активно</string>
@@ -3898,4 +3905,87 @@
<string name="host_resource_memory_elevated">Память хоста заканчивается.</string>
<string name="host_resource_disk_critical">Хранилище хоста почти заполнено; сообщения и настройки могут не сохраниться.</string>
<string name="host_resource_disk_elevated">Хранилище хоста заканчивается.</string>
<string name="bss_capabilities_title">Разрешения возможностей</string>
<string name="bss_capabilities_desc">Всегда/Никогда действует только для этого подключения Hermes. Главный переключатель и разрешения Android имеют приоритет.</string>
<string name="bss_capabilities_unavailable">Выберите и подключите Hermes перед изменением разрешений.</string>
<string name="bss_timed_capabilities_title">Доступ к экрану</string>
<string name="bss_timed_capabilities_desc">Просмотр и управление экраном истекают через %1$d мин. бездействия. Постоянные разрешения не продлевают таймер.</string>
<string name="bss_capability_always">Всегда, пока включен главный переключатель</string>
<string name="bss_capability_never">Никогда</string>
<string name="bss_capability_timed_on">Временный доступ активен</string>
<string name="bss_capability_timed_off">Временный доступ выключен</string>
<string name="bss_capability_device_info">Сведения об устройстве и приложениях</string>
<string name="bss_capability_contacts">Чтение контактов</string>
<string name="bss_capability_location">Чтение местоположения</string>
<string name="bss_capability_clipboard_read">Чтение буфера обмена</string>
<string name="bss_capability_clipboard_write">Запись в буфер обмена</string>
<string name="bss_capability_media">Управление воспроизведением</string>
<string name="bss_capability_communications">Отправка SMS и звонки</string>
<string name="bss_capability_sharing">Отправка медиа и создание MMS</string>
<string name="bss_capability_screen_inspection">Просмотр экрана и интерфейса</string>
<string name="bss_capability_screen_control">Управление приложениями и навигацией</string>
<string name="unattended_requires_timed_control">Требуется управление экраном — сначала разрешите его в разделе доступа агента.</string>
<string name="bridge_access_title">Доступ агента</string>
<string name="bridge_access_not_set_up">Не настроено</string>
<string name="bridge_access_summary_desc">Выберите, что Hermes может использовать в этом подключении.</string>
<string name="bridge_access_set_up">Настроить доступ</string>
<string name="bridge_access_always">Всегда доступно</string>
<string name="bridge_access_enabled_count">Включено %1$d из %2$d возможностей</string>
<string name="bridge_access_screen">Доступ к экрану</string>
<string name="bridge_access_screen_active">Просмотр или управление временно активно</string>
<string name="bridge_access_screen_off">Просмотр и управление выключены</string>
<string name="bridge_access_allow_duration">Разрешить…</string>
<string name="bridge_access_unattended_on">Может включать экран во время этой сессии</string>
<string name="bridge_access_unattended_available">Доступно, пока активен доступ к экрану</string>
<string name="bridge_access_unattended_needs_screen">Нужен временный доступ к экрану</string>
<string name="bridge_access_preset_read_only">Только чтение</string>
<string name="bridge_access_preset_confirmed_short">Чтение + действия</string>
<string name="bridge_access_preset_confirmed">Чтение + подтверждаемые действия</string>
<string name="bridge_access_preset_custom">Вручную</string>
<string name="bridge_access_choose_title">Выберите доступ агента</string>
<string name="bridge_access_choose_desc">Начните с шаблона, затем настройте отдельные возможности.</string>
<string name="bridge_access_preset_read_only_desc">Сведения об устройстве, контакты, местоположение и чтение буфера</string>
<string name="bridge_access_preset_confirmed_desc">Также разрешает звонки, SMS и отправку с подтверждением</string>
<string name="bridge_access_preset_custom_desc">Выберите каждую возможность самостоятельно</string>
<string name="bridge_access_recommended">Рекомендуется</string>
<string name="bridge_access_temporary_title">Временный доступ к экрану</string>
<string name="bridge_access_temporary_desc">Просмотр и управление экраном всегда истекают после бездействия.</string>
<string name="bridge_access_continue">Продолжить</string>
<string name="bridge_access_saved_review_android">Доступ сохранен. Проверьте необходимые разрешения Android перед включением главного переключателя.</string>
<string name="bridge_access_read_group">Доступ на чтение</string>
<string name="bridge_access_read_group_desc">Выберите данные устройства, которые Hermes может читать при включенном главном переключателе.</string>
<string name="bridge_access_actions_group">Действия</string>
<string name="bridge_access_actions_group_desc">Звонки, SMS и отправка по-прежнему требуют подтверждения.</string>
<string name="bridge_android_access_title">Доступ Android</string>
<string name="bridge_android_access_none">Доступ Android не требуется, пока возможности не выбраны.</string>
<string name="bridge_android_access_ready">Все разрешения Android для активных возможностей готовы.</string>
<string name="bridge_android_access_missing">Готово %1$d из %2$d · отсутствует %3$d</string>
<string name="bridge_android_access_hide">Скрыть</string>
<string name="bridge_android_access_review">Проверить</string>
<string name="bridge_android_ready_short">Готово</string>
<string name="bridge_android_missing_short">Нет</string>
<string name="bridge_android_selected_needs">Требуется для выбранного доступа</string>
<string name="bridge_android_selected_needs_desc">Эти разрешения Android еще отсутствуют. Отключенные возможности не учитываются.</string>
<string name="bridge_android_open_settings">Открыть</string>
<string name="bridge_timed_title">Разрешить доступ к экрану</string>
<string name="bridge_timed_desc">Выберите действия Hermes и время доступности.</string>
<string name="bridge_timed_inspection_desc">Дерево специальных возможностей, события интерфейса и снимки</string>
<string name="bridge_timed_control_desc">Нажатия, ввод, прокрутка, открытие приложений и навигация</string>
<string name="bridge_timed_ends_title">Завершается после бездействия</string>
<string name="bridge_timed_prerequisites">Условия доступа к экрану</string>
<string name="bridge_timed_capture_note">Согласие на захват запрашивается при первом снимке экрана.</string>
<string name="bridge_timed_allow">Разрешить доступ</string>
<string name="bridge_timed_end_now">Завершить сейчас</string>
<string name="bridge_timed_ended_snackbar">Доступ к экрану завершен. Постоянные разрешения остаются доступными.</string>
<string name="unattended_description_unlimited">Оставляет устройство доступным без тайм-аута бездействия. Доступ действует, пока не завершен доступ к экрану, не выключен главный переключатель или этот режим.</string>
<string name="bridge_access_screen_unlimited">Просмотр или управление активно до отключения</string>
<string name="bridge_access_until_off_short">До отключения</string>
<string name="bridge_access_unattended_unlimited">Всегда доступно, пока не отключен доступ к экрану</string>
<string name="bridge_timed_lifetime_title">Как завершается доступ</string>
<string name="bridge_timed_scope_title">Что может делать Hermes</string>
<string name="bridge_timed_until_off">До отключения</string>
<string name="bridge_timed_idle_explainer">Фиксированного лимита сессии нет. Активные команды обновляют таймер бездействия %1$s.</string>
<string name="bridge_timed_unlimited_warning">Без тайм-аута. Доступ сохраняется при бездействии и переподключении, пока не завершен, не выключен главный переключатель или не изменена политика. Для выделенного устройства.</string>
<string name="bss_screen_access_off_desc">Доступ к экрану выключен. Новый ограниченный доступ по умолчанию использует %1$d минут бездействия.</string>
<string name="bss_screen_access_unlimited_desc">Хотя бы одна экранная возможность активна до явного отключения.</string>
</resources>
+98 -7
View File
@@ -139,6 +139,13 @@
<string name="chat_search_result_count">%1$d of %2$d</string>
<string name="chat_retry">Retry</string>
<string name="chat_dismiss">Dismiss</string>
<string name="chat_failure_title">Hermes couldn’t complete this response</string>
<string name="chat_failure_details">Details</string>
<string name="chat_failure_details_title">Response failure</string>
<string name="chat_failure_details_guidance">Hermes reported this error. The app won’t switch routes or models automatically.</string>
<string name="chat_failure_copy_details">Copy details</string>
<string name="chat_failure_route_gateway">Gateway</string>
<string name="chat_failure_route_api">API fallback</string>
<string name="chat_open_settings">Open Settings</string>
<string name="chat_connect_hermes">Connect Hermes</string>
<string name="chat_try_demo">Try the demo</string>
@@ -588,7 +595,7 @@
<string name="bssc_safety">Safety</string>
<string name="bssc_blocked_apps">Apps the agent can\'t touch</string>
<string name="bssc_destructive_verbs">Words that always ask first</string>
<string name="bssc_auto_disable">Turns itself off when idle</string>
<string name="bssc_auto_disable">Timed screen access</string>
<string name="bssc_guardrails_hint">These guardrails keep Hermes in bounds. Tap to adjust.</string>
<string name="bssc_manage">Manage</string>
@@ -1043,7 +1050,7 @@
<string name="bridge_settings">Settings</string>
<string name="bridge_paired_title">Phone bridge is paired</string>
<string name="bridge_staged_title">Bridge controls are staged</string>
<string name="bridge_paired_subtitle">Terminal, voice, notification, media, and advanced phone controls share this grant.</string>
<string name="bridge_paired_subtitle">Relay pairing is active. Phone commands also follow Agent access below.</string>
<string name="bridge_staged_subtitle">Pair Relay to receive bridge commands. You can still configure permissions and safety before pairing.</string>
<string name="bridge_relay_label">relay</string>
<string name="bridge_safety_label">safety</string>
@@ -1110,7 +1117,7 @@
<string name="bss_add_verb_cd">Add verb</string>
<string name="bss_remove_cd">Remove</string>
<string name="bss_auto_disable">Auto-disable after idle</string>
<string name="bss_auto_disable_desc">Master toggle flips off after this many minutes with no bridge commands. Rescheduled on every command.</string>
<string name="bss_auto_disable_desc">Timed screen access expires after this many idle minutes. Only screen inspection or control resets the timer.</string>
<string name="bss_auto_disable_value">%1$d min</string>
<string name="bss_status_overlay">Status overlay</string>
<string name="bss_show_floating">Show floating indicator</string>
@@ -2369,7 +2376,8 @@
<string name="unattended_requires_master">Requires Agent Control — enable the master switch above first.</string>
<string name="unattended_on">On — agent may wake the screen and act while you\'re away.</string>
<string name="unattended_off">Off — bridge actions only land when the screen is already on.</string>
<string name="unattended_description">Acquires a screen-bright wake lock on each incoming bridge command and asks the system to dismiss the keyguard. Hard-bounded by the bridge auto-disable timer.</string>
<string name="unattended_description">Keeps the screen available for incoming commands. Active screen commands refresh the idle timer; inactivity ends Screen access and turns Unattended off.</string>
<string name="unattended_description_unlimited">Keeps this device available without an idle timeout. Unattended stays on until you end Screen access, disable Master, or turn it off here.</string>
<string name="unattended_keyguard_detected">Keyguard detected</string>
<string name="unattended_keyguard_body">The screen will wake but stop at the lock screen. Android won\'t let third-party apps dismiss PIN / pattern / biometric locks. Set your lock to None or Swipe in Settings > Security to let the agent reach apps.</string>
<string name="unattended_dialog_title">Enable Unattended Access?</string>
@@ -2380,7 +2388,7 @@
<string name="unattended_dialog_limitation_lock_detected">Your device currently has a PIN, pattern, or biometric lock set. Android does not let third-party apps dismiss these — the screen will wake, but stop at the lock screen. To let the agent reach apps, change your lock to None or Swipe in Settings > Security.</string>
<string name="unattended_dialog_limitation_no_lock">If you later set a PIN, pattern, or biometric lock, Android will not let Hermes dismiss it. The screen will wake, but stop at the lock screen, and the bridge will report a \'keyguard_blocked\' error to the agent.</string>
<string name="unattended_dialog_how_to_disable">How to disable:</string>
<string name="unattended_dialog_how_to_disable_body">• Flip this same toggle off at any time.\n• The bridge auto-disable timer (default 30 minutes of idle) will turn unattended access off along with the master bridge toggle.\n• Disconnecting from the relay also drops the wake lock immediately.</string>
<string name="unattended_dialog_how_to_disable_body">• Flip this same toggle off at any time.\n• With an idle limit, active screen commands refresh the timer; inactivity ends Screen access and turns Unattended off.\n• With Until turned off, use End now or disable Master to revoke access.\n• Disconnecting from the relay drops the current wake lock immediately.</string>
<string name="unattended_dialog_confirm">I understand — enable</string>
<string name="unattended_dialog_cancel">Cancel</string>
@@ -3594,8 +3602,8 @@
<string name="whats_new_title">What\u0027s new</string>
<string name="whats_new_subtitle">Latest release highlights</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Bridge auto-disabled</string>
<string name="bridge_notification_auto_disabled_body">Paused after idle — tap to re-enable in the Bridge tab.</string>
<string name="bridge_notification_auto_disabled_title">Timed screen access expired</string>
<string name="bridge_notification_auto_disabled_body">Screen inspection and control are off after being idle. Other grants are unchanged.</string>
<string name="bridge_notification_control_title">Hermes agent has device control</string>
<string name="bridge_notification_control_body">Bridge is active — tap Disable to stop at any time.</string>
<string name="gateway_keepalive_title">Hermes connection active</string>
@@ -4177,4 +4185,87 @@
<string name="host_resource_memory_elevated">Host memory is running low.</string>
<string name="host_resource_disk_critical">Host storage is critically low; messages and settings may fail to save.</string>
<string name="host_resource_disk_elevated">Host storage is running low.</string>
<string name="bss_capabilities_title">Capability grants</string>
<string name="bss_capabilities_desc">Always/Never grants apply only to this Hermes connection. The master switch and Android permissions still override them.</string>
<string name="bss_capabilities_unavailable">Select and connect a Hermes connection before changing grants.</string>
<string name="bss_timed_capabilities_title">Screen access</string>
<string name="bss_timed_capabilities_desc">Screen inspection and control expire after %1$d minutes of inactivity. Harmless permanent grants do not extend this timer.</string>
<string name="bss_screen_access_off_desc">Screen access is off. New finite access uses a %1$d-minute idle limit by default.</string>
<string name="bss_screen_access_unlimited_desc">At least one screen capability stays active until explicitly turned off.</string>
<string name="bss_capability_always">Always, while the master switch is on</string>
<string name="bss_capability_never">Never</string>
<string name="bss_capability_timed_on">Timed access active</string>
<string name="bss_capability_timed_off">Timed access off</string>
<string name="bss_capability_device_info">Device and app info</string>
<string name="bss_capability_contacts">Read contacts</string>
<string name="bss_capability_location">Read location</string>
<string name="bss_capability_clipboard_read">Read clipboard</string>
<string name="bss_capability_clipboard_write">Write clipboard</string>
<string name="bss_capability_media">Control media playback</string>
<string name="bss_capability_communications">Send SMS and place calls</string>
<string name="bss_capability_sharing">Share media and compose MMS</string>
<string name="bss_capability_screen_inspection">Inspect screen and UI</string>
<string name="bss_capability_screen_control">Drive apps and navigation</string>
<string name="unattended_requires_timed_control">Requires Screen control — allow it from Agent access first.</string>
<!-- Bridge capability cockpit and guided access flow -->
<string name="bridge_access_title">Agent access</string>
<string name="bridge_access_not_set_up">Not set up</string>
<string name="bridge_access_summary_desc">Choose what Hermes can use on this connection.</string>
<string name="bridge_access_set_up">Set up access</string>
<string name="bridge_access_always">Always available</string>
<string name="bridge_access_enabled_count">%1$d of %2$d capabilities enabled</string>
<string name="bridge_access_screen">Screen access</string>
<string name="bridge_access_screen_active">Inspection or control is temporarily active</string>
<string name="bridge_access_screen_unlimited">Inspection or control stays active until turned off</string>
<string name="bridge_access_until_off_short">Until off</string>
<string name="bridge_access_screen_off">Inspection and control are off</string>
<string name="bridge_access_allow_duration">Allow…</string>
<string name="bridge_access_unattended_on">Can wake the screen during this timed session</string>
<string name="bridge_access_unattended_unlimited">Always available until Screen access is turned off</string>
<string name="bridge_access_unattended_available">Available while Screen access is active</string>
<string name="bridge_access_unattended_needs_screen">Needs temporary Screen access</string>
<string name="bridge_access_preset_read_only">Read only</string>
<string name="bridge_access_preset_confirmed_short">Read + actions</string>
<string name="bridge_access_preset_confirmed">Read + confirmed actions</string>
<string name="bridge_access_preset_custom">Custom</string>
<string name="bridge_access_choose_title">Choose agent access</string>
<string name="bridge_access_choose_desc">Start with a preset, then adjust individual capabilities.</string>
<string name="bridge_access_preset_read_only_desc">Device info, contacts, location, and clipboard read</string>
<string name="bridge_access_preset_confirmed_desc">Also allows calls, SMS, and sharing with confirmation</string>
<string name="bridge_access_preset_custom_desc">Choose each capability yourself</string>
<string name="bridge_access_recommended">Recommended</string>
<string name="bridge_access_temporary_title">Temporary screen access</string>
<string name="bridge_access_temporary_desc">Screen inspection and control always expire after inactivity.</string>
<string name="bridge_access_continue">Continue</string>
<string name="bridge_access_saved_review_android">Access saved. Review the required Android access before enabling Master.</string>
<string name="bridge_access_read_group">Read access</string>
<string name="bridge_access_read_group_desc">Choose which device data Hermes may read while Master is on.</string>
<string name="bridge_access_actions_group">Actions</string>
<string name="bridge_access_actions_group_desc">Calls, SMS, and sharing still require confirmation when enabled.</string>
<string name="bridge_android_access_title">Android access</string>
<string name="bridge_android_access_none">No Android access is required until you choose capabilities.</string>
<string name="bridge_android_access_ready">All Android access required by active capabilities is ready.</string>
<string name="bridge_android_access_missing">%1$d of %2$d ready · %3$d missing</string>
<string name="bridge_android_access_hide">Hide</string>
<string name="bridge_android_access_review">Review</string>
<string name="bridge_android_ready_short">Ready</string>
<string name="bridge_android_missing_short">Missing</string>
<string name="bridge_android_selected_needs">Required for selected access</string>
<string name="bridge_android_selected_needs_desc">These Android permissions are still missing. Disabled capabilities are not included.</string>
<string name="bridge_android_open_settings">Open</string>
<string name="bridge_timed_title">Allow screen access</string>
<string name="bridge_timed_desc">Choose what Hermes can do and how long it stays available.</string>
<string name="bridge_timed_inspection_desc">Accessibility tree, UI events, and screenshots</string>
<string name="bridge_timed_control_desc">Tap, type, swipe, open apps, and navigation</string>
<string name="bridge_timed_ends_title">Ends after inactivity</string>
<string name="bridge_timed_lifetime_title">How access ends</string>
<string name="bridge_timed_scope_title">What Hermes can do</string>
<string name="bridge_timed_until_off">Until turned off</string>
<string name="bridge_timed_idle_explainer">No fixed session limit. Active screen commands refresh the %1$s idle timer.</string>
<string name="bridge_timed_unlimited_warning">No idle timeout. Screen access remains available through inactivity and reconnect until you end it, disable Master, or change policy. Best for a dedicated device.</string>
<string name="bridge_timed_prerequisites">Prerequisites for screen access</string>
<string name="bridge_timed_capture_note">Screen capture consent is requested when the first screenshot is used.</string>
<string name="bridge_timed_allow">Allow access</string>
<string name="bridge_timed_end_now">End now</string>
<string name="bridge_timed_ended_snackbar">Screen access ended. Permanent grants are still available.</string>
</resources>
@@ -49,7 +49,6 @@ class BargeInListenerShutdownRaceTest {
val listener = BargeInListener(
audioSource = source,
vadEngine = vadEngine,
audioSessionIdProvider = { 42 },
readerDispatcher = StandardTestDispatcher(testScheduler),
)
@@ -61,6 +60,7 @@ class BargeInListenerShutdownRaceTest {
}
private class OneFrameAudioSource : BargeInListener.AudioFrameSource {
override val audioSessionId: Int = 42
var readCount: Int = 0
private set
var released: Boolean = false
@@ -8,6 +8,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
@@ -92,12 +93,20 @@ class BargeInListenerTest {
every { vadEngine.analyze(any()) } returns
VadResult(isSpeech = true, probability = 1f)
val source = ScriptedAudioSource(List(10) { makeFrame(3_000) })
// Establish a quiet floor for the 450 ms calibration, then cover the
// 300 ms speech-majority window. Loud calibration frames correctly
// become the ambient floor and must not self-trigger.
val source = ScriptedAudioSource(
List(15) { makeFrame(0) } + List(12) { makeFrame(3_000) },
)
var frameTimeMs = 0L
val listener = BargeInListener(
audioSource = source,
vadEngine = vadEngine,
audioSessionIdProvider = { 42 }, // non-zero — skip the polling branch
readerDispatcher = StandardTestDispatcher(testScheduler),
nowMsProvider = {
frameTimeMs.also { frameTimeMs += 32L }
},
)
val collector = listener.bargeInDetected.asCollector(this)
@@ -115,10 +124,9 @@ class BargeInListenerTest {
advanceTimeBy(50)
runCurrent()
assertEquals(
"bargeInDetected should fire once the 300ms majority window is satisfied",
1,
collector.events.get(),
assertTrue(
"bargeInDetected should fire after the 300ms majority window is satisfied",
collector.events.get() >= 1,
)
// cancelAndJoin — plain cancel() leaves the collector in "cancelling"
@@ -140,7 +148,6 @@ class BargeInListenerTest {
val listener = BargeInListener(
audioSource = source,
vadEngine = vadEngine,
audioSessionIdProvider = { 42 },
readerDispatcher = StandardTestDispatcher(testScheduler),
)
@@ -191,7 +198,6 @@ class BargeInListenerTest {
val listener = BargeInListener(
audioSource = source,
vadEngine = vadEngine,
audioSessionIdProvider = { 42 },
readerDispatcher = StandardTestDispatcher(testScheduler),
)
@@ -228,7 +234,6 @@ class BargeInListenerTest {
val listener = BargeInListener(
audioSource = source,
vadEngine = vadEngine,
audioSessionIdProvider = { 42 },
readerDispatcher = StandardTestDispatcher(testScheduler),
)
@@ -253,34 +258,21 @@ class BargeInListenerTest {
val vadEngine = mockk<VadEngine>()
every { vadEngine.analyze(any()) } returns VadResult.NOT_SPEECH
// Always return 0 — simulates ExoPlayer that never allocates an
// AudioTrack during the listener's lifetime (e.g. it was started
// before TTS began).
val sessionIdCalls = AtomicInteger(0)
val source = EndlessAudioSource()
val source = EndlessAudioSource(audioSessionId = 0)
val listener = BargeInListener(
audioSource = source,
vadEngine = vadEngine,
audioSessionIdProvider = {
sessionIdCalls.incrementAndGet()
0
},
readerDispatcher = StandardTestDispatcher(testScheduler),
)
listener.start(this)
// Poll window is 1 000 ms at 50 ms intervals = 20 polls + 1 initial.
advanceTimeBy(1_200)
advanceTimeBy(50)
runCurrent()
assertFalse(
"aecAttached must stay false when sessionId is 0 for the full poll window",
"aecAttached must stay false when the capture session id is 0",
listener.aecAttached.value,
)
assertTrue(
"sessionIdProvider should have been polled more than once (initial + retries)",
sessionIdCalls.get() > 1,
)
assertTrue(
"reader loop must continue running even without AEC",
source.readCount.get() > 0,
@@ -291,6 +283,29 @@ class BargeInListenerTest {
runCurrent()
}
@Test
fun `aec attaches to AudioRecord capture session`() = runTest {
val vadEngine = mockk<VadEngine>()
every { vadEngine.analyze(any()) } returns VadResult.NOT_SPEECH
val effect = mockk<AcousticEchoCanceler>(relaxed = true)
every { AcousticEchoCanceler.isAvailable() } returns true
every { AcousticEchoCanceler.create(73) } returns effect
val source = EndlessAudioSource(audioSessionId = 73)
val listener = BargeInListener(
audioSource = source,
vadEngine = vadEngine,
readerDispatcher = StandardTestDispatcher(testScheduler),
)
listener.start(this)
advanceTimeBy(50)
runCurrent()
verify(exactly = 1) { AcousticEchoCanceler.create(73) }
assertTrue(listener.aecAttached.value)
listener.stop()?.join()
}
// ─── Helpers ──────────────────────────────────────────────────────────
/** A zero-filled PCM frame at the VadEngine's required length. */
@@ -324,6 +339,7 @@ class BargeInListenerTest {
private class ScriptedAudioSource(
private val frames: List<ShortArray>,
) : BargeInListener.AudioFrameSource {
override val audioSessionId: Int = 42
private var cursor = 0
override fun initialize(): Boolean = true
@@ -351,6 +367,7 @@ class BargeInListenerTest {
*/
private class EndlessAudioSource(
private val burstBeforeIdle: Int = 4,
override val audioSessionId: Int = 42,
) : BargeInListener.AudioFrameSource {
val readCount = AtomicInteger(0)
var released = false
@@ -0,0 +1,81 @@
package com.hermesandroid.relay.bridge
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BridgeCapabilitiesTest {
@Test
fun registryCoversTheCompleteAndroidBridgeRouteInventory() {
val expected = setOf(
"GET" to "/ping", "POST" to "/setup", "POST" to "/wait",
"GET" to "/current_app", "GET" to "/get_apps", "GET" to "/apps",
"POST" to "/search_contacts", "GET" to "/location",
"GET" to "/clipboard", "POST" to "/clipboard", "POST" to "/media",
"POST" to "/call", "POST" to "/send_sms", "POST" to "/share_media",
"POST" to "/send_mms", "GET" to "/screen", "GET" to "/screenshot",
"GET" to "/screen_hash", "GET" to "/events", "POST" to "/find_nodes",
"POST" to "/describe_node", "POST" to "/diff_screen",
"POST" to "/events/stream", "POST" to "/tap", "POST" to "/tap_text",
"POST" to "/long_press", "POST" to "/type", "POST" to "/swipe",
"POST" to "/drag", "POST" to "/scroll", "POST" to "/press_key",
"POST" to "/open_app", "POST" to "/return_to_hermes",
"POST" to "/send_intent", "POST" to "/broadcast",
)
assertEquals(expected, BridgeCommandRegistry.registeredRoutes())
}
@Test
fun clipboardMethodCannotCrossReadWriteAuthority() {
assertEquals(
BridgeCapability.CLIPBOARD_READ,
BridgeCommandRegistry.resolve("/clipboard", "GET")?.capability,
)
assertEquals(
BridgeCapability.CLIPBOARD_WRITE,
BridgeCommandRegistry.resolve("/clipboard", "post")?.capability,
)
}
@Test
fun aliasesResolveToTheSameCapability() {
assertEquals(
BridgeCommandRegistry.resolve("/get_apps", "GET"),
BridgeCommandRegistry.resolve("/apps", "GET"),
)
}
@Test
fun unknownPathAndWrongMethodFailClosed() {
assertNull(BridgeCommandRegistry.resolve("/future_command", "POST"))
assertNull(BridgeCommandRegistry.resolve("/send_sms", "GET"))
}
@Test
fun policySeparatesPermanentAndTimedAuthority() {
val now = 10_000L
val policy = BridgeCapabilityPolicy(
permanentGrants = setOf(BridgeCapability.CONTACTS_READ),
timedExpiriesMs = mapOf(BridgeCapability.SCREEN_CONTROL to now + 1),
)
assertTrue(policy.allows(BridgeCapability.CONTACTS_READ, now))
assertFalse(policy.allows(BridgeCapability.CLIPBOARD_READ, now))
assertTrue(policy.allows(BridgeCapability.SCREEN_CONTROL, now))
assertFalse(policy.allows(BridgeCapability.SCREEN_CONTROL, now + 1))
assertFalse(policy.allows(BridgeCapability.SCREEN_INSPECTION, now))
}
@Test
fun unlimitedScreenAuthorityRemainsActiveUntilExplicitlyRevoked() {
val policy = BridgeCapabilityPolicy(
timedExpiriesMs = mapOf(
BridgeCapability.SCREEN_CONTROL to BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS,
),
)
assertTrue(policy.allows(BridgeCapability.SCREEN_CONTROL, Long.MAX_VALUE - 1))
assertTrue(policy.isUnlimited(BridgeCapability.SCREEN_CONTROL))
assertFalse(policy.isUnlimited(BridgeCapability.SCREEN_INSPECTION))
}
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.os.PowerManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -251,6 +252,33 @@ class UnattendedAccessManagerTest {
UnattendedAccessManager.release()
}
@Test
fun releaseAfterAction_releasesAtEndOfSingleCommand() {
every { wakeLock.isHeld } returns true
UnattendedAccessManager.initialize(context)
UnattendedAccessManager.setEnabled(true)
UnattendedAccessManager.acquireForAction()
UnattendedAccessManager.releaseAfterAction()
verify(exactly = 1) { wakeLock.release() }
}
@Test
fun releaseAfterAction_keepsConcurrentCommandHeldUntilLastRelease() {
every { wakeLock.isHeld } returns true
UnattendedAccessManager.initialize(context)
UnattendedAccessManager.setEnabled(true)
UnattendedAccessManager.acquireForAction()
UnattendedAccessManager.acquireForAction()
UnattendedAccessManager.releaseAfterAction()
verify(exactly = 0) { wakeLock.release() }
UnattendedAccessManager.releaseAfterAction()
verify(exactly = 1) { wakeLock.release() }
}
// --- WakeOutcome enum semantics ---
@Test
@@ -0,0 +1,142 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
@RunWith(RobolectricTestRunner::class)
class BridgeCapabilityPolicyRepositoryTest {
private lateinit var context: Context
private lateinit var fence: File
@Before
fun setUp() = runTest {
context = ApplicationProvider.getApplicationContext()
context.relayDataStore.edit { it.clear() }
fence = File(context.noBackupFilesDir, "bridge-policy-install-id")
fence.delete()
}
@After
fun tearDown() = runTest {
context.relayDataStore.edit { it.clear() }
fence.delete()
}
@Test
fun grantsDefaultDeniedAndStayConnectionScoped() = runTest {
val repo = BridgeCapabilityPolicyRepository(context)
assertFalse(repo.snapshot("home").allows(BridgeCapability.CONTACTS_READ, 0L))
repo.setPermanent("home", BridgeCapability.CONTACTS_READ, true)
assertTrue(repo.snapshot("home").allows(BridgeCapability.CONTACTS_READ, 0L))
assertFalse(repo.snapshot("work").allows(BridgeCapability.CONTACTS_READ, 0L))
}
@Test
fun timedGrantUsesAbsoluteExpiryAndCanBeRevokedAsAGroup() = runTest {
val repo = BridgeCapabilityPolicyRepository(context)
repo.grantTimed("home", BridgeCapability.SCREEN_INSPECTION, 1_001L)
repo.grantTimed("home", BridgeCapability.SCREEN_CONTROL, 1_001L)
assertTrue(repo.snapshot("home").allows(BridgeCapability.SCREEN_CONTROL, 1_000L))
assertFalse(repo.snapshot("home").allows(BridgeCapability.SCREEN_CONTROL, 1_001L))
repo.revokeTimed("home")
assertFalse(repo.snapshot("home").allows(BridgeCapability.SCREEN_INSPECTION, 0L))
}
@Test
fun removingConnectionDeletesItsAuthority() = runTest {
val repo = BridgeCapabilityPolicyRepository(context)
repo.setPermanent("home", BridgeCapability.CLIPBOARD_READ, true)
repo.clearConnection("home")
assertFalse(repo.snapshot("home").allows(BridgeCapability.CLIPBOARD_READ, 0L))
}
@Test
fun batchReplacementIsAtomicAndDoesNotCrossConnections() = runTest {
val repo = BridgeCapabilityPolicyRepository(context)
repo.replacePermanent(
"home",
setOf(BridgeCapability.DEVICE_INFO, BridgeCapability.CLIPBOARD_READ),
)
repo.replaceTimed(
"home",
setOf(BridgeCapability.SCREEN_INSPECTION, BridgeCapability.SCREEN_CONTROL),
expiresAtMs = 5_000L,
)
val home = repo.snapshot("home")
assertTrue(BridgeCapability.DEVICE_INFO in home.permanentGrants)
assertEquals(2, home.timedExpiriesMs.size)
assertFalse(repo.snapshot("work").hasAnyGrantForTest(0L))
}
@Test
fun idleRefreshPreservesUnlimitedGrants() = runTest {
val repo = BridgeCapabilityPolicyRepository(context)
repo.replaceTimed(
"home",
setOf(BridgeCapability.SCREEN_CONTROL),
BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS,
)
repo.refreshActiveTimed("home", expiresAtMs = 30_000L)
val policy = repo.snapshot("home")
assertEquals(
BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS,
policy.timedExpiriesMs[BridgeCapability.SCREEN_CONTROL],
)
}
@Test
fun idleRefreshDoesNotReviveExpiredSiblingGrant() = runTest {
val repo = BridgeCapabilityPolicyRepository(context)
repo.replaceTimed(
"home",
setOf(BridgeCapability.SCREEN_INSPECTION),
expiresAtMs = 1L,
)
repo.refreshActiveTimed("home", expiresAtMs = Long.MAX_VALUE - 1)
assertFalse(
repo.snapshot("home").allows(BridgeCapability.SCREEN_INSPECTION, System.currentTimeMillis()),
)
}
@Test
fun restoredDataCannotCrossTheNoBackupInstallFence() = runTest {
val originalInstall = BridgeCapabilityPolicyRepository(context)
originalInstall.setPermanent("home", BridgeCapability.CONTACTS_READ, true)
assertTrue(originalInstall.snapshot("home").allows(BridgeCapability.CONTACTS_READ, 0L))
// Simulate DataStore restoration onto a new install: backed-up prefs
// remain, but Android's no-backup installation marker does not.
assertTrue(fence.delete())
val restoredInstall = BridgeCapabilityPolicyRepository(context)
assertFalse(restoredInstall.snapshot("home").allows(BridgeCapability.CONTACTS_READ, 0L))
}
}
private fun com.hermesandroid.relay.bridge.BridgeCapabilityPolicy.hasAnyGrantForTest(
nowMs: Long,
): Boolean = permanentGrants.isNotEmpty() || timedExpiriesMs.values.any { it > nowMs }
@@ -0,0 +1,46 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
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
@RunWith(RobolectricTestRunner::class)
class BridgePreferencesMigrationTest {
private lateinit var context: Context
private val legacyMaster = booleanPreferencesKey("bridge_master_enabled")
@Before
fun setUp() = runTest {
context = ApplicationProvider.getApplicationContext()
context.relayDataStore.edit { it.clear() }
}
@After
fun tearDown() = runTest { context.relayDataStore.edit { it.clear() } }
@Test
fun legacyEnabledMasterMigratesFailClosed() = runTest {
context.relayDataStore.edit { it[legacyMaster] = true }
assertFalse(BridgePreferencesRepository(context).settings.first().masterEnabled)
}
@Test
fun newMasterWritesKeepLegacyDowngradeGateOff() = runTest {
val repo = BridgePreferencesRepository(context)
repo.setMasterEnabled(true)
assertTrue(repo.settings.first().masterEnabled)
assertFalse(context.relayDataStore.data.first()[legacyMaster] ?: true)
}
}
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.relay
import android.content.Context
import com.hermesandroid.relay.R
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@@ -60,6 +62,8 @@ class RelayVoiceClientRoutingTest {
.callTimeout(5, TimeUnit.SECONDS)
.build()
context = mockk(relaxed = true)
every { context.getString(R.string.voice_diag_reconnected) } returns "Voice reconnected"
every { context.getString(R.string.voice_diag_resume_rejected) } returns "Resume rejected"
}
@After
@@ -97,7 +97,7 @@ class HermesReachTransportTest {
),
)
assertTrue(candidate.hasHermesReach())
assertEquals("Hermes Reach", candidate.displayLabel())
assertEquals("Hermes Reach · Experimental", candidate.displayLabel())
assertEquals("wss://broker.example/v1/connect", candidate.hermesReachRouteOrNull()?.tunnelUrlOrNull())
}
@@ -704,6 +704,7 @@ class GatewayChatClientTest {
val thinkingDeltas = ConcurrentLinkedQueue<String>()
val sessionIds = ConcurrentLinkedQueue<String>()
val errors = ConcurrentLinkedQueue<String>()
val resumeFailures = ConcurrentLinkedQueue<String>()
val interactions = ConcurrentLinkedQueue<GatewayAsk>()
val interactionExpiries = ConcurrentLinkedQueue<GatewayAskExpiry>()
val toolStarts = ConcurrentLinkedQueue<Pair<String, String>>()
@@ -736,6 +737,7 @@ class GatewayChatClientTest {
onMoaReference = { moaReferences += it },
onInteractionRequest = { interactions += it },
onInteractionExpired = { interactionExpiries += it },
onResumeFailure = { resumeFailures += it; completeLatch.countDown() },
onStatusUpdate = { _, _ -> },
onStatusClear = { },
)
@@ -1532,16 +1534,34 @@ class GatewayChatClientTest {
}
@Test
fun `failed resume falls back to fresh create`() {
fun `failed resume is visible and never forks a fresh session`() {
harness.resumeFails = true
val r = Recorder()
client.sendTurn("api_123_dead", "hi", "hi", r.callbacks) { r.preflightFailures += it }
harness.awaitRpc("session.resume")
harness.awaitRpc("session.create")
harness.awaitRpc("prompt.submit")
val deadline = System.currentTimeMillis() + 5_000
while (r.sessionIds.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(20)
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
waitUntil { r.resumeFailures.isNotEmpty() }
assertEquals(listOf("session.resume refused"), r.resumeFailures.toList())
assertTrue(harness.rpcLog.none { it.first == "session.create" })
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `profile-mismatched resume is visible and never submits the continuation`() {
client.sessionProfileProvider = { "mizu" }
harness.sessionProfileOverride = "default"
val r = Recorder()
client.sendTurn("stored-mizu", "continue", null, r.callbacks) {
r.preflightFailures += it
}
harness.awaitRpc("session.resume")
waitUntil { r.resumeFailures.isNotEmpty() }
assertTrue(r.resumeFailures.single().contains("profile", ignoreCase = true))
assertTrue(harness.rpcLog.none { it.first == "session.create" })
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
assertTrue(r.preflightFailures.isEmpty())
}
@Test
@@ -2677,6 +2697,40 @@ class GatewayChatClientTest {
)
}
@Test
fun `session info without provider clears prior session identity`() {
val recorder = Recorder()
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
serverWs.send(
harness.eventFrame(
"session.info",
buildJsonObject {
put("model", "deepseek-v3")
put("provider", "opencode")
},
"live-resumed",
),
)
waitUntil { client.serverProvider.value == "opencode" }
serverWs.send(
harness.eventFrame(
"session.info",
buildJsonObject { put("model", "agnes-2") },
"live-resumed",
),
)
waitUntil { client.serverModel.value == "agnes-2" }
assertNull(client.serverProvider.value)
assertNull(client.serverModelIdentity.value)
}
@Test
fun `approval mode get and set use profile config without session yolo scope`() {
harness.approvalMode = "smart"
@@ -3101,7 +3155,7 @@ class GatewayChatClientTest {
harness.awaitRpc("session.resume")
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
assertEquals(listOf(message), r.errors.toList())
assertEquals(listOf(message), r.resumeFailures.toList())
assertTrue(r.preflightFailures.isEmpty())
assertEquals(0, harness.rpcLog.count { it.first == "session.create" })
assertEquals(0, harness.rpcLog.count { it.first == "prompt.submit" })
@@ -31,6 +31,7 @@ class GatewayEventMapperTest {
val moaReferences = mutableListOf<GatewayMoaReference>()
val interactions = mutableListOf<GatewayAsk>()
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
val failures = mutableListOf<GatewayTurnFailure>()
val statusUpdates = mutableListOf<Pair<String?, String>>()
val statusClears = mutableListOf<String>()
val sessionIds = mutableListOf<String>()
@@ -66,6 +67,7 @@ class GatewayEventMapperTest {
onMoaReference = { moaReferences += it },
onInteractionRequest = { interactions += it },
onInteractionExpired = { interactionExpiries += it },
onFailure = { failures += it },
onStatusUpdate = { kind, text -> statusUpdates += kind to text },
onStatusClear = { statusClears += it },
)
@@ -305,6 +307,7 @@ class GatewayEventMapperTest {
assertEquals(listOf("partial answer"), r.textDeltas)
assertEquals(listOf("error" to "provider failed"), r.statusUpdates)
assertEquals(listOf(GatewayTurnFailure("provider failed", recoverable = true)), r.failures)
assertEquals(1, r.completes)
assertTrue(mapper.turnEnded)
}
@@ -321,6 +324,7 @@ class GatewayEventMapperTest {
assertEquals(listOf("Error: agent build failed"), r.textDeltas)
assertEquals(listOf("error" to "agent build failed"), r.statusUpdates)
assertEquals(listOf(GatewayTurnFailure("agent build failed", recoverable = true)), r.failures)
assertEquals(1, r.completes)
}
@@ -0,0 +1,159 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.ui.components.BridgeAccessPreset
import com.hermesandroid.relay.ui.components.BridgeAccessSetupSheet
import com.hermesandroid.relay.ui.components.BridgeAgentAccessCard
import com.hermesandroid.relay.ui.components.BridgeAndroidAccessSummaryCard
import com.hermesandroid.relay.ui.components.BridgeTimedAccessSheet
import com.hermesandroid.relay.ui.components.UnattendedAccessRow
import com.hermesandroid.relay.ui.components.bridgeAndroidAccessSummary
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
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 = "w400dp-h900dp-432dpi")
class BridgeAccessFlowScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun cockpitKeepsPolicyAndAndroidReadinessVisible() {
val policy = BridgeCapabilityPolicy(
permanentGrants = setOf(
BridgeCapability.DEVICE_INFO,
BridgeCapability.CONTACTS_READ,
BridgeCapability.CLIPBOARD_READ,
),
timedExpiriesMs = mapOf(
BridgeCapability.SCREEN_INSPECTION to 1_800_100L,
BridgeCapability.SCREEN_CONTROL to 1_800_100L,
),
)
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
BridgeAgentAccessCard(
policy = policy,
nowMs = 100L,
onSetUp = {},
onManage = {},
onAllowScreen = {},
)
UnattendedAccessRow(
enabled = false,
warningSeen = false,
credentialLockDetected = false,
onToggle = {},
onWarningSeen = {},
masterEnabled = true,
screenControlAvailable = true,
screenAccessUnlimited = false,
)
BridgeAndroidAccessSummaryCard(
summary = bridgeAndroidAccessSummary(
policy,
BridgePermissionStatus(
accessibilityServiceEnabled = true,
contactsPermitted = false,
),
nowMs = 100L,
),
expanded = false,
onToggle = {},
)
}
}
}
compose.onRoot().captureRoboImage("build/visual-qa/bridge-access-cockpit.png")
}
@Test
fun setupPresetSheetRendersAtPhoneWidth() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
BridgeAccessSetupSheet(
selected = BridgeAccessPreset.READ_ONLY,
onSelected = {},
onDismiss = {},
onContinue = {},
)
}
}
compose.onRoot().captureRoboImage("build/visual-qa/bridge-access-setup-sheet.png")
}
@Test
fun timedAccessSheetShowsTruthfulPrerequisites() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
BridgeTimedAccessSheet(
inspectEnabled = true,
controlEnabled = true,
durationMinutes = 30,
unlimited = false,
accessibilityReady = true,
overlayReady = false,
currentlyActive = false,
onInspectChanged = {},
onControlChanged = {},
onDurationChanged = {},
onUnlimitedChanged = {},
onOpenAccessibility = {},
onOpenOverlay = {},
onDismiss = {},
onAllow = {},
onEndNow = {},
)
}
}
compose.onRoot().captureRoboImage("build/visual-qa/bridge-timed-access-sheet.png")
}
@Test
fun unlimitedAccessSheetExplainsDedicatedDeviceRisk() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
BridgeTimedAccessSheet(
inspectEnabled = true,
controlEnabled = true,
durationMinutes = 30,
unlimited = true,
accessibilityReady = true,
overlayReady = true,
currentlyActive = false,
onInspectChanged = {},
onControlChanged = {},
onDurationChanged = {},
onUnlimitedChanged = {},
onOpenAccessibility = {},
onOpenOverlay = {},
onDismiss = {},
onAllow = {},
onEndNow = {},
)
}
}
compose.onRoot().captureRoboImage("build/visual-qa/bridge-unlimited-access-sheet.png")
}
}
@@ -0,0 +1,61 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.ui.screens.CapabilityGrantCards
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
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 = "w400dp-h1000dp-432dpi")
class BridgeCapabilityScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun groupedCapabilityControlsRenderAtPhoneWidth() {
compose.setContent {
HermesRelayTheme(themePreference = "dark") {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
CapabilityGrantCards(
policy = BridgeCapabilityPolicy(
permanentGrants = setOf(
BridgeCapability.DEVICE_INFO,
BridgeCapability.CONTACTS_READ,
BridgeCapability.CLIPBOARD_READ,
),
timedExpiriesMs = mapOf(
BridgeCapability.SCREEN_INSPECTION to Long.MAX_VALUE,
),
),
timerMinutes = 30,
enabled = true,
onPermanentChanged = { _, _ -> },
onTimedChanged = { _, _ -> },
)
}
}
}
compose.onRoot().captureRoboImage("build/visual-qa/bridge-capabilities.png")
}
}
@@ -0,0 +1,114 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.components.ChatFailureDetailsDialog
import com.hermesandroid.relay.ui.components.ChatFailurePanel
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatInputTrailing
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ChatFailureNotice
import com.hermesandroid.relay.viewmodel.ChatFailureRoute
import org.junit.Assert.assertTrue
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 = "w400dp-h800dp-432dpi")
class ChatFailurePanelScreenshotTest {
@get:Rule
val compose = createComposeRule()
private val failure = ChatFailureNotice(
sessionId = "session-1",
turnId = "turn-1",
rawError = "API call failed after 3 retries: HTTP 404: 404 page not found",
route = ChatFailureRoute.GATEWAY,
model = "agnes-2",
provider = "nous",
recoverable = true,
)
@Test
fun failurePanelRendersAtComposerEdgeAndOpensDetails() {
var detailsOpen by mutableStateOf(false)
var copied = false
var retried = false
var dismissed = false
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Bottom,
) {
ChatFailurePanel(
failure = failure,
routeLabel = "Gateway",
onDetails = { detailsOpen = true },
onRetry = { retried = true },
onDismiss = { dismissed = true },
)
ChatInputBar(
value = "",
onValueChange = {},
placeholder = "Message Hermes",
trailing = ChatInputTrailing.SEND,
onSend = {},
onVoice = {},
onStop = {},
onAttachPhotos = {},
onAttachFiles = {},
onAttachCamera = {},
onPasteImage = {},
onLongPressAttach = {},
charLimit = 20_000,
caption = null,
voiceReady = true,
showVoiceHint = false,
onVoiceHintShown = {},
isDarkTheme = true,
)
if (detailsOpen) {
ChatFailureDetailsDialog(
failure = failure,
routeLabel = "Gateway",
onCopy = { copied = true },
onDismiss = { detailsOpen = false },
)
}
}
}
}
compose.onRoot().captureRoboImage("build/ui-evidence/chat-failure-panel.png")
compose.onNodeWithText("Details").performClick()
compose.onNodeWithText(failure.rawError).assertExists()
compose.onRoot().captureRoboImage("build/ui-evidence/chat-failure-details.png")
compose.onNodeWithText("Copy details").performClick()
assertTrue(copied)
compose.onNodeWithText("Close").performClick()
compose.onNodeWithText("Retry").performClick()
compose.onNodeWithText("Dismiss").performClick()
assertTrue(retried)
assertTrue(dismissed)
}
}
@@ -0,0 +1,117 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BridgeAccessPresentationTest {
@Test
fun presetsMapToStableCapabilitySets() {
assertEquals(
BridgeAccessPreset.READ_ONLY,
BridgeCapabilityPolicy(
permanentGrants = READ_ONLY_BRIDGE_CAPABILITIES,
).displayPreset(),
)
assertEquals(
BridgeAccessPreset.READ_CONFIRMED,
BridgeCapabilityPolicy(
permanentGrants = READ_CONFIRMED_BRIDGE_CAPABILITIES,
).displayPreset(),
)
assertEquals(
BridgeAccessPreset.CUSTOM,
BridgeCapabilityPolicy(
permanentGrants = setOf(BridgeCapability.CLIPBOARD_READ),
).displayPreset(),
)
}
@Test
fun androidSummaryIncludesOnlySelectedCapabilityRequirements() {
val summary = bridgeAndroidAccessSummary(
policy = BridgeCapabilityPolicy(
permanentGrants = READ_ONLY_BRIDGE_CAPABILITIES,
),
status = BridgePermissionStatus(
accessibilityServiceEnabled = true,
contactsPermitted = true,
locationPermitted = false,
microphonePermitted = false,
cameraPermitted = false,
),
nowMs = 100L,
)
assertEquals(
setOf(
BridgeAndroidRequirement.ACCESSIBILITY,
BridgeAndroidRequirement.CONTACTS,
BridgeAndroidRequirement.LOCATION,
),
summary.required,
)
assertEquals(setOf(BridgeAndroidRequirement.LOCATION), summary.missing)
assertFalse(summary.allReady)
}
@Test
fun screenControlRequiresOverlayButInspectionDoesNot() {
val inspection = bridgeAndroidAccessSummary(
policy = BridgeCapabilityPolicy(
timedExpiriesMs = mapOf(BridgeCapability.SCREEN_INSPECTION to 1_000L),
),
status = BridgePermissionStatus(accessibilityServiceEnabled = true),
nowMs = 100L,
)
assertTrue(BridgeAndroidRequirement.ACCESSIBILITY in inspection.required)
assertFalse(BridgeAndroidRequirement.OVERLAY in inspection.required)
val control = bridgeAndroidAccessSummary(
policy = BridgeCapabilityPolicy(
timedExpiriesMs = mapOf(BridgeCapability.SCREEN_CONTROL to 1_000L),
),
status = BridgePermissionStatus(
accessibilityServiceEnabled = true,
overlayPermitted = false,
),
nowMs = 100L,
)
assertEquals(setOf(BridgeAndroidRequirement.OVERLAY), control.missing)
}
@Test
fun confirmedActionsExposeOverlayAndRuntimeRequirements() {
val summary = bridgeAndroidAccessSummary(
policy = BridgeCapabilityPolicy(
permanentGrants = READ_CONFIRMED_BRIDGE_CAPABILITIES,
),
status = BridgePermissionStatus(
accessibilityServiceEnabled = true,
contactsPermitted = true,
locationPermitted = true,
smsPermitted = false,
phonePermitted = false,
overlayPermitted = false,
),
nowMs = 0L,
)
assertTrue(BridgeAndroidRequirement.SMS in summary.missing)
assertTrue(BridgeAndroidRequirement.PHONE in summary.missing)
assertTrue(BridgeAndroidRequirement.OVERLAY in summary.missing)
}
@Test
fun expiredScreenGrantDoesNotAffectPermanentSummary() {
val policy = BridgeCapabilityPolicy(
permanentGrants = setOf(BridgeCapability.CONTACTS_READ),
timedExpiriesMs = mapOf(BridgeCapability.SCREEN_CONTROL to 100L),
)
assertEquals(emptySet<BridgeCapability>(), policy.activeTimedCapabilities(100L))
assertTrue(policy.hasAnyGrant(100L))
}
}
@@ -0,0 +1,13 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatFailurePanelTest {
@Test
fun `identity includes only confidently known fields`() {
assertEquals("Gateway · nous · agnes-2", failureIdentity("Gateway", "agnes-2", "nous"))
assertEquals("Gateway", failureIdentity("Gateway", null, null))
assertEquals("", failureIdentity("", null, null))
}
}
@@ -10,7 +10,8 @@ import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasImeAction
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
@@ -33,9 +34,10 @@ import org.robolectric.annotation.GraphicsMode
class ChatInputBarTest {
@get:Rule
val compose = createComposeRule()
val compose = createAndroidComposeRule<ComponentActivity>()
@Test
@Config(qualifiers = "w360dp-h720dp-xhdpi-qwerty")
fun `hardware enter submits through the composer action`() {
var sent = 0
setComposer(value = "Hello") { sent++ }
@@ -64,6 +66,7 @@ class ChatInputBarTest {
}
@Test
@Config(qualifiers = "w360dp-h720dp-xhdpi-qwerty")
fun `plain enter inserts newline when configured while ctrl enter submits`() {
var value = "Hello"
var sent = 0
@@ -88,6 +91,7 @@ class ChatInputBarTest {
}
@Test
@Config(qualifiers = "w360dp-h720dp-xhdpi-qwerty")
fun `hardware submit preserves steer action`() {
var submitted = 0
setComposer(value = "Correct this", trailing = ChatInputTrailing.STEER) { submitted++ }
@@ -109,6 +113,7 @@ class ChatInputBarTest {
}
@Test
@Config(qualifiers = "w360dp-h720dp-xhdpi-qwerty")
fun `hardware submit preserves queue action`() {
var submitted = 0
setComposer(value = "Follow up", trailing = ChatInputTrailing.QUEUE) { submitted++ }
@@ -157,6 +162,44 @@ class ChatInputBarTest {
}
}
@Test
fun `ime synthesized enter key inserts newline instead of submitting`() {
var value = "Hello"
var sent = 0
setComposer(value = value, onValueChange = { value = it }) { sent++ }
// Some IMEs dispatch the return key as a synthesized KEYCODE_ENTER
// key event instead of committing "\n" directly. With no physical
// keyboard attached the send path must not fire, otherwise the soft
// keyboard's return key sends the message (issue #367). Inject the
// event at the view root exactly like the IME's InputConnection
// key-event path does.
val imeDown = android.view.KeyEvent(
-1, 0,
android.view.KeyEvent.ACTION_DOWN,
android.view.KeyEvent.KEYCODE_ENTER,
0, 0,
)
val imeUp = android.view.KeyEvent(
-1, 0,
android.view.KeyEvent.ACTION_UP,
android.view.KeyEvent.KEYCODE_ENTER,
0, 0,
)
input().performClick()
compose.runOnIdle {
val root = compose.activity.findViewById<android.view.View>(android.R.id.content)
root.dispatchKeyEvent(imeDown)
root.dispatchKeyEvent(imeUp)
}
compose.runOnIdle {
assertEquals("Hello\n", value)
assertEquals(0, sent)
}
}
@Test
fun `large insertion becomes attachment while surrounding draft stays in composer`() {
var value = "before after"
@@ -0,0 +1,56 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatFailureStateTest {
private val failure = ChatFailureNotice(
sessionId = "session-a",
turnId = "turn-a",
rawError = "provider failed",
route = ChatFailureRoute.GATEWAY,
)
@Test
fun `failure is visible only in its owning session`() {
assertEquals(failure, scopedChatFailure(failure, "session-a"))
assertNull(scopedChatFailure(failure, "session-b"))
assertNull(scopedChatFailure(failure, null))
}
@Test
fun `draft failure stays scoped to the draft`() {
val draft = failure.copy(sessionId = null)
assertEquals(draft, scopedChatFailure(draft, null))
assertNull(scopedChatFailure(draft, "created-session"))
}
@Test
fun `structured failure records reviewable redacted route identity`() {
DiagnosticsLog.clear()
recordChatFailureDiagnostic(
failure.copy(
rawError = "HTTP 404 from endpoint api_key=secret-value",
model = "agnes-2",
provider = "openrouter",
),
liveSessionId = "live-a",
)
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Session), 1).single()
assertEquals(DiagnosticSeverity.Error, entry.severity)
assertEquals("gateway", entry.endpointRole)
assertTrue(entry.detail.orEmpty().contains("model=agnes-2"))
assertTrue(entry.detail.orEmpty().contains("provider=openrouter"))
assertTrue(entry.detail.orEmpty().contains("stored_session=session-a"))
assertTrue(entry.detail.orEmpty().contains("live_session=live-a"))
assertTrue(entry.detail.orEmpty().contains("HTTP 404"))
assertFalse(entry.detail.orEmpty().contains("secret-value"))
}
}
@@ -156,7 +156,7 @@ class VoiceViewModelBargeInTest {
localBridgeDispatcher = null,
bargeInPreferences = prefsRepo,
vadEngineFactory = { vadEngine },
bargeInListenerFactory = { _, _ -> bargeInListener },
bargeInListenerFactory = { _ -> bargeInListener },
)
return vm
}
+97 -3
View File
@@ -728,7 +728,8 @@ notes remain here as implementation history.
| Per-connection | Global |
|---|---|
| API baseUrl + bearer | Theme, dev-mode toggles |
| Sessions, messages, search | Bridge safety prefs (blocklist, destructive verbs, auto-disable) |
| Sessions, messages, search | Bridge safety vocabulary (blocklist, destructive verbs, timer duration) |
| Bridge capability policy (ADR 63) | Status-overlay presentation |
| Personalities (`/api/config`) | Feature flags / DataStore overrides |
| Skills, memory | Notification companion enabled/disabled |
| Relay WSS endpoint + cert pin (shared by host) | Keystore itself (one keystore, many entries) |
@@ -736,7 +737,11 @@ notes remain here as implementation history.
| Bridge command target | |
| Last-active session ID | |
**Trade-off — Bridge safety prefs are global:** a blocklist entry added for server A also applies when connected to server B. Accepted for v1 because the safety model is phone-wide (one user, one device, same risk appetite). If users report the shared blocklist biting, split per-connection later — the store shape allows it without breaking compatibility.
**Amended by ADR 63 — Bridge capability grants are per-connection.** The
blocklist, destructive vocabulary, confirmation timeout, and timed-window
duration remain phone-wide safety preferences. Actual Always/Never/Timed
authority is keyed by Connection ID, so server A cannot inherit grants made for
server B.
**Trade-off — "both agents in one channel" (Discord-style) deferred:** a unified chat view showing interleaved messages from two connections is possible but semantically fraught: sessions, memory, and tool calls don't merge on the server side, so the unified view would be purely client-side theater. Deferred until there's a concrete use case; v1 users switch contexts with one tap and carry on.
@@ -2349,6 +2354,9 @@ couple Standard voice to non-standard server behavior.
`Thinking`, `Speaking`, and final audio drain on Standard and Realtime paths.
A turn epoch fences callbacks, and teardown completes before replacement
capture or another listener can acquire the microphone.
- Optional AEC and noise suppression attach to the listener's `AudioRecord`
capture session, matching Android's preprocessing contract. Playback session
IDs are not effect attachment targets.
- Quiet-room RMS calibration occurs before output and freezes at playback
start. The gate follows upstream's 90th-percentile ambient floor, 3× default
multiplier, generation/playback minimums, 4,000 RMS ceiling, 500 ms grace,
@@ -2385,7 +2393,8 @@ couple Standard voice to non-standard server behavior.
intentionally not claimed.
- sherpa owns temporal confirmation through `numTrailingBlanks`. Android treats
each non-empty keyword result as a completed event, resets the native stream
immediately, and does not require the same completed result to recur. Voice
immediately, reuses its equal-sized normalization buffer, and does not
require the same completed result to recur. Voice
settings can arm a bounded real-microphone test; test detections report
success without entering voice or acquiring a second microphone owner.
@@ -3465,3 +3474,88 @@ and large structured pastes remain visible before send without making the
default path depend on Relay. Draft content stays local, is excluded from cloud
backup, is cleared on uninstall, and remains subject to the app's attachment
size limit.
## ADR 63 — Android Bridge authority is connection-scoped, grouped, and fail-closed
**Context.** The original Bridge safety contract had one persisted master
switch and one idle timer. Every command refreshed that timer, so a harmless
contacts or clipboard read kept screen-driving authority armed, while timer
expiry disabled harmless reads too. Command aliases, method-split clipboard
operations, raw intents, and Python-side composite tools also made a flat list
of UI command toggles easy to drift away from the actual executor surface.
**Threat model.** A valid Relay session or agent tool call may be mistaken,
prompt-injected, replayed after reconnect, routed to the wrong configured
Hermes connection, or upgraded while either endpoint runs an older version.
Android OS permission, AccessibilityService, MediaProjection, overlay
confirmation, target-package blocklist, and Relay channel grants are necessary
independent gates; none is evidence that the user granted a Bridge capability.
**Decision.** Android owns a closed `(HTTP method, path) -> capability` registry
at the first command boundary. Unknown routes and wrong methods return 403
before event reads, wake locks, confirmations, or executor calls. The master
switch overrides every capability. Policy is persisted by stable Android
Connection ID, and removing a connection removes its policy.
| Capability | Lifetime | Routes |
|---|---|---|
| Device/app info | Always/Never | current app, launcher apps (including `/apps` alias) |
| Contacts | Always/Never | contact search |
| Location | Always/Never | last-known location |
| Clipboard read | Always/Never | `GET /clipboard` |
| Clipboard write | Always/Never | `POST /clipboard` |
| Media control | Always/Never | play/pause/toggle/next/previous |
| Communications | Always/Never + per-action confirmation | call, send SMS |
| Outbound sharing | Always/Never + per-action confirmation | share media, compose MMS |
| Screen/UI inspection | Explicit lease | tree, nodes, hashes/diffs, events, screenshot |
| Screen/device control | Explicit lease | tap/type/gesture, keys, app navigation, raw intents/broadcasts |
`ping`, host-only `setup`, and bounded `wait` are operational primitives rather
than data authority. `android_navigate` and `android_macro` receive no composite
grant; each primitive route is checked. Notification history and shell remain
separate Notification Companion and terminal-channel contracts.
There is no read-SMS Bridge command in the audited inventory; adding one later
requires an explicit registry entry, capability decision, Android permission
review, status/docs update, and tests rather than inheriting Communications.
Screen leases offer 5 minutes, 30 minutes, or 2 hours of **idle** time. Active
screen commands refresh that timer, so there is no fixed wall-clock session
limit. A separately warned **Until turned off** choice has no idle expiry and
is intended for a dedicated/dummy device. It survives inactivity and reconnect
but still ends on End now, Master off, connection removal, or policy change.
Status reports this under `capabilities.unlimited`, not a fake expiry. Restart,
reconnect, master disable, manual revoke, and finite expiry cannot revive
revoked authority. Calls, SMS, sharing, and
MMS retain their on-device confirmation even when their capability is Always.
Android runtime permissions and MediaProjection consent are checked separately
at use time; the grant UI never claims to grant those OS authorities.
**Migration and compatibility.** Missing, malformed, or future policy schemas
mean no grants. Existing installs migrate with the master off and every
capability denied. The master moves to a v2 DataStore key while writes pin the
legacy key false, so downgrading to an APK that does not understand granular
policy fails closed. Capability policy is bound to a random install ID stored
under Android's no-backup directory, so a cloud or exported restore cannot
transfer authority to a destination install; restored connections must be
re-authorized. `bridge.status` adds only capability IDs and expiry timestamps; older
Relays cache and pass through the additive payload without interpreting it.
**UX rationale.** The main Bridge page is a cockpit, not the complete editor:
it keeps permanent and screen-access policy directly below Master, followed by
one authoritative Unattended Access card rather than a duplicate summary and
switch. It guides first setup through a preset or Custom and summarizes only
the Android prerequisites required by selected capabilities. Expanding Android
access preserves the full
permission matrix and Test/Settings actions; Safety preserves all granular
toggles, blocklists, confirmation vocabulary/timeouts, overlay setting, trusted
actions, and audit history. Screen access distinguishes renewable idle limits
from Until turned off, includes End now, and explains the dedicated-device
risk. Ending it clears unattended while permanent grants remain available.
This follows Android's guidance to
[request access in context and degrade gracefully](https://developer.android.com/training/permissions/requesting),
[minimize permission scope](https://developer.android.com/privacy-and-security/minimize-permission-requests),
and require fresh consent for each
[MediaProjection session](https://developer.android.com/media/grow/media-projection).
It also mirrors MCP authorization's
[least-privilege scope selection](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
without presenting Android app policy as OAuth scope.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"main": "9a26f5679c73a8c217097a75c09cf93d9c420827329599605ddba8fa77707637",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"main": "9a26f5679c73a8c217097a75c09cf93d9c420827329599605ddba8fa77707637",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"main": "9a26f5679c73a8c217097a75c09cf93d9c420827329599605ddba8fa77707637",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"main": "9a26f5679c73a8c217097a75c09cf93d9c420827329599605ddba8fa77707637",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"main": "9a26f5679c73a8c217097a75c09cf93d9c420827329599605ddba8fa77707637",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "6c5f74d4f9a1b2391bbc7fc431b1e9f67386d71228a53c2513498b36d07d44cb",
"main": "9a26f5679c73a8c217097a75c09cf93d9c420827329599605ddba8fa77707637",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+2 -7
View File
@@ -89,15 +89,10 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.10.0 - Chat that stays put
v1.11.0 - More reliable chat
* Render Markdown while replies stream without a final rebuild or scroll jump.
* Resume the open Hermes session after returning from another app.
* Restore composer drafts and pending attachments after app restarts.
* Turn large pastes into reviewable text attachments.
* Use Return on the software keyboard while the dedicated button sends.
Keep stored-session failures visible with route-aware retry details. Use Return for multiline prompts across more software keyboards. Preserve Stopped status when cancelling answer recovery. Reduce idle redraws and release capture and audio resources sooner.
```
## Category
Tools
+24
View File
@@ -221,6 +221,30 @@ Message types:
| `bridge.response` | Client → Server | Phone replies, echoing `request_id` |
| `bridge.status` | Client → Server | Periodic device state (screen on, battery, current app, accessibility on) |
Android Bridge commands are authorized on-device after Relay channel/session
authorization. Current clients add a `capabilities` object to `bridge.status`:
```json
{
"capabilities": {
"schema_version": 1,
"permanent": ["contacts_read", "clipboard_read"],
"timed": {"screen_inspection": 1787180400000},
"unlimited": ["screen_control"]
}
}
```
`timed` contains epoch-millisecond expiries for renewable idle leases;
`unlimited` contains explicitly selected Until-turned-off screen leases. The
lists contain stable capability IDs only;
they contain no command arguments, contact data, clipboard content, or screen
content. Relay caches this additive status for diagnostics but does not turn it
into authority. The phone's closed `(method, path)` registry is authoritative,
and unknown commands, absent grants, and expired grants return 403. This keeps
mixed-version Relays compatible without letting an older server bypass a newer
phone policy. See ADR 63 and `BridgeCapabilities.kt` for the complete matrix.
Bridge exposes 30+ HTTP routes on the relay itself (mirrored from legacy): `/ping`, `/screen`, `/screenshot`, `/tap`, `/tap_text`, `/long_press`, `/type`, `/swipe`, `/drag`, `/open_app`, `/return_to_hermes`, `/press_key`, `/scroll`, `/clipboard`, `/wait`, `/setup`, `/media`, `/find_nodes`, `/screen_hash`, `/diff_screen`, `/send_intent`, `/broadcast`, `/events`, `/location`, `/search_contacts`, `/call`, `/send_sms`, `/share_media`, `/send_mms`, …
Sources: `plugin/relay/channels/bridge.py`, `app/src/main/kotlin/.../network/handlers/BridgeCommandHandler.kt`.
+15 -13
View File
@@ -483,11 +483,12 @@ Bottom navigation bar with 4 tabs:
Shipped in v0.3.0; card hierarchy rewritten in v0.4.1. Rendered by `BridgeScreen.kt` + `BridgeViewModel` in this order:
1. **Master toggle card** (`BridgeMasterToggle`) — headline "Allow Agent Control" switch with a `MASTER` pill and leading "Master switch —" subtitle copy so the parent-gate role is legible at a glance. Gated on accessibility permission being granted; tapping the Switch when Accessibility is not granted surfaces a snackbar ("Accessibility Service must be enabled first.") with an "Open Settings" action that deep-links to `ACTION_ACCESSIBILITY_SETTINGS` rather than silent-dropping the tap. Inline device / battery / screen / current-app rows live in-card (the old standalone `BridgeStatusCard` was dropped from the layout in v0.4.1). Info icon opens a Play-review explanation dialog that also names the "Hermes has device control" persistent notification owned by the master switch.
2. **Permission checklist** (`BridgePermissionChecklist`) — tiered four-section layout shipped in v0.4.1 (Core bridge / Notification companion / Voice & camera / Sideload features). Reads the same `AppPermissionStatusProbe` snapshot as Settings -> Permissions so Bridge and the central review surface agree on Android grants, special-access toggles, and screen-capture session state. Tap-to-open Android Settings via `ACTION_ACCESSIBILITY_SETTINGS`, `ACTION_MANAGE_OVERLAY_PERMISSION`, `enabled_notification_listeners`, and per-row runtime-permission affordances; rows fall back to `ACTION_APPLICATION_DETAILS_SETTINGS` when a runtime permission has been permanently denied. Optional rows render an "Optional" Material 3 pill in a `FlowRow` with `softWrap=false` so the pill never wraps internally on narrow titles. Re-probes on `Lifecycle.Event.ON_RESUME` so returning from Android Settings flips rows green without navigation churn.
3. **Advanced divider** — visual separator between "operate the bridge" and "expand what the bridge can do".
4. **Unattended Access card** (`UnattendedAccessRow`, sideload-only) — opt-in toggle gated on the master toggle (`enabled = masterEnabled`; subtitle reads "Requires Agent Control — enable the master switch above first." when master is off). First-enable shows the scary one-time dialog covering the security model + credential-lock limitation + how to disable. Credential-lock warning renders as an inline `KeyguardDetectedAlert` Surface band inside this card (was a standalone chip pre-v0.4.1, inlined so the warning lives next to the toggle that triggers it).
5. **Safety summary card** (`BridgeSafetySummaryCard`) — blocklist count / destructive-verb count / countdown timer (`in MM:SS` during an active idle window, else `N min idle`). Tap-through to `BridgeSafetySettingsScreen` for editing the blocklist / destructive verbs / auto-disable timer / status overlay / confirmation timeout.
6. **Activity log** (`BridgeActivityLog`) — scrollable `LazyColumn` capped at 320dp + `MAX_LOG_ENTRIES=100`. Tap-to-expand rows showing timestamp, status (Pending / Success / Failed / Blocked), result text, and optional screenshot token. DataStore-backed via `BridgePreferences`.
2. **Agent access cockpit** (`BridgeAgentAccessCard`) — always visible directly below Master. It shows the current preset/custom posture, permanent-grant count, and screen lease/countdown or **Until off**. A first-use **Set up access** sheet offers Read only, Read + confirmed actions, or Custom; presets atomically replace permanent grants, while Custom opens the complete grouped editor. Inspection/control uses a separate sheet with renewable 5 min / 30 min / 2 hr idle limits or an explicitly warned **Until turned off** dedicated-device posture, prerequisite truth, and End now.
3. **Unattended Access card** (`UnattendedAccessRow`, sideload-only) — the single authoritative placement, directly below Agent access. Its opt-in toggle is gated on both Master and active Screen control. With an idle limit, active commands continually refresh the timer and Unattended ends only after inactivity. With **Until turned off**, it remains available through inactivity/reconnect for a dedicated device. First-enable shows the warning dialog covering the security model, selected lifetime, credential-lock limitation, persistent indicators, and revocation. Expiry, End now, or Master-off clears the unattended preference, so a later screen grant cannot silently revive it.
4. **Android access disclosure** (`BridgeAndroidAccessSummaryCard` + `BridgeSelectedAndroidAccessCard`) — summarizes readiness only for capabilities the user selected. Missing permissions for disabled capabilities never nag. Expanding preserves the complete existing `BridgePermissionChecklist` with every status, Settings link, and Test action (Core bridge / Notification companion / Voice & camera / Sideload features). The selected-access card leads with the still-missing requirements before the full matrix. Reads the same `AppPermissionStatusProbe` snapshot as Settings -> Permissions and re-probes on resume.
5. **Advanced divider** — visual separator between ordinary access/readiness and safety power controls.
6. **Safety & full capability editor** (`BridgeSafetySummaryCard` → `BridgeSafetySettingsScreen`) — grouped Read access, Actions, and Timed screen access retain all ten granular toggles. The same screen retains the global blocklist, destructive verbs, 5–120 minute timed-access window, status overlay, and confirmation timeout.
7. **Activity log** (`BridgeActivityLog`) — unchanged scrollable audit history capped at 100 entries with expandable result/status detail and optional screenshot token.
The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafetyManager`, `BridgeForegroundService`, `BridgeStatusOverlay`, `AutoDisableWorker`). See `docs/decisions.md` and `CLAUDE.md`'s file table for the full wiring.
@@ -817,12 +818,13 @@ Tier C tools add runtime permissions or user-mediated system share/compose hando
| `android_share_media(...)` | `POST /share_media` | Share text, host-local files, relay `MEDIA:` markers, or raw media tokens through Android's native share UI with `FileProvider` `content://` grants. | n/a |
| `android_send_mms(to, body?, attachments...)` | `POST /send_mms` | Open a user-mediated MMS compose/share handoff with recipient, optional body, and attachments. Hermes Relay does not silently send MMS because Android reserves background MMS delivery for the default SMS app. | n/a |
**Safety integration.** All HTTP routes except `/ping`, `/current_app`, and `/return_to_hermes` are gated in `BridgeCommandHandler` on the Bridge master toggle (`bridge_master_enabled` DataStore flag) and the Tier 5 three-stage safety check:
1. **Blocklist gate** — `BridgeSafetyManager.checkPackageAllowed(currentApp)` returns 403 `{"error": "blocked package <name>"}` when the foreground package is in the blocklist (~30 banking/payments/password-manager/2FA defaults seeded via `DEFAULT_BLOCKLIST`).
2. **Destructive-verb confirmation** — `/tap_text` and `/type` commands whose text matches the user's destructive-verb regex list (`send` / `pay` / `delete` / `transfer` / `confirm` / `submit` / ...) suspend on a `CompletableDeferred<Boolean>` under a `withTimeout`, waiting for the user to Allow / Deny via the `BridgeStatusOverlay` modal. **Tier C `android_call`, `android_send_sms`, `android_share_media`, and `android_send_mms` always go through this gate regardless of body content** — these actions leave the phone or hand user data to another app. Denied or timed-out commands return 403 `{"error": "user denied destructive action", "reason": "confirmation_denied_or_timeout"}`.
3. **Auto-disable reschedule** — every successful command resets the idle countdown on `BridgeSafetyManager.rescheduleAutoDisable`, which flips master off after the configured idle window (default 30 min, clamped 5..120).
**Safety integration.** `BridgeCommandHandler` resolves every request through the closed `(method, path)` registry before side effects. Unknown paths, wrong methods, missing policy, missing active Connection identity, absent grants, and expired timed grants fail with structured 403 responses. Only `/ping`, host-only `/setup`, and bounded `/wait` are grant-exempt operational primitives; the master switch still overrides `/wait` and every capability route. The remaining gates compose rather than replace one another:
1. **Connection-scoped capability** — the active Android Connection selects the persisted policy. Durable capabilities are Always/Never. Screen/UI inspection and screen/device control store absolute expiries and are Timed-only. Only accepted timed commands extend their shared idle window; harmless permanent reads do not.
2. **Master + Android authority** — the v2 master key gates every capability. AccessibilityService, runtime permissions, Notification Listener, overlay access, and current-session MediaProjection remain separately enforced by Android and the executor. A UI grant never substitutes for an OS grant.
3. **Blocklist gate** — `BridgeSafetyManager.checkPackageAllowed(currentApp)` returns 403 when the foreground package is blocklisted. Intent/broadcast/open targets are checked separately.
4. **Confirmation gate** — destructive text actions suspend for the overlay decision. `android_call`, `android_send_sms`, `android_share_media`, and `android_send_mms` always confirm even when their capability is Always. Silence, missing overlay, and timeout deny.
The newly added Tier A/B tools all flow through the same `BridgeCommandHandler` dispatch and are covered by the existing gates without additional wiring. Tier A tools that only *read* (e.g. `android_screen_hash`, `android_clipboard_read`, `android_describe_node`) skip the destructive-verb check but still hit the blocklist and master-enable gates. `android_send_intent` and `android_broadcast` hit the blocklist gate keyed on the target `package` (not just the foreground app) so an agent can't bypass the blocklist by firing an Intent at a blocked target from an allowed foreground.
Aliases share one capability (`/apps` and `/get_apps`), while `GET /clipboard` and `POST /clipboard` intentionally resolve to separate read/write capabilities. `android_navigate` and `android_macro` have no aggregate bypass: every primitive call crosses the registry. Policy is not exported in Android backups, removal of a Connection deletes it, and the legacy master key is pinned false so downgrade fails closed. See ADR 63.
#### 6.4.2 Architectural patterns adopted in v0.4
@@ -889,7 +891,7 @@ The `ActionResult.data` field indicates which tier succeeded (`"direct"` / `"par
- [x] App: Permission management (`BridgePermissionChecklist` plus Settings -> Permissions — shared accessibility, screen capture, overlay, notification listener, runtime-grant status)
- [x] App: Activity log (`BridgeActivityLog` + `BridgePreferences`, capped at 100 entries)
- [x] App: Accessibility service (`HermesAccessibilityService` + `ScreenReader` + `ActionExecutor` + `BridgeCommandHandler`)
- [x] App: Tier 5 safety rails — `BridgeSafetyManager` (blocklist + destructive-verb confirmation + auto-disable timer), `BridgeForegroundService` (persistent "Hermes has device control" notification), `BridgeStatusOverlay` (confirmation modal + optional floating chip)
- [x] App: Tier 5 safety rails — `BridgeSafetyManager` (connection-scoped capability grants + timed screen-access expiry + global blocklist + destructive confirmation), `BridgeForegroundService` (persistent "Hermes has device control" notification), `BridgeStatusOverlay` (confirmation modal + optional floating chip)
- [x] App: Flavor split — googlePlay (conservative a11y config) and sideload (full capabilities)
- [x] Plugin: notification-listener companion channel (`android_notifications_recent`) + `android_navigate` vision loop
- [x] **v0.4 bridge feature expansion** — 10 Tier A tools (long_press, drag, find_nodes, describe_node, screen_hash + diff_screen, clipboard r/w, media, macro) + 2 Tier B tools (events/event_stream, send_intent + broadcast) + 4 Tier C sideload-only tools (location, search_contacts, call, send_sms); architectural patterns — `WakeLockManager` wake-scope wrapping, multi-window `ScreenReader`, A9 three-tier `tapText` cascade, `ScreenHasher` content fingerprinting. See §6.4.1 for the tool surface table and §6.4.2 for the patterns.
@@ -955,8 +957,8 @@ utilities.
- `RealtimePcmPlayer` streams `/voice/output/*`, `/voice/realtime/*`, and `/voice/realtime-agent/*` PCM deltas directly to `AudioTrack`.
- `VoiceViewModel` state machine (`Idle / Listening / Transcribing / Thinking / Speaking / Error`). Assistant text is sanitized (markdown / tool-annotations / URLs / emoji-set stripped) on each delta before a coalescing chunker (`MIN_COALESCE_LEN=40`, `MAX_BUFFER_LEN=400` secondary-break escape, 800 ms timer flush) emits sentence-scale chunks. The observer aggregates every assistant bubble created by one Hermes run, including interim tool handoffs and the final answer, and finishes speech only when the run-level stream ends. Stable bubble identity and submitted-turn/session fences prevent StateFlow/history reconciliation or a pending-new-chat session switch from speaking stale or duplicate text. Bubble boundaries flush incomplete prior text so adjacent narration cannot run together. The default queue calls `/voice/output/*` for exact renderer PCM playback; failed output turns fall back to the existing `/voice/synthesize` synth/play workers. The same stream observer watches Hermes-owned `ToolCall` state and speaks bounded status lines for running tools; execution, approval, and tool results remain in the Hermes chat/relay loop.
- Server-side, `/voice/synthesize` runs a matching sanitizer (`plugin/relay/tts_sanitizer.py`) before handing text to the upstream `text_to_speech_tool` — defense-in-depth for any client that doesn't pre-sanitize.
- **Full-turn barge-in** (default on, user-configurable). One turn-scoped `BargeInListener` starts when the submitted voice turn enters `Thinking` and remains the sole listener through generation, `Speaking`, and audio drain on both Standard and Realtime paths. Its duplex `AudioRecord` (16 kHz mono PCM, `VOICE_COMMUNICATION` source) feeds 32 ms frames through Silero VAD and an upstream-compatible RMS gate: roughly 450 ms of non-triggering pre-playback calibration, a 90th-percentile quiet floor of at least 200 RMS, a default 3× multiplier, generation/playback floors of 400/1,500 RMS, a 4,000 RMS ceiling, 500 ms playback grace, and an 80%-majority 300 ms decision window. The ambient floor may drift only while playback is inactive and the room remains below threshold. Playback phase follows the renderer, returning to generation thresholds between output spans and rearming grace only after a gap of at least one second. Raw probable speech ducks playback; only model-confirmed speech above the RMS gate interrupts. `AcousticEchoCanceler` + `NoiseSuppressor` attach when a playback session becomes available. Detection uses the existing gateway/provider interrupt seam, fences stale callbacks and late audio/text deltas, waits for microphone release, then captures the replacement utterance. A 600 ms watchdog preserves the existing resume-after-interruption behavior for playback. Configurable stop phrases default to exact bare `stop`; an empty list disables them. A stop phrase ends the active voice chat during generation or playback, while ordinary requests such as “stop the container” remain agent input outside that exact match. Playback interruption sets a one-shot, API-local 120-second latch that adds `[Note: the user interrupted your previous spoken reply before it finished.]` to the next model-bound turn without changing visible or persisted user text; generation or pre-audio synthesis interruption does not set it. Silencing a promoted background run leaves the Hermes task alive unless the user explicitly requests background-task cancellation.
- **Experimental local wake word** (opt-in, default off). Android runs sherpa-onnx keyword spotting for the single validated phrase “Hey Hermes” inside a user-started microphone foreground service. Pre-activation PCM never leaves the phone. Voice settings expose strictness (higher is harder to trigger), decoder confirmation, start-new-session behavior, and a ten-second test that exercises the real microphone and model without opening voice; the stored routing shape reserves future profile-specific selection while this release deliberately preserves the currently selected profile. The first enable downloads and SHA-256 verifies the approximately 6 MB English KWS model rather than bundling it in the APK. A completed sherpa result is consumed once and its stream is reset immediately. Detection releases the wake microphone before entering the existing voice flow, pauses wake listening while voice owns the microphone, and resumes only after voice exits. Android’s ongoing microphone notification provides the persistent privacy status and Stop action; there is no boot or background auto-start. Opening visible Voice settings reconciles an enabled listener after app replacement or process death. In foreground-service mode, a background detection remains pending behind its notification until Hermes is visible; system-assistant integration is a separate opt-in mode.
- **Full-turn barge-in** (default on, user-configurable). One turn-scoped `BargeInListener` starts when the submitted voice turn enters `Thinking` and remains the sole listener through generation, `Speaking`, and audio drain on both Standard and Realtime paths. Its duplex `AudioRecord` (16 kHz mono PCM, `VOICE_COMMUNICATION` source) feeds 32 ms frames through Silero VAD and an upstream-compatible RMS gate: roughly 450 ms of non-triggering pre-playback calibration, a 90th-percentile quiet floor of at least 200 RMS, a default 3× multiplier, generation/playback floors of 400/1,500 RMS, a 4,000 RMS ceiling, 500 ms playback grace, and an 80%-majority 300 ms decision window. The ambient floor may drift only while playback is inactive and the room remains below threshold. Playback phase follows the renderer, returning to generation thresholds between output spans and rearming grace only after a gap of at least one second. Raw probable speech ducks playback; only model-confirmed speech above the RMS gate interrupts. `AcousticEchoCanceler` + `NoiseSuppressor` attach to that `AudioRecord` capture session and release with it. Detection uses the existing gateway/provider interrupt seam, fences stale callbacks and late audio/text deltas, waits for microphone release, then captures the replacement utterance. A 600 ms watchdog preserves the existing resume-after-interruption behavior for playback. Configurable stop phrases default to exact bare `stop`; an empty list disables them. A stop phrase ends the active voice chat during generation or playback, while ordinary requests such as “stop the container” remain agent input outside that exact match. Playback interruption sets a one-shot, API-local 120-second latch that adds `[Note: the user interrupted your previous spoken reply before it finished.]` to the next model-bound turn without changing visible or persisted user text; generation or pre-audio synthesis interruption does not set it. Silencing a promoted background run leaves the Hermes task alive unless the user explicitly requests background-task cancellation.
- **Experimental local wake word** (opt-in, default off). Android runs sherpa-onnx keyword spotting for the single validated phrase “Hey Hermes” inside a user-started microphone foreground service. Pre-activation PCM never leaves the phone. Voice settings expose strictness (higher is harder to trigger), decoder confirmation, start-new-session behavior, and a ten-second test that exercises the real microphone and model without opening voice; the stored routing shape reserves future profile-specific selection while this release deliberately preserves the currently selected profile. The first enable downloads and SHA-256 verifies the approximately 6 MB English KWS model rather than bundling it in the APK. The detector reuses its PCM normalization buffer across equal-sized frames. A completed sherpa result is consumed once and its stream is reset immediately. Detection releases the wake microphone before entering the existing voice flow, pauses wake listening while voice owns the microphone, and resumes only after voice exits. Android’s ongoing microphone notification provides the persistent privacy status and Stop action; there is no boot or background auto-start. Opening visible Voice settings reconciles an enabled listener after app replacement or process death. In foreground-service mode, a background detection remains pending behind its notification until Hermes is visible; system-assistant integration is a separate opt-in mode.
- **System voice overlay background capture.** Opening the app-owned voice overlay while Hermes is visible starts a dedicated microphone foreground service before the user backgrounds the app. The service owns no `AudioRecord`; `VoiceViewModel`, `VoiceRecorder`, and the process-wide microphone lease remain the only capture path. Its ongoing notification exposes a terminal **Stop voice** action. Hide, Exit, Open Hermes, voice-mode shutdown, overlay creation failure, and task removal release the service so foreground-only microphone access cannot outlive the visible overlay session.
- **Android Digital Assistant mode** (opt-in, default off). A declared
`VoiceInteractionService` becomes active only after the user selects Hermes
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.10.0"
appVersionCode = "45"
appVersionName = "1.11.0"
appVersionCode = "46"
agp = "9.3.1"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
+22 -2
View File
@@ -233,6 +233,26 @@ def render_status_block(data: dict, palette: _Palette) -> str:
f" Notifications: {_granted(bridge.get('notification_listener_granted'), palette)}"
)
capabilities = data.get("capabilities") or {}
if isinstance(capabilities, dict) and capabilities:
permanent = capabilities.get("permanent") or []
timed = capabilities.get("timed") or {}
unlimited = capabilities.get("unlimited") or []
lines.append("")
lines.append(" " + palette.label("Capability grants"))
lines.append(
" Always: "
+ (", ".join(str(item) for item in permanent) if permanent else "none")
)
lines.append(
" Timed: "
+ (", ".join(sorted(str(item) for item in timed)) if isinstance(timed, dict) and timed else "none")
)
lines.append(
" Until off: "
+ (", ".join(str(item) for item in unlimited) if unlimited else "none")
)
# Safety
safety = data.get("safety") or {}
if isinstance(safety, dict) and safety:
@@ -249,10 +269,10 @@ def render_status_block(data: dict, palette: _Palette) -> str:
if auto is not None:
if at_ms:
lines.append(
f" Auto-disable: {auto} min idle (armed)"
f" Timed screen: {auto} min idle (armed)"
)
else:
lines.append(f" Auto-disable: {auto} min idle")
lines.append(f" Timed screen: {auto} min idle")
lines.append("")
return "\n".join(lines)
+11
View File
@@ -65,6 +65,12 @@ def _sample_status() -> dict:
"auto_disable_minutes": 30,
"auto_disable_at_ms": None,
},
"capabilities": {
"schema_version": 1,
"permanent": ["clipboard_read", "contacts_read"],
"timed": {"screen_inspection": 1787180400000},
"unlimited": ["screen_control"],
},
}
@@ -329,6 +335,11 @@ class TestStatusCliFetch(unittest.TestCase):
self.assertIn("Destructive verbs", text)
self.assertIn("Device control", text)
self.assertIn("Accessibility", text)
self.assertIn("Capability grants", text)
self.assertIn("contacts_read", text)
self.assertIn("screen_inspection", text)
self.assertIn("screen_control", text)
self.assertIn("Timed screen", text)
def test_render_status_block_marks_bridge_core_as_no_device_control(self) -> None:
sample = _sample_status()
+5 -2
View File
@@ -61,7 +61,8 @@ def android_phone_status() -> str:
full JSON contract. Key fields: ``phone_connected`` (bool),
``last_seen_seconds_ago`` (int), ``device`` (name/battery/screen/
current_app), ``bridge`` (permission flags), ``safety`` (blocklist
counts + auto-disable timer).
counts + timed-access timer), and ``capabilities`` (granted permanent
capability IDs plus timed capability expiry timestamps).
"""
url = f"{_relay_url()}/bridge/status"
req = urllib.request.Request(url, method="GET")
@@ -155,7 +156,9 @@ _SCHEMAS = {
"permissions have been granted (accessibility, screen "
"capture, overlay, notification listener), the current "
"safety-rail configuration (blocklist size, destructive-"
"verb count, auto-disable idle timer), and the unattended-"
"verb count, timed screen-access idle timer), granular Bridge "
"capabilities (`permanent` IDs and `timed` expiry timestamps), "
"and the unattended-"
"access state (supported, enabled, credential_lock_detected). "
"Call this BEFORE attempting bridge operations like tapping, "
"typing, or screenshots so you know whether the phone is "
+3
View File
@@ -63,6 +63,9 @@ for (const htmlPath of htmlFiles) {
} catch {
failures.push(`${htmlPath}: invalid external URL ${reference}`);
}
if (/^https:\/\/hermes-relay\.dev\/docs\/guide\/remote-access\/(?:#|$)/.test(reference)) {
failures.push(`${htmlPath}: remote-access docs links must not use the unsupported trailing-slash route ${reference}`);
}
continue;
}
+3 -2
View File
@@ -22,8 +22,9 @@ const cliUrl = `${canonicalDocsUrl}desktop/`;
const koFiUrl = 'https://ko-fi.com/L4L31Q8LJ1';
const quickStartUrl = `${docsUrl}guide/quick-start`;
const relayPluginUrl = `${docsUrl}guide/release-tracks`;
const secureLinkUrl = `${canonicalDocsUrl}guide/remote-access/`;
const tailscaleUrl = `${canonicalDocsUrl}guide/remote-access/#recommended-tailscale`;
const remoteAccessUrl = `${canonicalDocsUrl}guide/remote-access`;
const secureLinkUrl = remoteAccessUrl;
const tailscaleUrl = `${remoteAccessUrl}#recommended-tailscale`;
const currentVersion = versionCatalog.match(/^appVersionName\s*=\s*"([^"]+)"/m)?.[1] ?? '1.4.5';
const currentReleaseUrl = `${githubUrl}/releases/tag/android-v${currentVersion}`;
const pageTitle = copy.metadata.title;