Compare commits

...
Author SHA1 Message Date
Bailey Dixon e55dd99f62 Merge pull request #184 from Codename-11/dev
release(android): android-v1.4.2
2026-07-11 21:27:39 -04:00
Bailey Dixon 7793934edf Merge pull request #183 from Codename-11/feature/android-i18n-zh-salvage
feat(android): add scalable Simplified Chinese localization
2026-07-11 21:02:01 -04:00
Bailey Dixon f49c6c4203 release(android): prepare android-v1.4.2 2026-07-11 20:52:27 -04:00
Bailey DixonandEasongChung 52a7d67cc4 feat(android): add scalable Simplified Chinese localization
Salvages and integrates the user-facing localization work from PR #180 onto current dev, with complete catalogs, CI parity checks, translated entry points, and contributor guidance.

Co-authored-by: EasongChung <easong@users.noreply.github.com>
2026-07-11 20:52:07 -04:00
Bailey Dixon c52340ecde Merge pull request #182 from Codename-11/dev
Release Android and plugin v1.4.1
2026-07-11 16:46:18 -04:00
Bailey Dixon 7570f93dbf chore(release): finalize android-v1.4.1 and plugin-v1.4.1 2026-07-11 16:34:11 -04:00
Bailey Dixon 6d32ccf024 merge: chat in-flight session recovery 2026-07-11 10:34:46 -04:00
Bailey Dixon 4233817e9f feat(chat): recover in-flight sessions after reopen 2026-07-11 10:29:33 -04:00
Bailey Dixon 577732069f chore: merge background process start discovery fix 2026-07-10 18:24:15 -04:00
Bailey Dixon a738a0e151 fix(chat): discover background processes after turn completion 2026-07-10 18:24:07 -04:00
Bailey Dixon 42d6c77cfb chore: merge gateway background process UI for 1.4.1 2026-07-10 12:42:39 -04:00
Bailey Dixon bd9f53e8db feat(chat): surface gateway background processes 2026-07-10 12:42:29 -04:00
Bailey Dixon 2017d60f4c chore: merge unsolicited gateway completion fix 2026-07-10 10:14:19 -04:00
Bailey Dixon 1e133ee15c fix(chat): receive unsolicited gateway completions 2026-07-10 10:13:59 -04:00
Bailey Dixon 9a40ed9afc chore: merge 1.4.1 chat and voice enhancements
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
#	TODO.md
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt
2026-07-10 08:27:25 -04:00
Bailey Dixon 9ce07b45f7 feat: enhance chat and voice for 1.4.1 2026-07-10 08:19:53 -04:00
Bailey Dixon 2fd90a6e81 docs: record 1.4.0 release completion 2026-07-09 23:31:06 -04:00
173 changed files with 20708 additions and 3654 deletions
+13
View File
@@ -12,10 +12,22 @@
-
## Lineage / contributor credit
<!--
If this PR salvages or supersedes earlier work, link every source PR and name
the original contributor(s). Preserve original commit authors where practical;
otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
-->
- Source PR(s): N/A
- Attribution preserved by: N/A
## Checklist
- [ ] Target branch is `dev` unless this is a release PR
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
- [ ] Translation changes: `python scripts/check-android-locales.py` ran and the locale was reviewed on a device/emulator, or N/A
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
@@ -23,3 +35,4 @@
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
- [ ] CHANGELOG.md updated (if user-facing)
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
- [ ] Salvaged work links the source PR and preserves contributor authorship, or N/A
+16 -2
View File
@@ -25,6 +25,7 @@ on:
- "gradle.properties"
- "gradlew"
- "gradlew.bat"
- "scripts/check-android-locales.py"
- ".github/workflows/ci-android.yml"
pull_request:
branches: [main, dev]
@@ -36,6 +37,7 @@ on:
- "gradle.properties"
- "gradlew"
- "gradlew.bat"
- "scripts/check-android-locales.py"
- ".github/workflows/ci-android.yml"
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
@@ -66,6 +68,9 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
- name: Validate translation catalogs
run: python3 scripts/check-android-locales.py
- name: Run Android lint
run: ./gradlew lint --console=plain
@@ -138,8 +143,8 @@ jobs:
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
# suites tracked by issue #32. Keep CI release-relevant until that suite is
# split: pairing URL derivation plus connection switching are the stable
# Android regression slice for the active release work.
# split: run the stable connection slice plus focused Chat/Voice state,
# parser, layout, and accessibility regressions for the active release.
- name: Run focused Android unit tests
run: |
./gradlew :app:testSideloadDebugUnitTest \
@@ -149,6 +154,15 @@ jobs:
--tests com.hermesandroid.relay.util.ServerAddressTest \
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
--console=plain
# Upload reports only for failures. Successful PR report uploads add
+34 -1
View File
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.4.2] - 2026-07-11
### Added
- **Android now supports Simplified Chinese.** Chat, Manage, Voice, connection setup, settings, diagnostics, notifications, accessibility labels, and both product flavors follow the device language, with Android per-app language discovery on supported versions.
- **Localization is contributor-ready.** CI enforces resource, plural, and format-argument parity; translated README and VitePress entry points establish a repeatable path for adding languages without duplicating fast-moving technical references.
### Fixed
- **Connection scan and queued-message counts use proper plurals.** Count formatting no longer depends on English-only suffix arguments and cannot fail when a locale needs a different plural structure.
## [1.4.1] - 2026-07-11
### Added
- **Background work is visible in Standard Chat.** A live process strip opens a mobile process sheet with running or recent state, output, elapsed time, Stop, and Dismiss controls. It remains compatible with older Hermes servers that do not expose process details.
- **Background work has a clearer Chat home.** Realtime work appears as a titled task card with working, waiting, delivery, and completion states, queued work, and an expandable tool timeline.
- **Multi-image messages open as galleries.** Adjacent images render in a compact grid and open at the selected image in a swipeable viewer while preserving sensitive-media reveal and original-file actions.
- **Voice gains commands and presets.** Spoken commands can stop speech, cancel background work, pause or resume listening, repeat a result, or start Standard voice chat. Hands-free, Low latency, Careful tools, and Quiet presets tune existing interaction settings.
### Changed
- **Streaming Chat content stays steadier and more readable.** Settled prose and headings adopt final Markdown styling during generation, wide tables scroll with readable columns, the thinking indicator respects system motion and TalkBack settings, and the jump-to-bottom control counts unread messages.
- **Offline Demo mode no longer starts Voice.** The mic action now explains locally that a Hermes connection is required.
### Fixed
- **An in-flight Chat turn survives reopening the app.** Session-backed replies restore partial text, live reasoning, lifecycle status, tool/subagent cards, background-task state, and unanswered approval or clarification cards. Current Hermes gateways reattach to the same running turn; older or finished sessions reconcile from history without duplicating the prompt or losing the final answer.
- **Realtime Agent delivery is protected.** Hermes results use exact provider speech where supported, delivery validation, generation-safe confirmation, and a single relay-TTS fallback if the provider closes or rejects delivery. Voice commands no longer leave synthetic cancellation turns or mute a later background answer.
- **Standard Chat receives background-process completions automatically.** When Hermes completes detached work and starts a follow-up turn on the originating Gateway session, Android shows the unsolicited assistant stream in the open conversation and reconciles history after a cold reconnect. The synthetic process prompt is rendered as a compact process notice rather than a user-authored message.
## [1.4.0] - 2026-07-09
### Added
@@ -1503,7 +1534,9 @@ MVP release — native Android companion app for Hermes agent with direct API ch
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...HEAD
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.2...HEAD
[1.4.2]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.1...android-v1.4.2
[1.4.1]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...android-v1.4.1
[1.4.0]: https://github.com/Codename-11/hermes-relay/compare/android-v1.3.0...android-v1.4.0
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
+46
View File
@@ -96,6 +96,52 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
## Stale PR salvage and contributor credit
A valuable pull request can become unsafe to merge when `dev` has materially
changed around it. Maintainers may create a replacement **salvage PR** from the
current `dev` instead of resolving a stale branch by choosing whole conflict
sides.
A salvage PR must:
- Link the original PR and contributor in its title or opening summary.
- Recover only the intended feature; unrelated fork, release, signing, and
generated migration changes stay out.
- Preserve the original commit author when a substantive commit can be safely
cherry-picked.
- Use a verified `Co-authored-by: Name <email>` trailer when the implementation
must be reconstructed or substantially rewritten.
- Include a `Lineage` section listing source and superseded PRs, plus a concise
explanation of integration changes made for current `dev`.
- Run current verification rather than relying on checks from the stale branch.
- Leave a comment linking the replacement before the source PR is closed.
The maintainer remains the committer for integration commits. The original
contributor remains the author or co-author of the recovered work. Do not guess
an email address: use the source commit's verified address or ask the
contributor.
## Localization contributions
English resources are canonical and Android locale catalogs must retain exact
resource and format-argument parity. Read [docs/localization.md](docs/localization.md)
before changing user-facing strings or adding a language.
Translation PRs should cover one locale or one clear catalog refresh. They must
not include custom APK publishing, signing configuration, version bumps, or
fork-specific branding. Run:
```bash
python scripts/check-android-locales.py
./gradlew lint
```
Also identify a fluent reviewer or explain the device and language review used.
Translated READMEs use separate `README.<locale>.md` files; `README.md` remains
the canonical project description. User docs may be added incrementally under
`user-docs/<locale>/`, with links back to canonical English reference material.
## Changelog & writing conventions
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
+202
View File
@@ -1,5 +1,207 @@
# Hermes-Relay — Dev Log
## 2026-07-11 — Android localization foundation and Simplified Chinese
The Android UI now resolves its broad static copy through canonical resources,
with a complete Simplified Chinese catalog under the script-qualified
`values-b+zh+Hans` directory and a matching sideload-flavor catalog. Android's
locale configuration advertises English and Simplified Chinese to system per-app
language settings. Current notification, voice-overlay, background-process,
diagnostic, attachment, card, crash, timeline, and session-TTL surfaces were
reconciled after the original localization branch diverged from `dev`.
`scripts/check-android-locales.py` discovers locale catalogs in every Android
source set and rejects malformed XML, duplicate resources, missing or extra
keys, mismatched resource types, incompatible format arguments, and locale
configuration drift. Android CI runs the checker before lint. Scan summaries and
queued-message counts were converted from English suffix formatting to Android
plurals after validation exposed a missing-format-argument path in the current
connection wizard.
The public contribution workflow now documents stale-PR salvage, preserved
authorship, scoped translation PRs, locale qualifiers, device review, and the
English canonical boundary. The root README links a maintained Chinese summary,
and VitePress exposes a Simplified Chinese locale with localized landing, quick
start, and feature pages while linking fast-moving reference material back to
canonical English.
## 2026-07-10 — In-flight Chat turns recover across app recreation
Current upstream Hermes can keep a running Dashboard/TUI Gateway session alive
after its WebSocket transport disappears. `session.activate` rebinds an exact
live session id, while `session.resume` can reuse a live session by its durable
session key and returns `running`, `status`, and an `inflight` snapshot containing
the user prompt plus partial assistant text. Those fields are enough to recover
the live worker and transcript tail, but upstream intentionally does not persist
Android's reasoning presentation, tool-card lifecycle, pending ask card, or
client-owned background-task UI.
Android now checkpoints one session-backed in-flight turn in the shared app
DataStore. The snapshot is scoped by connection/profile and session, expires
after 24 hours, and contains the user/assistant pair, partial answer, reasoning,
tool and subagent states, lifecycle caption, background-task state, and the
server-issued half of an interactive ask. Entered passwords/secrets are never
written. Mutations are debounced during streaming and flushed immediately when
the app backgrounds, when a tool/ask/session boundary changes, and during
orderly ViewModel teardown.
Returning to Chat first restores the rich local snapshot. Gateway sessions then
activate the saved live id before accepting new deltas; if activation is absent
or the live id has expired, Android resumes by durable session id. A running
payload binds the normal event mapper so reasoning, tool, status, ask, and
completion callbacks continue on the same bubble. A settled or unreachable
worker uses the existing bounded, positionally anchored history recovery, which
also covers sessions-SSE transport loss and route handoff. Explicit Stop and
session/profile/connection changes retain their prior interrupt semantics and
clear the checkpoint; lifecycle teardown detaches without sending
`session.interrupt`.
Regression coverage includes checkpoint round-trip/corruption/expiry, rich
ChatHandler rehydration without duplicated repeated prompts, exact activation,
durable-resume fallback, idle-session settlement, detach-without-interrupt, and
a Robolectric reopen that continues reasoning/tool events and clears the saved
turn after authoritative completion.
## 2026-07-10 — Gateway background processes become visible Chat activity
Current upstream Hermes exposes a session-scoped process registry over the same
Dashboard/TUI Gateway socket Android already uses for Chat. `process.list`
returns running and recently finished entries plus a bounded output tail;
`process.kill` stops one process after verifying session ownership;
`agent.terminal.output`, process status events, and terminal/process tool
completion provide refresh and live-output signals. There is no structured
process-start event, so Android follows the official Desktop reconciliation
recipe: load after session prewarm, refresh on relevant events, and poll every
five seconds only while a process remains running. Method-not-found is treated
as an unsupported optional surface instead of a Chat transport failure.
Live phone verification exposed a start-discovery gap: the Gateway emitted the
assistant turn that confirmed a new process ID but no terminal/process
`tool.complete` event, so Android could not begin the running-only poll and first
found the row from a later reconnect/completion snapshot. Every exact-session
`message.complete` now invalidates the process snapshot as a low-cost fallback;
ordinary tool/status events remain the faster path when upstream emits them.
Chat now exposes that state through a compact composer-adjacent background strip
and a current-chat bottom sheet. Running and recent rows show command, elapsed
time, completion/exit state, expandable live or snapshot output, exact-process
Stop, and local Dismiss. Session/client generations reject stale responses after
a chat or connection switch, and profile context is part of the ownership key
because isolated profile databases can reuse stored session IDs. A newer async
prewarm invalidates an older resume before it can replace the live session.
Reconnects repopulate from `process.list`; the five-second safety poll pauses in
the background unless the user explicitly enabled Gateway keep-alive, so it
cannot reopen the socket after the normal background grace close. Raw process
output is length-only in logcat, never placed in notifications, and ANSI/control
sequences are removed before the plain-text mobile viewer renders it.
Hermes intentionally persists a completed process notification as synthetic
user-role input before starting the agent's follow-up turn. Android now recognizes
the upstream formatter shape and presents that history item as a compact,
expandable process notice rather than a human-authored bubble, while preserving
its wire/history role and excluding it from edit-and-resend behavior.
Focused Gateway transport, process-controller, notification-parser, and output
viewer tests pass on both Android product flavors. Google Play and sideload debug
lint report zero errors, and the sideload debug APK assembles successfully for
physical-device validation.
## 2026-07-10 — Gateway background completions return to ordinary Chat
Upstream Hermes already associates a detached process with the originating
Dashboard/TUI Gateway session. When that process completes, its notification
poller injects a synthetic user event, runs a follow-up agent turn, emits the
normal `message.start` / delta / completion lifecycle, and persists the reply.
Android discarded that lifecycle because `GatewayChatClient` only allocated a
turn mapper after a phone-initiated `sendTurn()`; with no request-scoped
`activeTurn`, every event returned before session filtering or UI dispatch.
The Gateway client now accepts a server-initiated turn only when an explicit
event session exactly matches its active live session and the open Chat still
matches the corresponding stored session. It allocates a fresh mapper, binds a
real cancellable turn handle into `ChatViewModel`, and reuses the normal text,
thinking, tool, ask, status, completion, notification, queue, and authoritative
history-reconciliation paths. Foreign or untagged events remain fail-closed.
The mapper also collapses the adjacent duplicate `message.start` pair currently
emitted by the upstream completion poller, preventing a phantom boundary or
duplicate placeholder.
After a user Stop, the client retains a short exact-session drain tombstone for
the interrupted turn. Its late deltas/terminal event are ignored before a
same-session next prompt is submitted, so canceled output cannot reappear as an
unsolicited answer or prematurely complete the newer turn.
A cold foreground prewarm now refreshes the exact resumed session when no turn
is active, recovering a completion that may have finished while the Gateway
socket was closed without overwriting another session or live response.
Regressions cover no-`sendTurn()` delivery, exact-session filtering, duplicate
starts, error recovery, Chat rendering/finalization, Stop-to-interrupt behavior,
late canceled terminals, queued-send draining, and disconnected history recovery.
## 2026-07-09 — Android 1.4.1 Chat and Voice enhancement batch
Chat now represents a promoted realtime background run as one first-class
assistant turn. The same row moves through queued, running, waiting, delivering,
complete, failed, or cancelled state and owns its tool detail and authoritative
answer. Run IDs retain the initiating assistant-row identity across later turns,
including local Voice commands, so delayed progress or delivery cannot settle a
newer placeholder. Local pause, resume, stop, repeat, and cancel commands are
removed from Chat history, quarantine their provider acknowledgement, and cannot
become the target of Retry. The authoritative answer still persists through the
existing session history, and the in-flight Chat checkpoint now preserves the
client-only task-card metadata across a cold restart.
Streaming Markdown can promote blank-terminated prose and headings without
waiting for the final response, while structurally ambiguous lists, quotes,
tables, HTML, and fences remain in the raw tail. GFM tables now wrap in readable
minimum-width columns inside a horizontally scrollable surface. Contiguous image
attachments render as a bounded gallery with selected-page full-screen paging,
sensitive-action gating, original-byte Share/Save behavior, and no adjacent
full-resolution preload. The thinking indicator follows app and system motion
settings plus TalkBack, the jump-to-bottom affordance reports unread messages,
and the Demo mic explains locally that Voice requires a real connection.
Voice now intercepts only exact, final-transcript commands in states where the
action is safe. Standard Voice supports a rearmed new-chat command; realtime
new-chat remains gated until a persistent WebSocket can be rebound safely. Four
presets compose existing Voice settings without replacing manual controls or
silently enabling experimental barge-in. Preset application updates the relay
first and rolls it back if local persistence fails, with an explicit recovery
message if rollback also fails. Relay event parsing accepts the documented and
legacy field aliases used by current broker events.
Foreground Hermes results now use the same forced-summary lifecycle as protected
background delivery. Non-structured verbatim results take the provider's exact
text path where supported; structured results use constrained instructions.
Provider send or response-request failures emit one authoritative fallback before
the terminal error, and each delivery emits one completion boundary. Delivery
confirmation is generation-scoped so an alarm from an older response cannot
invalidate a newer one. Voice-command response suppression is callback-local,
and forced deliveries remain audible after pause, stop, or background-cancel
commands.
Verification passed the focused Google Play and sideload Chat/Voice unit suites,
including a forced clean rerun of the cross-turn ownership regression. Both
Android lint flavors passed. The realtime route, promotion, validation, xAI, and
OpenAI provider slice passed 94/94 tests. Device validation remains for gallery
gestures, reduced-motion/TalkBack behavior, cross-turn background delivery,
command phrasing, all four presets, provider failure fallback, and the existing
route-loss/audio quality release gates.
## 2026-07-09 — Android and plugin 1.4.0 released
`android-v1.4.0` and `plugin-v1.4.0` were published from the same release
commit. The plugin wheel, source archive, and checksum file were downloaded and
verified after publication. The Android release exposes only the intended
sideload APK, Google Play AAB, and matching checksum file; both downloaded
artifacts matched their recorded hashes, and the APK/AAB certificate digests
matched the release signer.
Google Play accepted Android versionCode 22 as a production draft. The draft
was then promoted to `completed`, starting the production rollout. Extended
physical-device recovery stress testing remains deferred and is tracked in
`TODO.md`; live findings may still require follow-up recovery hardening.
## 2026-07-09 — Android realtime turns survive background route loss
An on-device foreground/resume failure left a realtime turn showing
+17 -34
View File
@@ -1,56 +1,39 @@
# Hermes-Relay-Plugin v__VERSION__
**Release Date:** July 9, 2026
**Release Date:** July 11, 2026
**Since v1.3.0:** Realtime Agent background work gains queued long requests, quick side-session answers, deterministic exact xAI delivery, stronger resume ownership, and a delivery-health report. The plugin now installs through upstream Hermes' native plugin path, handles modern virtual-environment layouts, targets multiple Android devices, protects credential paths in media delivery, and adds sharper doctor checks.
**Since v1.4.0:** Realtime Agent result delivery is more dependable when a provider closes, stalls, or overlaps a newer response. Completed Hermes work stays authoritative through provider-native delivery where available and a single relay-TTS fallback otherwise.
Pairs with Hermes-Relay-Android v1.4.0 for the matching background-task, model/voice selection, resume, and task-chip behavior. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
Pairs with Hermes-Relay-Android v1.4.1 for the matching background-task, voice-command, and result-delivery behavior. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
### Added
- **Queued background voice work.** Up to three additional long requests can wait behind an active Hermes task and start automatically in order; cancelling the active run also clears its queue.
- **Quick side-session answers.** A short follow-up can be answered while a background run continues, without disturbing the durable task or its eventual delivery.
- **Provider-native exact xAI delivery.** Exact non-structured results use xAI's forced speech event so the selected realtime voice reads the authoritative Hermes answer without another model inference step.
- **Multi-device Android Bridge.** Multiple Android clients can remain connected and tools can target a named device class, alias, or explicit device ID. `/bridge/devices` and `/bridge/select-active` expose current routing.
- **Delivery health report.** `python -m plugin.relay.realtime_agent.report` summarizes recent realtime-voice delivery modes and fallback reasons.
### Changed
- **Compatibility bootstrap covers only true gaps.** Current Hermes owns native session CRUD/messages and skill discovery; the optional hook now limits itself to legacy surfaces with no upstream replacement.
- **aiohttp 3.14.1 or newer.** Plugin/package requirements move to the patched dependency line covering the 2026 aiohttp security advisories.
- **Long gateway turns use liveness, not a short RPC cap.** Prompt submit can wait up to the server's long-turn ceiling while idle-progress watchdogs determine whether a turn has actually stalled.
- **Provider-native delivery carries an explicit mode.** Realtime responses consistently identify forced-summary and fallback delivery so the Android client can present one authoritative result.
- **Exact delivery is more direct.** Non-structured verbatim results can use provider-native exact text while natural summaries retain delivery guidance.
### Fixed
- **Native `hermes plugins install` compatibility.** Runtime imports are package-relative, dashboard loading works under the upstream plugin namespace, and doctor exercises the real import chain.
- **Modern install layouts.** The installer detects classic, uv-managed, and containerized environments and points generated services/shims at the interpreter it actually found.
- **Doctor catches wrong dashboard surfaces and duplicate plugin copies.** Operators get an actionable correction instead of silently loading a stale directory or pointing Manage at a headless API server.
- **Resume ownership is generation-safe.** A stale candidate cannot detach an active phone route; confirmed replacements reject old failure/close/fatal callbacks, and failed opening candidates are never activated after their terminal callback.
- **Background results survive route loss.** Resumable sessions retain unacknowledged input and replay missed output, retry budgets start when a route is lost, and a detached durable run can still deliver by resume or notification.
- **One handoff and one ready event.** Duplicate spoken background acknowledgements and duplicate fresh-session ready telemetry are suppressed.
- **Credential files cannot be served as media.** Resolved paths under auth, token, pairing, SSH, relay-secret, and system-config locations are blocked even when general media delivery is permissive.
- **A completed result survives provider failure.** If tool-result submission or a follow-up provider response fails, the relay speaks the authoritative Hermes answer through its fallback path before reporting the provider error.
- **Delivery confirmation ignores stale work.** A generation token prevents an older confirmation alarm from emitting a duplicate answer after a newer delivery or preemption.
- **Fallback completion is unambiguous.** The fallback path emits one complete result event even when the provider's audio render cannot finish.
## Install / update
```bash
# Native upstream plugin path:
hermes plugins install Codename-11/hermes-relay/plugin --enable
# Native upstream plugin path:
hermes plugins install Codename-11/hermes-relay/plugin --enable
# Classic install / update on a systemd host:
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
# or, if already installed:
hermes-relay-update
```
# Classic install / update on a systemd host:
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
# or, if already installed:
hermes-relay-update
## Verify
```bash
hermes relay doctor
python scripts/check-plugin-version-sync.py --expect __VERSION__
```
hermes relay doctor
python scripts/check-plugin-version-sync.py --expect __VERSION__
---
Tag prefixes: Android releases use `android-v*`, plugin releases use `plugin-v*`, and CLI releases use `cli-v*`.
Tag prefixes: Android releases use android-v*, plugin releases use plugin-v*, and CLI releases use cli-v*.
+11
View File
@@ -21,6 +21,7 @@
</p>
<p align="center">
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
@@ -151,6 +152,16 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
</tr>
</table>
### Simplified Chinese
<table>
<tr>
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置 — 全面汉化</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理 — 仪表盘汉化</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航菜单 — 简体中文</b></sub></td>
</tr>
</table>
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
## Features
+104
View File
@@ -0,0 +1,104 @@
<p align="center">
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — 随身携带您的 Hermes 代理" width="800">
</p>
<p align="center">
<strong>运行在您的电脑上,连接到您的设备。</strong><br>
Hermes-Relay 是 <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的原生 Android 客户端,提供流式聊天、免手动语音和代理管理;另有单文件 CLI,让代理在已配对的电脑上安全使用终端、文件和截图工具。
</p>
<p align="center">
<strong>简体中文</strong> · <a href="README.md">English</a><br>
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
<a href="CHANGELOG.md">更新日志</a>
</p>
> 英文 [README.md](README.md) 是最新、完整的项目说明。本页维护中文安装入口和核心功能摘要;协议、架构和维护者文档以英文版本为准。
## 功能简介
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、多连接和配置文件。
- **无需插件的标准路径**:聊天、管理和标准语音可直接连接未修改的上游 Hermes Agent。
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音和电脑工具。
- **安全连接**:二维码配对、Android Keystore、证书固定、按通道授权和可配置会话有效期。
- **远程使用**:可配置 Tailscale 或 HTTPS 地址,在家庭局域网和远程路由之间自动切换。
- **两种 Android 发行渠道**:Google Play 版本适合日常使用;sideload 版本包含完整手机控制能力。
## 快速开始
### 1. 安装 Android 应用
- [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay):自动更新,包含聊天、语音、管理、终端、媒体和通知功能。
- [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases):下载最新 `android-v*` 版本中以 `-sideload-release.apk` 结尾的文件,获得完整手机控制功能。
### 2. 启动 Hermes API 服务
手机需要能够访问 Hermes API 服务,并使用 API 密钥进行身份验证:
```bash
hermes setup --portal
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)"
cat >> ~/.hermes/.env <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$API_SERVER_KEY
EOF
chmod 600 ~/.hermes/.env
echo "Android API URL: http://<电脑IP>:8642 key: $API_SERVER_KEY"
hermes gateway
```
`0.0.0.0` 会让同一网络中的设备访问 API。请保留强密钥;离开可信局域网时,应使用 Tailscale 或 HTTPS 反向代理,不要直接把端口暴露到互联网。
### 3. 在手机上连接
打开应用后,可以:
- 扫描局域网中的 Hermes;
- 手动输入 `http://<主机>:8642` 和 API 密钥;
- 扫描包含 API、Dashboard 和可选 Relay 地址的设置二维码。
如需在手机上管理模型、密钥、技能和配置文件,请运行 Hermes Dashboard,并在应用的 **管理** 页面登录一次。同一登录会话也会启用标准语音。
### 4. 可选:安装 Relay
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音或电脑工具时安装:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
hermes pair
```
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
## 中文界面
<table>
<tr>
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航</b></sub></td>
</tr>
</table>
## 参与翻译
Android 英文资源是规范来源。新增语言必须保持资源名称、类型和格式参数一致,并通过:
```bash
python scripts/check-android-locales.py
./gradlew lint
```
翻译规范、目录命名、复数和占位符规则见 [docs/localization.md](docs/localization.md)。
## 许可证
[MIT](LICENSE) — Copyright (c) 2026 [Axiom-Labs](https://codename-11.dev)
+18 -33
View File
@@ -1,56 +1,41 @@
# Hermes-Relay-Android v1.4.0
# Hermes-Relay-Android v1.4.2
**Release Date:** July 9, 2026
**Release Date:** July 11, 2026
**Since v1.3.0:** Realtime voice can keep a long task moving while you ask a quick follow-up, queue another long request, and deliver the finished answer in the selected realtime voice. Recovery is substantially stronger across backgrounding and route changes, model choices apply to the next session, and stale listening, thinking, reconnecting, and cancellation states no longer strand the voice screen. This release also adds model-catalog refresh, proactive notification rules, multi-device Bridge targeting, session-cleanup plumbing, and broad chat, startup, and security fixes.
**Since v1.4.1:** Hermes-Relay now supports Simplified Chinese throughout the Android app. Android's per-app language settings can switch between English and Simplified Chinese, and the localization structure, validation, and contributor documentation make future languages easier to add safely.
v1.4.0 is recommended for everyone. Realtime Agent remains experimental and pairs with relay plugin v1.4.0; the no-plugin Standard chat and Vanilla Hermes voice paths remain upstream-compatible.
v1.4.2 is recommended for Simplified Chinese users and anyone contributing a translation. This is an Android and documentation release; it does not require a relay plugin update. Standard chat and Vanilla Hermes voice remain compatible with unmodified upstream Hermes.
---
## Download
**Installing on your phone?** Download **`hermes-relay-1.4.0-sideload-release.apk`** and tap it — that's the direct-install build with the full feature set (installs as `com.axiomlabs.hermesrelay.sideload`). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
**Installing on your phone?** Download hermes-relay-1.4.2-sideload-release.apk and tap it — that's the direct-install build with the full feature set (installs as com.axiomlabs.hermesrelay.sideload). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The other file, `hermes-relay-1.4.0-googlePlay-release.aab`, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
The other file, hermes-relay-1.4.2-googlePlay-release.aab, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
Verify integrity with SHA256SUMS.txt from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
---
## Highlights
### Realtime voice that finishes the job
### Simplified Chinese throughout the app
- **Keep talking while work runs.** Quick follow-ups can be answered while one Hermes task runs in the background, and another long request can wait in a bounded queue instead of being discarded.
- **Hear the authoritative answer.** Exact xAI delivery uses provider-native forced speech, finished-task answers can be replayed from the task chip, and TTS/text/notification fallbacks keep a result from disappearing when the realtime floor is unavailable.
- **Stronger route recovery.** Recorded turns wait for relay-confirmed resume, unacknowledged audio is replayed without starting a second Hermes run, and long-lived sessions get a fresh bounded retry window when the route actually drops. Retired sockets and sessions cannot overwrite a newer connection.
- **Clean lifecycle state.** Provider transcripts no longer impersonate active microphone capture; Stop settles local placeholders; exit detaches durable work while clearing session-owned UI; rejected, unacknowledged, or terminal cancels cannot leave an undismissable reconnecting task chip.
- **Your model and voice selection sticks.** Realtime Agent model and voice choices are scoped to the active connection/profile, survive restart, and apply when the next session opens.
- **Use the complete Android experience in Simplified Chinese.** Onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, accessibility labels, and both product flavors are localized.
- **Switch languages with Android.** Supported Android versions expose English and Simplified Chinese through the system's per-app language settings; other versions follow the device language.
- **Get locale-correct counts.** Connection scan results and queued-message counts use Android plurals instead of English-only suffix formatting.
### Chat and model management
### Translation support that can grow
- **Long turns stay alive.** Gateway submits use the server's long-turn window and idle-progress checks, avoiding premature transport fallback and duplicate turns.
- **Phone context reaches Hermes.** Voice-intent traces, card actions, and supported attachments now use payload channels the upstream server actually consumes; unsupported attachment paths report the gap instead of dropping it silently.
- **Refresh model catalogs on demand.** Chat and Manage can explicitly reload dynamic/custom provider models, while Manage keeps unconfigured providers visible with key-setup guidance.
- **Session cleanup groundwork.** The dashboard client supports export, prune preview/apply, archive, restore, and archived-session filtering for the Manage surface.
### Phone automation
- **Notification triggers.** Opt-in rules can match app notifications and show a safe local "Ask Hermes?" prompt, with recent activity and a global pause switch.
- **Multi-device Bridge targeting.** Relay tools can select a paired phone, foldable, tablet, or explicit device ID instead of assuming one Android client.
### Reliability and security
- **Older Android crash safety.** Collection calls that require Android 15 were removed from lower-API paths, and encrypted-storage dependencies are pinned to the compatible line.
- **Bad server addresses fail safely.** Malformed relay, media, session, voice, and chat URLs surface a normal connection error instead of closing the app.
- **Credential paths stay private.** Relay media delivery resolves symlinks and blocks credential, token, pairing, SSH, and system-config locations.
- **Cleaner voice failures.** Duplicate error surfaces are gone, fallback speech animates the voice UI, routine provider idle expiry opens fresh on the next turn, and fresh sessions emit one ready event.
- **Prevent incomplete catalogs.** CI checks resource names, plural structure, and format-argument parity against canonical English resources.
- **Start from documented conventions.** A localization guide, translated README, and Simplified Chinese user-doc entry points define how to add and maintain another language.
- **Preserve contributor credit.** The contribution workflow documents how maintainers salvage valuable stale translation PRs onto current `dev` while retaining original authorship.
---
## Upgrade notes
- App-side release on **both** flavors. Realtime Agent background/recovery features require relay plugin **v1.4.0**; Standard chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- `appVersionCode` is **22**.
- Realtime Agent is still an experimental engine. Stable assistant speech remains available through **Hermes Chat + Voice Output**.
- App version: **1.4.2** (versionCode **24**).
- No relay plugin update is required for localization support.
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
+132 -119
View File
@@ -6,22 +6,42 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Active — next up (2026-07-07)
## Active — 1.4.1 release verification (2026-07-10)
Compaction-safe snapshot of where we are; details in the linked sections below.
Implementation plan: `docs/plans/2026-07-09-1.4.1-chat-voice-enhancements.md`.
Android 1.4.0 / versionCode 22 and plugin 1.4.0 were published on 2026-07-09.
The 1.4.1 Chat and Voice waves are code-complete and merged into local `dev` for
device validation. Version bumps, public release artifacts, push, tags, production
deployment, and store upload remain separate owner-controlled steps.
- **RELEASE IN PROGRESS — cut android-v1.4.0 + plugin-v1.4.0 (owner direction 2026-07-09).** Android is **1.4.0 / versionCode 22**, plugin is **1.4.0**, public release notes and store copy are synchronized, focused realtime recovery tests and Android lint are green, both signed release flavors build, the plugin package builds, and the current sideload APK is installed. Extended on-device recovery stress testing and the force-stop persistence check are explicitly deferred rather than release blockers:
1. Push `dev`, wait for its release-facing CI, merge `dev` -> `main` with a merge commit, then tag the shared merge tip as `plugin-v1.4.0` and `android-v1.4.0`. Plugin is a **MINOR** (it carries #165 native-loader + installer-venv, #170 doctor guardrails, #171 multi-device bridge, #178 dedup guard — not the 1.3.1 patch originally queued).
2. Verify both GitHub releases, their checksums/artifacts, and the Android signing summary.
3. Discard the 1.3.0 Play Console draft, inspect the uploaded 1.4.0 production draft, and start rollout deliberately.
- **Voice bugs being worked now** — see "Voice — on-device findings" below for full detail:
1. Background/resume turn stuck on `Listening...` / `Still working...` — **fixed in code; current APK installed; extended live stress test deferred** (2026-07-09).
2. Tool-call status pills/ordering + stuck "Thinking" — fixed in code; final visual ordering re-check remains.
3. Tap/static click between sentences (`RealtimePcmPlayer` boundary) — still needs an on-device audio repro before fixing.
- **Deferred post-release voice validation.** Repeat long-idle prewarm → record → background/foreground → route-change recovery, terminal retry exhaustion, repeated reopen/exit, cancel-without-ack, and force-stop persistence on physical devices. Capture both Android and relay traces for any recurrence; further recovery hardening or UX refinement may be required from those results.
- **Screen-wake-lock — SHIPPED (2026-07-07).** See "Voice — on-device findings" below.
- **Owner / Mizu — GitHub triage.** Close #64 as superseded, plus the queued open-issue comment/close/label batch (see "Open-issue resolution batch" below).
- **Voice exact-mode signoff — PASSED (2026-07-09 e2e).** Both `grok-voice-latest` and the pinned `grok-voice-think-fast-1.0` deferred on model-generated exact delivery, so xAI exact mode now bypasses inference through provider-native `force_message`. The full on-device background path spoke the authoritative answer through xAI with no fallback, and a pure-recall follow-up repeated it from history without a second Hermes route/run. OpenAI's separate out-of-band delivery spike remains on its next-RC roadmap. Full detail + the background-tasks-as-chat UX asks + remaining audio/UI gaps are below.
Before release preparation, keep these owner/device gates explicit:
- Repeat the exact record → background/route loss → foreground reproduction on the
newly installed debug APK; no `Listening...` / `Still working...` row may strand.
- Recheck long-run tool ordering, the screen wake lock, output waveform timing,
final-syllable tail, and the reported PCM tap/static between sentences.
- Exercise the 1.4.1 Chat surfaces: streaming reflow, wide-table overflow, gallery
paging/zoom/sensitive actions, unread tracking, Demo mic gate, and task-card lifecycle.
- Re-run an ordinary Chat background process through the Gateway: the current-chat
process strip/sheet must show running state, live or snapshot output, exact Stop,
recent completion and Dismiss; the synthetic completion must render as a process
notice, its unsolicited assistant follow-up must appear without another prompt,
and both must survive a socket-close/foreground history refresh without crossing
into a different session or profile. Backgrounding with keep-alive disabled must
also let the Gateway socket close normally instead of polling it back open.
- Start a long Standard Chat turn, wait for visible reasoning plus at least one
running tool card, then background/force-stop/reopen the app. The same session
must restore its partial answer, thinking/status line, tool state, and any live
approval card; new deltas must continue without a duplicate prompt, and a turn
that finished while offline must settle from history instead of staying busy.
- Exercise commands and presets on Standard and Realtime Voice, including ordinary
prompts that resemble commands, explicit stop-vs-cancel behavior, Custom detection,
and preservation of route/provider/model/voice/concurrency/barge-in choices.
- Repeat the Tink encrypted-session smoke: pair → force-stop → relaunch; the session
must persist without an encrypted-preferences startup crash.
- Run release preparation separately: 1.4.1 versioning and public release artifacts,
then owner-controlled `dev` → `main` merge, tag, production deployment, and upload.
- Complete the owner/Mizu GitHub triage batch, including closing #64 as superseded.
---
@@ -60,17 +80,15 @@ Theme: stop treating a background run as an ephemeral voice-only side effect —
surface it in chat like any other turn and keep its result. Overlaps the "Voice
background-run v2" chip roadmap below (items 3/4/7) but reframed around
chat/history rather than the voice chip; unify rather than build twice.
- **Titled background tasks.** Give each run a short title/label (first-line- or
model-derived) so it's identifiable in a list and in chat.
- **Chat entry on kickoff + result.** Drop a chat entry when a background task
starts ("Background task: <title> — running") and settle the result into the same
thread when it finishes. Don't leave it voice-only.
- **Results persisted in chat/history.** Show the background result cleanly in chat
history instead of discarding it after it's spoken — especially valuable for
follow-ups ("what did that say again?").
- **Detail view (expand on tap).** Tapping a background-task chat entry expands to
the full run detail like a normal chat message / tool timeline (reuse
`SubagentLane`). Same intent as v2 items 3+4 — build once.
- **First-class Chat task turn — CODE-COMPLETE for 1.4.1; device verification
remains.** Promotion attaches a short objective title and running state to
the existing assistant row; progress, queued count, waiting/delivery, completion,
failure, cancellation, answer text, and expandable tool detail settle that same
identity. The authoritative answer persists in normal session history. The new
in-flight Chat checkpoint preserves client-only task-card metadata while a turn is
still running across a cold app restart. Metadata for an already-completed task is
still absent from the server history schema after the checkpoint is cleared; keep
that terminal-history case as a separate durability decision.
- **Realtime agent retains background-result context in-session — FALLBACK PATH
DONE + SEEDING LIVE-VERIFIED (2026-07-09); NO-RERUN VERIFY PENDING.** On a FALLBACK delivery the broker now
seeds the delivered answer into the provider's history as an assistant turn
@@ -189,10 +207,15 @@ green. Needs relay deploy + APK install + live verify.
audio, history, and completion lifecycle. Structured results and summary modes
remain model-generated; relay TTS remains the validator fallback. The on-device
background path produced a clean `forced_summary_streaming` event and recall
reused the resulting provider history without another Hermes run. Post-audit hardening remains:
provider-death TTS fallback on all three delivery paths, confirm alarm on all
three, barge-in preemption-as-text, blocklist answer-exemption, and
structured-answer prompt routing.
reused the resulting provider history without another Hermes run.
**1.4.1 post-audit hardening is code-complete:** foreground Hermes results now
enter the same validation/confirmation lifecycle, non-structured Exact delivery
passes authoritative text to provider-native forced speech where supported,
structured answers keep instruction-driven routing, an answer equal to a short
acknowledgement is not falsely blocked, and provider tool-result/response-request
failure emits exactly one authoritative fallback before its terminal error. Live
verify foreground delivery and provider-failure fallback. Barge-in preemption as
durable visible text remains open.
- **Audit leftovers (deliberate, small).** (1) DONE-chip respeak always
renders via relay TTS — intentional determinism, but it voice-mismatches
the exact mode's promise; candidate: provider-voiced respeak with TTS
@@ -209,14 +232,6 @@ wrappers, Android `DiagnosticsLog` Voice category) is in good shape — it
carried every live-round forensics session. Three gaps before the release
candidate:
- **Run-dir retention + wav tap gating — DONE (2026-07-08).**
`run_retention_days` (default 14, 0 disables) sweeps JSONL + wav
artifacts at session-log creation; the render wav is a debug-only tap
(`debug_audio_tap`, default off) deleted after PCM streams.
- **Delivery-outcome rollup — DONE (2026-07-08).**
`python -m plugin.relay.realtime_agent.report [--days N] [--json]`
tallies provider-spoken vs fallback deliveries with reasons; new
`forced_summary_delivered` marker makes clean deliveries countable.
- **Buffered flight-recorder writes (minor).** `_log` open/appends per
event on the event loop, including one line per audio chunk. Fine so
far; switch to a buffered writer if voice sessions ever stutter under
@@ -228,16 +243,13 @@ Full findings with sources in
`docs/plans/2026-07-08-openai-realtime-notes.md`. Headline: the OpenAI
provider already exists and is broker-wired
(`plugin/relay/realtime_agent/providers/openai.py`) but has never had a
live round and defaults to a superseded model. Key provider contrasts vs
recorded live round. The default is already updated to `gpt-realtime-2.1`.
Key provider contrasts vs
xAI: hard 60-min wall-clock session cap (not an inactivity timer),
out-of-band responses (`conversation:"none"` + explicit `input`), async
function calls, per-token pricing (2.1 audio $32/$64 per 1M; mini $10/$20)
vs grok's flat $0.05/min.
- **Bump OpenAI realtime default to `gpt-realtime-2.1` — CODE DONE
(2026-07-08).** Default bumped, `2.1-mini` + rollback `2` in the model
options. Remaining: live connect on 2.1 (covered by the live-verify
item below).
- **Live-verify the OpenAI provider end-to-end.** Code-complete but no
recorded live round (all forensics are grok-voice). Run the xAI
on-device battery (pair → voice turn → `hermes_run_task` →
@@ -263,9 +275,6 @@ vs grok's flat $0.05/min.
instructions, retiring `native_pending_delivery_note`. Success bar:
provider history reads "done" (never "still running") after a promoted
run, verified live.
- **Guardrail test: only `hermes_*` tools advertised on OpenAI
realtime.** Assert `session.update` never advertises hosted-MCP or
non-Hermes tools. Success bar: test fails if any such tool appears.
- **(Defer/eval-only) provider `semantic_vad` vs relay-owned floor.**
Better turn-taking naturalness but moves barge-in ownership off
`RealtimeFloor` — re-architecture, not RC scope.
@@ -277,9 +286,9 @@ tool-calling precision) as the new flagship; `grok-voice-fast-1.0` is
deprecated and the `grok-voice-latest` ALIAS NOW RESOLVES TO THINK-FAST.
We default to the alias everywhere (`config.py:106`,
`providers/xai.py:31`), so the live model may have changed under us —
xAI's docs explicitly say to pin versioned models in production. July also
added 21 multilingual voices, speech tags, voice cloning, session
resumption (30-min inactivity history retention), and a
xAI's docs explicitly say to pin versioned models in production. The current
platform documents five built-in expressive voices, 20+ spoken languages,
speech tags, custom voice IDs, session resumption, and a
`turn_detection.idle_timeout_ms` re-engagement knob.
- **Decide pin-vs-alias, then re-baseline the live delivery rounds.** The
@@ -298,10 +307,12 @@ resumption (30-min inactivity history retention), and a
if resumption is real, the idle-close-and-reseed handling can become
reconnect-and-resume. Success bar: fresh empirical timeout/resume
verdicts recorded in the POC doc.
- **Surface the new voices + speech tags.** `provider_options.py` carries
a static grok voice list; refresh or fetch dynamically, and evaluate
speech tags against the enhanced-voice config contract. Success bar:
new voices selectable in Voice Settings against a live relay.
- **xAI voice catalog + speech-tag UX are code-current; live verify only.** Dynamic
discovery uses xAI's paginated `/tts/voices` surface when auth is available; the
unauthenticated fallback matches the documented built-ins (`eve`, `ara`, `rex`,
`sal`, `leo`; verified 2026-07-09). Voice Settings and Voice Output already expose
the enhanced contract's expressive speech-tag toggle. Exercise both surfaces with
a live xAI relay before release.
## Voice — on-device findings (2026-07-08 e2e realtime test)
@@ -330,10 +341,6 @@ test.**
(payload/metadata). Removed everywhere model-visible (get_status/cancel
default to the active run; the client gets ids via events) + explicit
"never say run/session IDs aloud" in all three instruction sites.
- **Model claimed "I'll add that to the queue" — FIXED (relay, instruction).**
No queue exists (v2 item 2 not built). All handoff/busy instructions now
state "there is no task queue — do not offer to queue or claim to have
queued anything." True multi-task chip stacking remains the v2 queue item.
- **Delivery spoke deferral filler instead of the answer — FIXED (relay).**
The forced-summary validator caught run-id speech (that saved the Minnesota
answer via fallback) but not "One moment while I look that up. I'll report
@@ -395,9 +402,9 @@ cancels). Ranked next increments, in value-per-complexity order:
(at-least-once, unread) — same property as promotion; (c) live verify:
during a long background run, ask a quick second question → answered
inline; ask a second long thing → busy answer unchanged.
2. **Task queue** — upgrade the busy answer from refusal to offer ("want me
to queue it?"): small FIFO in the broker session, start-next-on-completion
with a spoken handoff, chip shows "+1 queued". Pairs with (1).
2. **Task queue — SHIPPED + LIVE-VERIFIED (2026-07-08).** FIFO cap 3,
start-next-on-completion, spoken transition, cancel-clears-queue, and the
`+N queued` chip all landed in the A-E batch above.
3. **Chip tap-through to the transcript** — the run executes on a real
gateway session, so full tool calls/outputs already live in that session's
history; make the chip (or the finished turn) open it. Cheapest "see tool
@@ -410,9 +417,9 @@ cancels). Ranked next increments, in value-per-complexity order:
native async function calling, leave the tool call pending and deliver the
real `function_call_output` late instead of interim-ack + synthetic
instruction text. Needs a live xAI parity check first.
6. **Pending-result FIFO** — `pending_background_result` is a single slot
(correct for one run); generalize to an ordered list the day (1)/(2) land
so two results delivered during a detach don't race.
6. **Pending-result FIFO** — `pending_background_result` is a single slot and
remains correct for the shipped serial queue. Generalize it only with N-way
concurrent background runs so multiple completions can race while detached.
7. **Full N-way concurrent background runs — deliberately deferred.** Needs
session-per-run topology (a gateway session serializes turns), which
fragments conversation context, multiplies delivery/floor/failure modes,
@@ -581,13 +588,13 @@ every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
conversation gets its own beat; long-press haptic on the action menu; streaming dots
gated to pre-first-token. Deferred:
- **Streaming↔final render parity (kill the reflow).** `StreamingMarkdownContent`
renders raw markdown source (`## `, `**bold**`, `- item`) as plain 14sp text for the
whole turn, then swaps to the full renderer at completion — headings still pop
14sp→20sp on finalize (much reduced now that settled headings are small and lists no
longer resize, but not zero). Run the real renderer on the settled prefix and keep
only the trailing unterminated block raw. Riskier (partial-fence flicker) — needs
on-device testing. Highest-effort audit item.
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
the final Markdown renderer during generation while the active tail stays raw.
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
final parse because partial CommonMark containers can re-parent earlier blocks.
Verify that the chosen boundary removes the common heading/prose pop without
introducing partial-fence or list flicker.
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
@@ -597,9 +604,6 @@ gated to pre-first-token. Deferred:
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
changes the look — get design intent before flipping. `isLastInGroup` is now
meaningful (grouping breaks on gaps) so it's ready if wanted.
- **Wide tables.** GFM tables use the default renderer on ~272dp (columns crush);
code fences already horizontal-scroll. Add a custom `table` component in
`markdownComponents` with `horizontalScroll` + ~110dp min column + right-edge fade.
- **Assistant bubble width decoupled from user.** Both cap at 300dp though only the
assistant carries markdown/code; let the assistant run wider (~92% of available /
340–360dp cap) so fences wrap/scroll later. Keep user ~300dp.
@@ -610,8 +614,8 @@ gated to pre-first-token. Deferred:
text selection instead of opening Copy/Quote. Pick one owner (drop
`SelectionContainer`, expose Copy via the menu — chat-app norm — or move actions to a
kebab). Needs on-device confirmation of the current conflict first.
- **Jump-to-bottom FAB unread badge** + drop the no-op tap ripple on bubbles
(`combinedClickable onClick={}` still ripples). Telegram pattern.
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
new ids → every visible bubble replays its enter animation (gateway transport,
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
@@ -760,8 +764,12 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
- **LOOK INTO (own item, owner-requested 2026-06-29): live `/api/ws` transport for a foregrounded Thread.** Goal: when a Thread is open in the app foreground, give it the *same* live experience as Chat (live `reasoning.delta` + tool-progress) by running the turn over the `/api/ws` dashboard-gateway transport into that `source=phone` session, instead of the notification-grade `proactive.reply` path. Spec the experiment: (1) does `session.resume` + `prompt.submit` on a `source=phone` session over `/api/ws` keep `source=phone` (not silently re-tag `tui`)? (2) does it bypass `PhoneAdapter` / the role_authorized reply loop, and does that matter when the user is the one typing? (3) reconcile the two send paths (foreground→`/api/ws`, background/notification→`proactive.reply`) without double-sends. If it holds, a Thread becomes "background-delivered like a DM, but live like Chat when you open it" — the best of both. Until verified, `proactive.reply` stays the only send path.
- **Docs/user-docs for Threads (lockstep — author with the user-facing slices 4–5).** Dev refs are done (ADR 12 carries the unified-session decision + the two-"gateway" split). Still to write when the surface ships: a plain-language `user-docs/features/threads.md` — what a Thread *is*, **Chat vs Threads** (live foreground work vs. persistent, agent-reachable conversations), the two opt-in gates, that it's relay-only — plus a **brief in-app explainer** (e.g. a one-line hint on the Threads filter empty state or a small info affordance, not a wall of text), and `docs/relay-protocol.md` + relay-server route docs for the wire. Replace the stale user-docs "Coming Soon → Push Notifications" row; keep it distinct from the clipboard inbox and the inbound Notification Companion.
- **More Threads fold-ins (capture now, build with the relevant slice).** (a) **Read-state back to the agent** — tell the gateway you saw a proactive message (Discord-style read receipt) so the agent knows; fold into the `proactive.reply.ack` design (#7). (b) **Cross-surface reply** — because a Thread is just a gateway session, a reply could come from the desktop CLI / dashboard too, not only the phone; near-free once unified, verify the reply routing. (c) **Priority/importance on a proactive message** — let the agent mark urgent vs FYI → notification importance / quiet-hours bypass; small payload field + maps to the notifier channel.
- **Per-thread `chat_id`.** Everything is hardcoded `chat_id="phone"` (one thread) today; the adapter already plumbs `chat_id`, so varying it yields multiple threads (per topic, or the agent opening distinct conversations). Ties into the threaded surface.
- **Message status + delivery state.** Surface sent / delivered / queued / failed per message in the thread (depends on outbound buffering's queued state) so the user knows whether the agent actually reached them.
- **Agent-created per-thread `chat_id`.** User-created named Threads and arbitrary
`chat_id` routing are shipped. Remaining: expose a `send_message`-adjacent
agent affordance that can deliberately open/name a project Thread.
- **Queued message state.** Sending/Delivered/Failed bubbles and relay reply ACKs
are shipped. Add an honest Queued state plus Cancel when the offline outbox
exists; do not infer delivery from socket enqueue alone.
- **Auto-title the phone thread** like other sessions (first confirm whether the gateway already auto-titles platform sessions; wire it through if so).
### Discord/Telegram replacement — capability gaps (to fully retire reaching for them)
@@ -769,15 +777,27 @@ The gateway-platform model is the *correct + sufficient architecture* (the phone
- **Guaranteed background delivery (the biggest gap; no push today).** Delivery is **live-WSS-only** + a 24 h relay buffer; there is **no FCM/UnifiedPush** wake-up. If the app process is dead AND not holding a socket, a message waits for the next reconnect, and the relay buffer is ephemeral (lost on relay restart). Discord/Telegram feel instant because they wake the device via push even when the app is dead. Decide a **push transport**: **UnifiedPush/ntfy** (recommended — self-hostable, no Google dependency, upstream *already* ships an `ntfy` platform, on-brand for self-hosted) vs **FCM** (simplest UX but adds Play Services + a push relay; clashes with self-hosted ethos — at most the `googlePlay` flavor) vs **persistent foreground keep-alive service** holding the relay WSS (zero new infra, like `GatewayKeepAliveService`, but battery cost + Doze-fragile). Likely: UnifiedPush primary + foreground-keepalive fallback.
- **Cron / background-job delivery is BROKEN** (already tracked above): `deliver=phone` standalone path → `Unknown platform: phone`. This is load-bearing for "receiver of crons/background jobs" — fix is required, not optional, for the replacement goal.
- **Multi-thread is wired-for but never varied** (already tracked: per-thread `chat_id`). For real DM/channel parity the agent must *open distinct threads* (vary `chat_id` per topic/job), the app must render a **thread list** (N conversations, not one), and replies route back by `chat_id`+`reply_to` (already plumbed).
- **Durable history / scrollback.** The relay buffer is ephemeral; a real messaging surface needs persisted scrollback. Read the gateway **session store** for the `phone` platform's history (relay-exposed read path) so reopening a thread shows the full conversation, not just buffered-while-away.
- **Agent-initiated multi-thread creation remains.** The app already renders N
`source=phone` sessions, user-created Threads vary `chat_id`, and replies route
by `chat_id` + `reply_to`. The missing parity is letting the agent open/name a
distinct Thread for a topic or job.
- **Durable history / scrollback — SHIPPED.** Threads reopen through the gateway
session store; the relay buffer is only the live/offline-delivery layer, not a
parallel history database.
- **Profile = contact mapping (new idea, fold in).** Multiple Hermes **profiles** (distinct agent personas/configs) could each be a distinct thread *source*/"contact" — DMing different agents. Maps cleanly onto the per-thread `chat_id` + source-attribution work; lets the app feel like a contact list of agents.
- **Per-thread notification controls + deep-link (Discord-parity affordances).** Per-thread notification channels, mute/DND/quiet-hours (Phase 3 partially), and a notification that **deep-links into the exact thread** (tap → land in that conversation) so dipping in/out while multitasking is frictionless.
- **Agent-initiated rich content.** Agent → phone thread with **images/cards** (relay media infra + `InboundAttachmentCard`/`HermesCardBubble` already exist on the chat side — reuse). Inbound (phone → agent) reply media stays deferred (text-first), but outbound rich content is low-cost parity.
- **In-thread "agent is working" indicator.** A typing/working state in the thread while the agent thinks/runs tools (Discord typing-dots parity) — the chat surface already has thinking indicators to reuse.
- **Source/platform attribution + filtering in the drawer (NOW READY — owner-requested 2026-06-29; the gateway/Threads surface has shipped).** `/api/sessions` DOES expose `source` (confirmed live: `tui`, `cli`, `api_server`, `web`, `discord`, `telegram`, `cron`, `webhook`, `phone`). Build: **(a)** a clean **source badge** per session in the drawer — phone → the thread-spool (done); discord / telegram / cron / webhook / web → a small per-platform chip/icon (match hermes-desktop's convention); the app's own `tui`/`api_server` chats get no badge (or a subtle one). **(b)** a **filter** (drawer dropdown) to show/hide sources. **(c)** a **setting** (Chat settings) for the default — **hide the agent's other-gateway/automation sessions (cron / webhook / discord / telegram) by default** so the drawer shows just your chats + Threads, with a toggle to reveal them (the live default `state.db` is full of cron/discord/webhook noise). Persist the visibility prefs. Can't see the official desktop (no clone) — infer its chip styling; match exactly if specifics surface. Standard-path: read-only display of the upstream `source` field. Fold cross-restart **Thread-name persistence** (currently in-memory) into this drawer pass.
- **Beta-gate the Threads featureset (owner direction 2026-06-29).** Mark Threads **Beta** with a clean badge in the UI (the Threads filter chip + the best-path "Threads" capability row) until the enhancements land. Full (non-beta) release is gated on: **live `/api/ws` transport for a foregrounded Thread** (an open Thread streams like Chat — the headline), per-session **unread**, the **`chat_id`-on-`/api/sessions` upstream fix** (so threads route after restart / cross-device), and **outbox/retry**.
- **Source/platform attribution, filtering, and Thread-name persistence — SHIPPED.**
The drawer and Chat settings show source badges and persisted visibility filters;
`ThreadNameStore` persists user Thread names across restart and reapplies them to
session rows. Remaining Threads work is the explicit residual list above
(unread, outbox/retry, exact deep-link, agent-created named Threads, and live
foreground `/api/ws`).
- **Threads Beta badges — SHIPPED.** The Threads filter and best-path capability
row render the shared `BetaChip`. Removing Beta remains gated on live foreground
`/api/ws`, per-session unread, upstream `chat_id` exposure, and outbox/retry.
## Voice — Standard-path parity follow-ups
@@ -829,18 +849,19 @@ The client-side mitigations shipped (see DEVLOG 2026-06-27): the `updateSessions
### Thinking indicator — post-v1.3.0 follow-ups
The animated dot-matrix "thinking" indicator shipped in **android-v1.3.0** (Wave/Pulse/Bounce/Sparkle motions + Auto/accent colors, live preview in Chat settings; static when animations are off). Remaining:
The animated dot-matrix "thinking" indicator shipped in **android-v1.3.0**
(Wave/Pulse/Bounce/Sparkle motions + Auto/accent colors, live preview in Chat
settings). The 1.4.1 path also honors app animation settings, OS animator scale,
and TalkBack touch exploration. Remaining:
- **OS-level reduce-motion / TalkBack** — currently gates only on the app's `animationEnabled` pref. Also honor OS reduce-motion + touch-exploration like `CleanChatMode` does (`rememberCleanMotionState().osAnimations`).
- **Optional: promote to a full avatar style** — the alternative scope (a `DotMatrixAvatar` `AgentAvatar` shown everywhere via `LocalAvailableAvatars`, selected in Appearance). Deferred in favor of the narrower in-bubble indicator.
## Demo mode (2026-06-27) — deferred polish
Shipped offline Demo / Explore mode (see DEVLOG 2026-06-27). Core is in; these are non-blocking polish items, none required for the Play "App access" fix:
- **On-device verify (Studio).** Confirm: "Try the demo" on the onboarding Connect page and the standalone Connect screen lands on Chat showing the canned transcript (Markdown, tool-progress card, weather card, code block); the persistent banner shows and its Connect exits demo into the real wizard; demo runs in airplane mode with no network; Manage/Voice show the demo empty state; Bridge/Terminal show their pair-gate; backing out of demo Chat clears the flag so a real connection still works.
- **On-device verify (Studio).** Confirm: "Try the demo" on the onboarding Connect page and the standalone Connect screen lands on Chat showing the canned transcript (Markdown, tool-progress card, weather card, code block); the persistent banner shows and its Connect exits demo into the real wizard; demo runs in airplane mode with no network; the Chat mic explains locally that Voice needs a connection and never attempts transcription; Manage/Voice show the demo empty state; Bridge/Terminal show their pair-gate; backing out of demo Chat clears the flag so a real connection still works.
- **Demo composer is a silent no-op — DONE 2026-07-08.** `sendMessage` now intercepts while `isDemoMode`: echoes the user bubble and appends `DemoContent.composerReply` ("offline demo, can't answer for real — tap Connect in the banner"), both clientOnly so demo-exit's `clearMessages()` wipes them. Wired via `setDemoModeWiring` (unconditional in RelayApp — the client-gated chat init never runs in demo, so ChatViewModel's own handler is null there). On-device check rides the existing demo verify item above.
- **Live voice mode in demo.** The voice-mode overlay (mic) launched from Chat isn't demo-gated — a tap would attempt a transcribe (fails gracefully, no crash). Add a demo notice / disable the mic in demo. (Voice settings screen already shows the demo empty state.)
- **Light typewriter/stream simulation.** The transcript is statically populated; an optional per-token reveal on first entry would better convey the "streaming" feel. Acceptable as static for v1.
- **Optional richer demo.** Could add a second tool type or an image attachment to the transcript to showcase more surfaces; kept minimal/one-file for now.
@@ -863,7 +884,6 @@ Client-side profile-lock + voice fixes (the items marked above) landed via a pla
- **Per-profile voice on Standard (upstream).** `/api/audio/*` is host-global/text-only; the Standard surface still can't carry a per-request voice. Needs the upstream profile-voice / `/v1/audio/*` PR. Until then the client prefers the relay path; consider surfacing an honest "override needs Relay" state when Standard is the effective surface.
- **Profile lock: ChatScreen glyph + export.** The optional lock glyph on the chat-header avatar was skipped (`ChatScreen.kt` is owned by a concurrent session). Decide whether the per-connection lock belongs in settings export/import (it rides the `profile_selections` DataStore).
- **Unit tests — DONE 2026-06-21 (36/36 pass via `:app:testSideloadDebugUnitTest`).** `ProfileLockStoreTest` (9 — uses an in-memory `DataStore` harness; the file-backed factory hits a Windows write-rename/instance race), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12).
- **CHANGELOG.** Add `[Unreleased]` entries (Profile lock → Added; voice override + realtime → Fixed) at build-verify/PR time.
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a &gt;90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
## Hands-free agentic voice backlog
@@ -872,33 +892,25 @@ Goal: make Hermes usable for hands-free work without leaving the operator blind
to tool state, safety prompts, or the current task.
- **Waveform output-start sync** — current input waveform timing feels good, but
- **Waveform output-start sync — SHIPPED; on-device confirmation remains.**
Realtime output now gates on `RealtimePcmPlayer` playback-head movement or
playback-synchronized amplitude through `shouldMarkRealtimeOutputActive`,
matching the basic-TTS path. Confirm visually on-device with the 1.4.1 batch.
the agent-output waveform can unfold and begin movement before audible speech
- **Voice command layer — initial 1.4.1 subset code-complete; live verify and
navigation residuals remain.** Exact final transcripts can stop speech,
explicitly cancel the active background task, pause/resume Continuous mode,
repeat a settled background answer, and start a new Standard chat. Bare `stop`
and `cancel`, partial transcripts, and command-like ordinary prompts stay on the
normal Hermes route. Realtime `new chat` remains gated on a clean websocket
session-rebind boundary; `open overlay` and `return to Hermes` remain future
navigation commands. Verify barge-in Stop, pause during a background run, local
command Chat cleanup, and Continuous rearm on device.
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
gate the unfolded Speaking waveform on the first real playback frame/audio
amplitude. Processing can stay as the folded circular spinner until output is
actually audible.
- **Voice command layer** — reserve local commands that bypass normal agent
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
overlay", "return to Hermes", and "new chat". These should work while the
agent is thinking, speaking, or using tools.
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
short status updates such as "I'm checking the relay logs" or "I found an
error" without waiting for final assistant text. Long tool calls should emit
periodic, low-noise progress updates.
- **Spoken tool progress — baseline shipped; broader hands-free policy remains.**
Realtime background runs already emit milestone speech plus coarse, low-noise
progress with repeat suppression. The 1.4.1 residual is a unified policy across
Voice engines and presets, not another parallel heartbeat implementation.
- **Realtime tool timeline parity** — the voice overlay should render the same
@@ -918,11 +930,12 @@ the current voice task: active objective, last tool result, pending next step,
and whether the agent is waiting on the user.
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
spoken tool progress, confirmations, and overlay availability.
- **Mode presets — CODE-COMPLETE for 1.4.1; live apply/Custom-state verification
remains.** Hands-free, Low latency, Careful tools, and Quiet/visual-only compose
existing interaction and relay-promotion controls. They preserve engine, route,
provider, model, voice, credentials, concurrency, and Hands-free's existing
experimental barge-in choice. Relay update is server-first; local Voice/barge-in
values share one DataStore transaction, with relay rollback on local failure.
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
@@ -1041,7 +1054,6 @@ Follow-ups:
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
- **A6 — multi-image gallery.** N images in one message → grid + swipe-across viewer (Telegram media-group parity).
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
- **D5 — outbound upload progress.** No per-attachment progress during the 60s gateway PDF-render window.
@@ -1049,12 +1061,13 @@ Follow-ups:
## Voice overhaul (shipped 2026-06-18 — `docs/plans/2026-06-18-voice-overhaul.md`)
- **Per-profile voice on Standard (upstream PR).** Upstream `/api/profiles/*` has no voice field and `/api/audio/*` is host-global. Long-term: PR a voice section to the profile config + make `/api/audio/*` honor the active/`?profile=` profile. The relay path already carries per-profile voice; ship that first.
- ~~**Wire connectionId for per-profile voice namespacing.**~~ **Already shipped — stale entry (verified 2026-07-08).** The wiring landed in `0aa1b38` (2026-06-21, the same batch this list belongs to): `RelayApp` has a `LaunchedEffect(activeConnectionId, selectedProfile?.name)` calling `voiceViewModel.setVoicePrefsConnection(activeConnectionId)` *before* `onProfileChanged(...)`, and `applyVoicePrefsScope` pushes `(connectionId, profile)` into `VoicePreferencesRepository.setActiveScope`. Two connections with same-named profiles namespace separately.
- **Realtime-PCM waveform output gating.** The basic-TTS output waveform is now Visualizer-accurate (gated on real playback amplitude), but the realtime path gates `outputAudioActive` on `audioSeen` (first decoded PCM bytes) in `VoiceViewModel.handleRealtimeVoiceEvent`, which can still lead audible output by the `RealtimePcmPlayer` start prebuffer. Gate realtime on actual playback-start (head moved) to match the basic-TTS path.
## Chat clean-mode + pets (shipped 2026-06-18 — `docs/plans/2026-06-18-chat-clean-mode-and-pets.md`)
- **Part-A chat polish (optional bundle).** Per-code-block copy + horizontal scroll, visible copy affordance, mid-stream stall feedback, profile/skill-aware empty-state chips, the ~40-flow recomposition hotspot at the top of `ChatScreen`. (Sphere `contentDescription`/reduced-motion was handled by the clean-mode a11y work.)
- **Part-A chat polish residuals.** Per-code-block copy, horizontal scroll, the
visible copy affordance, and mid-stream stall feedback are shipped. Remaining:
profile/skill-aware empty-state chips and the ~40-flow recomposition hotspot at
the top of `ChatScreen`.
- **Pet hot-load + in-app add/remove (shipped 2026-06-20).** Pets now live-refresh: an `avatarsRefreshTick` keys the avatar `produceState` in `RelayApp`, and Appearance re-scans `pets/` on open and after in-app import/delete — no app restart. Appearance gained "Add a pet" (SAF `.zip` import via `PetImporter`, zip-slip/zip-bomb guarded + validated through `toAvatar`) and an "Installed pets" list with per-pet remove (`PetLoader.deletePet`, confirm dialog, Sphere fallback). Remaining:
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
- `**adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
@@ -1,6 +1,8 @@
v1.4.0 — Realtime voice that finishes the job.
v1.4.2 - Simplified Chinese
• Long voice tasks can queue, keep running while you ask quick follow-ups, and deliver answers in the selected realtime voice.
• Voice sessions recover more reliably after background or route changes and clear stale task states.
• Refresh model catalogs on demand; add opt-in notification rules and multi-device Bridge targeting.
• Safer startup, server-address handling, long chat turns, and credential media access.
Language
* Use onboarding, Chat, Manage, Voice, settings, diagnostics, and notifications in Simplified Chinese.
* Switch between English and Simplified Chinese through Android's app-language settings.
Contributing
* Locale validation and new contributor docs make future translations safer to add.
@@ -0,0 +1,8 @@
v1.4.2 - 简体中文支持
语言
* 入门引导、聊天、管理、语音、设置、诊断和通知现已支持简体中文。
* 可通过 Android 的应用语言设置在英文和简体中文之间切换。
贡献
* 新增语言目录校验和贡献文档,让后续翻译更易于安全添加。
+1
View File
@@ -29,6 +29,7 @@
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.HermesRelay">
+48
View File
@@ -1,5 +1,53 @@
{
"versions": [
{
"version": "1.4.2",
"title": "Simplified Chinese and scalable localization",
"date": "2026-07-11",
"sections": [
{
"header": "Simplified Chinese throughout the app",
"bullets": [
"Use onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, and accessibility labels in Simplified Chinese across both product flavors.",
"Switch between English and Simplified Chinese through Android's per-app language settings on supported versions, or follow the device language elsewhere."
]
},
{
"header": "Localization built to grow",
"bullets": [
"Automated catalog checks protect resource, plural, and format-argument parity, while contributor docs and translated entry points make another language easier to add safely.",
"Connection scan and queued-message counts now use locale-aware Android plurals."
]
}
]
},
{
"version": "1.4.1",
"title": "Chat that keeps up",
"date": "2026-07-11",
"sections": [
{
"header": "Chat that stays with you",
"bullets": [
"Follow background terminal work from a compact process strip and expandable sheet. Its completed answer appears in the same conversation automatically.",
"Close and reopen while a reply runs: partial text, thinking, tool progress, background-task state, and pending approvals return in the same chat without repeating your prompt."
]
},
{
"header": "Voice you can direct",
"bullets": [
"Use spoken commands to pause or resume listening, stop speech, cancel background work, repeat a finished result, or start Standard voice chat.",
"Hands-free, Low latency, Careful tools, and Quiet presets tune existing voice behavior without changing your selected voice or route."
]
},
{
"header": "Clearer conversations",
"bullets": [
"Browse adjacent images as a gallery, read smoother streaming Markdown and wide tables, and see background-process completion as a compact process notice."
]
}
]
},
{
"version": "1.4.0",
"title": "Realtime voice that finishes the job",
+6 -20
View File
@@ -1,22 +1,8 @@
v1.4.0 - Realtime voice that finishes the job
v1.4.2 - Simplified Chinese
Voice
* Keep talking while long work runs: quick follow-ups can be
answered, another long request can queue, and the finished
answer stays in your selected realtime voice.
* Background and route changes recover more reliably. Stale
listening, thinking, reconnecting, and cancel states clear
instead of trapping the voice screen.
* Pick a Realtime Agent model and voice per connection/profile;
the next session uses it and the choice survives restart.
Language
* Use onboarding, Chat, Manage, Voice, settings, diagnostics, and notifications in Simplified Chinese.
* Switch between English and Simplified Chinese through Android's app-language settings.
More control
* Refresh provider model catalogs from Chat or Manage.
* Opt-in notification rules can offer a local "Ask Hermes?"
action, and Bridge tools can target a specific Android device.
Reliability
* Long chats avoid premature transport fallback, phone context
reaches upstream Hermes on supported paths, malformed server
addresses fail safely, and credential files cannot be served
through relay media.
Contributing
* Locale validation and new contributor docs make future translations safer to add.
@@ -9,6 +9,7 @@ import android.media.AudioTrack
import android.os.Build
import android.os.SystemClock
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@@ -25,7 +26,7 @@ import kotlin.math.sqrt
* writes them directly to an AudioTrack so the Android Studio dev build can
* hear provider output without waiting for an encoded file.
*/
class RealtimePcmPlayer(context: Context? = null) {
class RealtimePcmPlayer(private val context: Context? = null) {
private val trackLock = Any()
private val writeLock = Any()
private val audioManager =
@@ -449,7 +450,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Info,
title = "Realtime audio started",
title = context?.getString(R.string.audio_diag_started) ?: "Realtime audio started",
detail = "First sample reached the speaker after ${ttfaMs}ms.",
)
}
@@ -489,7 +490,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime audio not starting",
title = context?.getString(R.string.audio_diag_not_starting) ?: "Realtime audio not starting",
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
"(${mediaVolumeSummaryLocked()}).",
)
@@ -587,7 +588,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime audio stream gap",
title = context?.getString(R.string.audio_diag_stream_gap) ?: "Realtime audio stream gap",
detail = reason,
)
}
@@ -603,7 +604,7 @@ class RealtimePcmPlayer(context: Context? = null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime voice volume muted",
title = context?.getString(R.string.audio_diag_volume_muted) ?: "Realtime voice volume muted",
detail = "Media volume is 0/${maxVolume ?: "?"}.",
)
}
@@ -89,8 +89,8 @@ class AutoDisableWorker(private val context: Context) {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Bridge auto-disabled")
.setContentText("Paused after idle — tap to re-enable in the Bridge tab.")
.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."
@@ -373,8 +373,8 @@ class BridgeForegroundService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes agent has device control")
.setContentText("Bridge is active — tap Disable to stop at any time.")
.setContentTitle(getString(R.string.bridge_notification_control_title))
.setContentText(getString(R.string.bridge_notification_control_body))
.setStyle(NotificationCompat.BigTextStyle().bigText(
"The Hermes agent can currently read the screen and perform " +
"actions on your behalf through the accessibility service. " +
@@ -82,9 +82,9 @@ class BargeInPreferencesRepository(
constructor(context: Context) : this(context.relayDataStore)
companion object {
private val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
private val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
private val KEY_RESUME_AFTER_INTERRUPTION =
internal val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
internal val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
internal val KEY_RESUME_AFTER_INTERRUPTION =
booleanPreferencesKey("barge_in_resume_after_interruption")
}
@@ -120,8 +120,42 @@ data class ChatMessage(
* no status affix.
*/
val deliveryStatus: MessageDeliveryStatus? = null,
/**
* Client-side lifecycle for a promoted/durable Hermes run that belongs to
* this assistant turn. The same message owns the state from promotion
* through delivery so Chat never needs a separate system notice and final
* reply for one task. On the normal post-turn history reconcile this field
* is carried forward with the rest of the client-only enrichment whenever
* the live message can be matched to its server row.
*/
val backgroundTask: BackgroundTaskState? = null,
)
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
data class BackgroundTaskState(
/** Relay run id when supplied; otherwise a stable id derived from the message. */
val id: String,
/** Short objective derived from the associated user turn. */
val title: String,
/** ADR 33 tier: `promoted` or `durable`. */
val tier: String = "promoted",
val phase: BackgroundTaskPhase = BackgroundTaskPhase.RUNNING,
/** Latest meaningful progress line, deliberately not a raw event trace. */
val statusLine: String? = null,
val completedToolCount: Int = 0,
val queuedCount: Int = 0,
val startedAt: Long = System.currentTimeMillis(),
)
enum class BackgroundTaskPhase {
RUNNING,
WAITING,
DELIVERING,
COMPLETE,
FAILED,
CANCELLED,
}
/**
* Structured details about a phone-local voice intent that was dispatched
* in-process via [com.hermesandroid.relay.network.relay.BridgeCommandHandler.handleLocalCommand].
@@ -0,0 +1,162 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* Durable, client-owned snapshot of one in-flight chat turn.
*
* Hermes history is authoritative once a turn finishes, but it cannot recreate
* transient UI that existed before persistence (live reasoning, a running tool,
* an interactive ask, or the latest lifecycle line). This checkpoint bridges
* that gap across Activity recreation and process death. It deliberately stores
* no entered secret/approval response; only the server-issued ask is retained.
*/
@Serializable
data class ChatTurnCheckpoint(
val schemaVersion: Int = CURRENT_SCHEMA,
val contextKey: String,
val sessionId: String,
val liveSessionId: String? = null,
val transport: String,
val user: ChatTurnUserCheckpoint,
val assistant: ChatTurnAssistantCheckpoint,
val turnStatus: String? = null,
val priorUserMessageCount: Int,
val baselineAssistantCount: Int,
val pendingAsk: ChatTurnAskCheckpoint? = null,
val startedAt: Long,
val updatedAt: Long,
) {
companion object {
const val CURRENT_SCHEMA = 1
const val MAX_AGE_MS = 24L * 60L * 60L * 1_000L
}
}
@Serializable
data class ChatTurnUserCheckpoint(
val id: String,
val content: String,
val timestamp: Long,
)
@Serializable
data class ChatTurnAssistantCheckpoint(
val id: String,
val content: String = "",
val timestamp: Long,
val isStreaming: Boolean = true,
val thinkingContent: String = "",
val isThinkingStreaming: Boolean = false,
val inputTokens: Int? = null,
val outputTokens: Int? = null,
val totalTokens: Int? = null,
val estimatedCost: Double? = null,
val agentName: String? = null,
val badges: List<String> = emptyList(),
val cards: List<HermesCard> = emptyList(),
val cardDispatches: List<HermesCardDispatch> = emptyList(),
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
)
@Serializable
data class ChatTurnToolCheckpoint(
val id: String? = null,
val name: String,
val result: String? = null,
val success: Boolean? = null,
val isComplete: Boolean = false,
val error: String? = null,
val runId: String? = null,
val provenance: String? = null,
val startedAt: Long,
val completedAt: Long? = null,
val isGenerating: Boolean = false,
val taskIndex: Int? = null,
val taskLabel: String? = null,
)
@Serializable
data class ChatTurnBackgroundTaskCheckpoint(
val id: String,
val title: String,
val tier: String,
val phase: String,
val statusLine: String? = null,
val completedToolCount: Int = 0,
val queuedCount: Int = 0,
val startedAt: Long,
)
@Serializable
data class ChatTurnAskCheckpoint(
val kind: String,
val requestId: String? = null,
val text: String,
val choices: List<String>? = null,
val envVar: String? = null,
val timeoutSeconds: Int,
val messageId: String,
val cardKey: String,
/** Original receive time, used to preserve an ask's expiry after reopen. */
val receivedAt: Long,
)
interface ChatTurnCheckpointStore {
suspend fun read(): ChatTurnCheckpoint?
suspend fun write(checkpoint: ChatTurnCheckpoint)
suspend fun clear()
}
class DataStoreChatTurnCheckpointStore(
private val dataStore: DataStore<Preferences>,
private val now: () -> Long = System::currentTimeMillis,
) : ChatTurnCheckpointStore {
constructor(context: Context) : this(context.applicationContext.relayDataStore)
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
isLenient = true
}
override suspend fun read(): ChatTurnCheckpoint? {
val raw = runCatching { dataStore.data.first()[KEY_CHECKPOINT] }.getOrNull()
?: return null
val checkpoint = runCatching { json.decodeFromString<ChatTurnCheckpoint>(raw) }.getOrNull()
if (checkpoint == null ||
checkpoint.schemaVersion != ChatTurnCheckpoint.CURRENT_SCHEMA ||
now() - checkpoint.updatedAt > ChatTurnCheckpoint.MAX_AGE_MS
) {
// Cleanup is best-effort. In particular, Windows can briefly keep
// the just-read preferences file open and reject DataStore's atomic
// temp-file rename; an invalid checkpoint must still read as null.
runCatching { clear() }
return null
}
return checkpoint
}
override suspend fun write(checkpoint: ChatTurnCheckpoint) {
dataStore.edit { preferences ->
preferences[KEY_CHECKPOINT] = json.encodeToString(checkpoint)
}
}
override suspend fun clear() {
dataStore.edit { preferences -> preferences.remove(KEY_CHECKPOINT) }
}
private companion object {
val KEY_CHECKPOINT = stringPreferencesKey("chat_inflight_turn_checkpoint_v1")
}
}
@@ -0,0 +1,69 @@
package com.hermesandroid.relay.data
/**
* A process event that upstream Hermes injected into transcript history as a
* synthetic user message.
*
* Hermes intentionally persists these events with role=user so the agent can
* react to them without breaking message-role alternation. UI code should use
* [ChatMessage.hermesProcessNotificationOrNull] to present them as process
* notices without changing their canonical role or content.
*/
data class HermesProcessNotification(
val processId: String,
val headline: String,
val detail: String?,
)
/**
* Recognizes the exact envelope emitted by upstream
* `tools.process_registry.format_process_notification` for background-process
* completion and watch events.
*
* The parser deliberately excludes other `[IMPORTANT: ...]` messages. Those
* can carry unrelated agent instructions and must continue through the normal
* transcript renderer.
*/
object HermesProcessNotificationParser {
private const val ENVELOPE_PREFIX = "[IMPORTANT: Background process "
private const val HEADLINE_PREFIX = "Background process "
fun parse(content: String): HermesProcessNotification? {
val normalized = content.trim()
if (!normalized.startsWith(ENVELOPE_PREFIX) || !normalized.endsWith(']')) {
return null
}
val body = normalized
.removePrefix("[IMPORTANT: ")
.dropLast(1)
val headline = body.substringBefore('\n').trim()
if (!headline.startsWith(HEADLINE_PREFIX)) return null
val identityAndStatus = headline.removePrefix(HEADLINE_PREFIX)
val processId = identityAndStatus.substringBefore(' ')
val status = identityAndStatus.substringAfter(' ', missingDelimiterValue = "")
if (processId.isBlank() || status.isBlank()) return null
val detail = body
.substringAfter('\n', missingDelimiterValue = "")
.trim()
.ifBlank { null }
return HermesProcessNotification(
processId = processId,
headline = headline,
detail = detail,
)
}
}
/**
* Returns the upstream process-notification presentation model only for the
* canonical synthetic user-row shape. The original [ChatMessage.role] remains
* [MessageRole.USER].
*/
fun ChatMessage.hermesProcessNotificationOrNull(): HermesProcessNotification? =
takeIf { it.role == MessageRole.USER }
?.content
?.let(HermesProcessNotificationParser::parse)
@@ -0,0 +1,191 @@
package com.hermesandroid.relay.data
/**
* One-tap bundles over voice settings that already exist in the app and relay.
*
* Presets intentionally do not own voice identity or routing: engine, audio
* route, provider, model, voice, enhanced-voice overrides, and background-run
* concurrency all remain exactly as the user configured them. A preset only
* coordinates interaction ergonomics, barge-in, Realtime trace/session
* behavior, and the existing ADR 33 background-delivery controls.
*/
enum class VoiceModePreset(
val displayName: String,
val shortLabel: String,
val description: String,
internal val localSettings: VoicePresetLocalSettings,
internal val bargeInUpdate: VoicePresetBargeInUpdate,
val promotionUpdate: VoicePresetPromotionUpdate,
) {
HandsFree(
displayName = "Hands-free",
shortLabel = "Hands-free",
description =
"Continuous listening, exact answers, detailed trace, and low-noise " +
"spoken progress after 15 seconds. Your barge-in choice is preserved.",
localSettings = VoicePresetLocalSettings(
interactionMode = "continuous",
silenceThresholdMs = 1250L,
realtimeTraceDetails = true,
realtimePersistentSession = true,
),
// Barge-in remains an explicit experimental opt-in until echo and
// self-recording hardening is complete. Never enable it via a preset.
bargeInUpdate = VoicePresetBargeInUpdate(),
promotionUpdate = VoicePresetPromotionUpdate(
enabled = true,
promoteAfterMs = 6000,
backgroundDefaultMode = "promote",
spokenHandoff = true,
progressSpokenAfterMs = 15000,
progressRepeatMs = 90000,
resultDelivery = "speak_verbatim",
),
),
LowLatency(
displayName = "Low latency",
shortLabel = "Fast",
description =
"Tap capture, the shortest supported silence window, a persistent " +
"session, and a fast visual handoff for long work.",
localSettings = VoicePresetLocalSettings(
interactionMode = "tap",
silenceThresholdMs = 750L,
realtimeTraceDetails = false,
realtimePersistentSession = true,
),
bargeInUpdate = VoicePresetBargeInUpdate(enabled = false),
promotionUpdate = VoicePresetPromotionUpdate(
enabled = true,
promoteAfterMs = 2500,
backgroundDefaultMode = "promote",
spokenHandoff = false,
progressSpokenAfterMs = 0,
resultDelivery = "speak_when_idle",
),
),
CarefulTools(
displayName = "Careful tools",
shortLabel = "Careful",
description =
"Hold-to-talk, uninterrupted foreground tool runs, a detailed trace, and exact result delivery.",
localSettings = VoicePresetLocalSettings(
interactionMode = "hold",
silenceThresholdMs = 1750L,
realtimeTraceDetails = true,
realtimePersistentSession = true,
),
bargeInUpdate = VoicePresetBargeInUpdate(enabled = false),
promotionUpdate = VoicePresetPromotionUpdate(
enabled = false,
backgroundDefaultMode = "foreground",
spokenHandoff = false,
progressSpokenAfterMs = 0,
resultDelivery = "speak_verbatim",
),
),
QuietVisualOnly(
displayName = "Quiet / visual-only",
shortLabel = "Quiet",
description =
"Manual capture with visual long-task handoffs and results. Normal short voice replies still speak.",
localSettings = VoicePresetLocalSettings(
interactionMode = "tap",
silenceThresholdMs = 1250L,
realtimeTraceDetails = true,
realtimePersistentSession = true,
),
bargeInUpdate = VoicePresetBargeInUpdate(enabled = false),
promotionUpdate = VoicePresetPromotionUpdate(
enabled = true,
promoteAfterMs = 6000,
backgroundDefaultMode = "promote",
spokenHandoff = false,
progressSpokenAfterMs = 0,
resultDelivery = "visual_only",
),
);
/** Apply only fields owned by this preset; every other value is preserved. */
fun applyTo(current: VoiceModePresetState): VoiceModePresetState =
current.copy(
voiceSettings = current.voiceSettings.copy(
interactionMode = localSettings.interactionMode,
silenceThresholdMs = localSettings.silenceThresholdMs,
realtimeTraceDetails = localSettings.realtimeTraceDetails,
realtimePersistentSession = localSettings.realtimePersistentSession,
),
bargeInPreferences = current.bargeInPreferences.copy(
enabled = bargeInUpdate.enabled ?: current.bargeInPreferences.enabled,
sensitivity =
bargeInUpdate.sensitivity ?: current.bargeInPreferences.sensitivity,
resumeAfterInterruption = bargeInUpdate.resumeAfterInterruption
?: current.bargeInPreferences.resumeAfterInterruption,
),
promotion = current.promotion?.let(promotionUpdate::applyTo),
)
/** A preset is active only when every field it owns still matches. */
fun matches(current: VoiceModePresetState): Boolean =
current.promotion != null && applyTo(current) == current
}
/** Snapshot used by the pure preset reducer and active-preset detector. */
data class VoiceModePresetState(
val voiceSettings: VoiceSettings,
val bargeInPreferences: BargeInPreferences,
val promotion: VoicePresetPromotionSettings?,
)
/** Relay promotion values mirrored without introducing a data -> network dependency. */
data class VoicePresetPromotionSettings(
val enabled: Boolean = true,
val promoteAfterMs: Int = 6000,
val backgroundDefaultMode: String = "promote",
val spokenHandoff: Boolean = true,
val progressSpokenAfterMs: Int = 0,
val progressRepeatMs: Int = 90000,
val resultDelivery: String = "speak_verbatim",
val maxBackgroundRuns: Int = 1,
)
/** Nullable fields map directly to RelayVoiceClient's partial PATCH contract. */
data class VoicePresetPromotionUpdate(
val enabled: Boolean? = null,
val promoteAfterMs: Int? = null,
val backgroundDefaultMode: String? = null,
val spokenHandoff: Boolean? = null,
val progressSpokenAfterMs: Int? = null,
val progressRepeatMs: Int? = null,
val resultDelivery: String? = null,
val maxBackgroundRuns: Int? = null,
) {
internal fun applyTo(current: VoicePresetPromotionSettings): VoicePresetPromotionSettings =
current.copy(
enabled = enabled ?: current.enabled,
promoteAfterMs = promoteAfterMs ?: current.promoteAfterMs,
backgroundDefaultMode = backgroundDefaultMode ?: current.backgroundDefaultMode,
spokenHandoff = spokenHandoff ?: current.spokenHandoff,
progressSpokenAfterMs = progressSpokenAfterMs ?: current.progressSpokenAfterMs,
progressRepeatMs = progressRepeatMs ?: current.progressRepeatMs,
resultDelivery = resultDelivery ?: current.resultDelivery,
maxBackgroundRuns = maxBackgroundRuns ?: current.maxBackgroundRuns,
)
}
internal data class VoicePresetLocalSettings(
val interactionMode: String,
val silenceThresholdMs: Long,
val realtimeTraceDetails: Boolean,
val realtimePersistentSession: Boolean,
)
internal data class VoicePresetBargeInUpdate(
val enabled: Boolean? = null,
val sensitivity: BargeInSensitivity? = null,
val resumeAfterInterruption: Boolean? = null,
)
/** Null means the current manual values are Custom. */
fun detectVoiceModePreset(current: VoiceModePresetState): VoiceModePreset? =
VoiceModePreset.entries.firstOrNull { it.matches(current) }
@@ -374,4 +374,31 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
suspend fun setRealtimePersistentSession(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
}
/**
* Atomically apply the phone-side portion of [preset]. Only fields owned by
* the preset are written, so route/provider/model/voice overrides and other
* preferences remain untouched. Barge-in shares this DataStore and is
* updated in the same transaction so observers never see a half-applied
* local preset.
*/
suspend fun applyModePreset(preset: VoiceModePreset) {
val local = preset.localSettings
val bargeIn = preset.bargeInUpdate
dataStore.edit { prefs ->
prefs[KEY_INTERACTION_MODE] = local.interactionMode
prefs[KEY_SILENCE_THRESHOLD_MS] = local.silenceThresholdMs.coerceAtLeast(500L)
prefs[KEY_REALTIME_TRACE_DETAILS] = local.realtimeTraceDetails
prefs[KEY_REALTIME_PERSISTENT_SESSION] = local.realtimePersistentSession
bargeIn.enabled?.let {
prefs[BargeInPreferencesRepository.KEY_ENABLED] = it
}
bargeIn.sensitivity?.let {
prefs[BargeInPreferencesRepository.KEY_SENSITIVITY] = it.name
}
bargeIn.resumeAfterInterruption?.let {
prefs[BargeInPreferencesRepository.KEY_RESUME_AFTER_INTERRUPTION] = it
}
}
}
}
@@ -6,6 +6,7 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.PairingPreferences
@@ -306,7 +307,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Insecure relay mode enabled",
title = context?.getString(R.string.conn_diag_insecure_mode) ?: "Insecure relay mode enabled",
detail = "ws:// connections are allowed",
)
}
@@ -332,7 +333,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay route selected",
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
endpointRole = resolved.role,
url = resolved.relay.url,
)
@@ -342,7 +343,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Using configured relay URL",
title = context?.getString(R.string.conn_diag_using_configured_url) ?: "Using configured relay URL",
detail = "No resolver winner",
url = url,
)
@@ -368,7 +369,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket blocked",
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
detail = "ws:// is disabled",
url = url,
)
@@ -379,7 +380,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket URL invalid",
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
detail = "URL must start with ws:// or wss://",
url = url,
)
@@ -408,14 +409,14 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Opening insecure relay socket",
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
url = normalized,
)
} else {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Opening relay socket",
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
url = normalized,
)
}
@@ -757,7 +758,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay socket disconnect requested",
title = context?.getString(R.string.conn_diag_disconnect_requested) ?: "Relay socket disconnect requested",
url = serverUrl,
)
webSocket?.close(1000, "Client disconnect")
@@ -884,7 +885,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay socket connected",
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
url = url,
)
@@ -944,7 +945,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay socket closed",
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
detail = "code=$code reason=$reason",
url = url,
)
@@ -963,7 +964,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket failed",
title = context?.getString(R.string.conn_diag_failed) ?: "Relay socket failed",
detail = listOfNotNull(
t.javaClass.simpleName,
t.message,
@@ -1012,7 +1013,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Session,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect skipped",
title = context?.getString(R.string.conn_diag_reconnect_skipped) ?: "Relay reconnect skipped",
detail = "No paired session or pending pair code",
url = serverUrl,
)
@@ -1036,7 +1037,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect delayed",
title = context?.getString(R.string.conn_diag_reconnect_delayed) ?: "Relay reconnect delayed",
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
url = url,
)
@@ -1049,7 +1050,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect slow-polling",
title = context?.getString(R.string.conn_diag_reconnect_slow_poll) ?: "Relay reconnect slow-polling",
detail = "Server unreachable for a while; retrying every ${SLOW_POLL_BACKOFF_MS / 1000}s until it recovers (a network change reconnects immediately)",
url = url,
)
@@ -1061,7 +1062,7 @@ class ConnectionManager(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay reconnect scheduled",
title = context?.getString(R.string.conn_diag_reconnect_scheduled) ?: "Relay reconnect scheduled",
detail = "Retrying in ${ms / 1000}s",
url = url,
)
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.relay
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -48,6 +50,9 @@ class RelayHttpClient(
* paired). Lets [mediaUrlConfigured] check fetch-readiness without
* suspending; mirrors what [sessionTokenProvider] resolves. */
private val pairedTokenSnapshot: () -> String? = { null },
/** Application context for localized string resources. Nullable for
* backwards-compat with call sites that don't need localization. */
private val context: Context? = null,
) {
companion object {
@@ -904,7 +909,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay URL invalid",
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
detail = e.message,
url = relayUrl,
)
@@ -934,7 +939,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "HTTP ${response.code}",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -948,7 +953,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Empty response",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -965,7 +970,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Non-JSON response",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -979,7 +984,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "status=${status ?: "missing"}",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -993,7 +998,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Missing version field",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1010,7 +1015,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay health ok",
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
detail = "version=$version clients=$clients sessions=$sessions",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1023,7 +1028,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health timeout",
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
detail = "No HTTP response in 3s",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1034,7 +1039,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay connection refused",
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
detail = e.message,
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1045,7 +1050,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: "Network error",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -1056,7 +1061,7 @@ class RelayHttpClient(
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay health failed",
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: e.javaClass.simpleName,
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.network.relay
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
@@ -19,6 +20,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
@@ -541,7 +543,7 @@ class RelayVoiceClient(
getRealtimeProviderOptionsAt(
providerId = providerId,
pathPrefix = "/voice/realtime-agent/providers",
label = "Realtime agent provider options",
label = context.getString(R.string.voice_diag_agent_provider_options),
)
suspend fun validateRealtimeAgentProvider(
@@ -556,7 +558,7 @@ class RelayVoiceClient(
voice = voice,
sampleRate = sampleRate,
pathPrefix = "/voice/realtime-agent/providers",
label = "Realtime agent provider validation",
label = context.getString(R.string.voice_diag_agent_provider_validation),
)
suspend fun updateRealtimeAgentConfig(
@@ -573,7 +575,7 @@ class RelayVoiceClient(
voice = voice,
sampleRate = sampleRate,
path = "/voice/realtime-agent/config",
label = "Realtime agent config update",
label = context.getString(R.string.voice_diag_agent_config_update),
)
/**
@@ -878,7 +880,7 @@ class RelayVoiceClient(
if (resumeAttempted.get()) {
onHandoff(
VoiceHandoffEvent(
label = "Voice handoff failed",
label = context.getString(R.string.voice_diag_handoff_failed),
detail = message,
active = false,
)
@@ -903,7 +905,7 @@ class RelayVoiceClient(
if (resume) {
onHandoff(
VoiceHandoffEvent(
label = "Trying voice route",
label = context.getString(R.string.voice_diag_trying_route),
route = routeLabel(currentWsBase),
active = true,
)
@@ -929,7 +931,7 @@ class RelayVoiceClient(
)
onHandoff(
VoiceHandoffEvent(
label = "Resume sent",
label = context.getString(R.string.voice_diag_resume_sent),
route = routeLabel(webSocket.request().url.toString()),
active = true,
)
@@ -1015,7 +1017,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", t.message, routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Waiting for route",
label = context.getString(R.string.voice_diag_waiting_for_route),
detail = t.message,
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -1029,7 +1031,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", t.message, routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Connection changed",
label = context.getString(R.string.voice_diag_connection_changed),
detail = t.message,
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -1057,7 +1059,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", "Closed $code $reason", routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Waiting for route",
label = context.getString(R.string.voice_diag_waiting_for_route),
detail = "Closed $code $reason",
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -1075,7 +1077,7 @@ class RelayVoiceClient(
requestRouteProbeOnce("Voice output", "Closed $code $reason", routeProbeRequested)
onHandoff(
VoiceHandoffEvent(
label = "Connection changed",
label = context.getString(R.string.voice_diag_connection_changed),
detail = "Closed $code $reason",
route = routeLabel(webSocket.request().url.toString()),
active = true,
@@ -2364,7 +2366,7 @@ class RelayVoiceClient(
)
onHandoff(
VoiceHandoffEvent(
label = "Route changed",
label = context.getString(R.string.voice_diag_route_changed),
previousRoute = routeLabel(previousWsBase),
nextRoute = routeLabel(nextWsBase),
route = routeLabel(nextWsBase),
@@ -2829,7 +2831,7 @@ class RelayVoiceClient(
when (event.type) {
"voice.session.resumed" -> onHandoff(
VoiceHandoffEvent(
label = "Voice reconnected",
label = context.getString(R.string.voice_diag_reconnected),
detail = surface,
route = route,
active = false,
@@ -2839,7 +2841,7 @@ class RelayVoiceClient(
)
"voice.replay.started" -> onHandoff(
VoiceHandoffEvent(
label = "Replaying missed audio",
label = context.getString(R.string.voice_diag_replaying_audio),
route = route,
active = true,
transitionRevision = transitionRevision,
@@ -2847,7 +2849,7 @@ class RelayVoiceClient(
)
"voice.replay.done" -> onHandoff(
VoiceHandoffEvent(
label = "Caught up",
label = context.getString(R.string.voice_diag_caught_up),
detail = surface,
route = route,
active = false,
@@ -2857,7 +2859,7 @@ class RelayVoiceClient(
)
"voice.session.resume_failed" -> onHandoff(
VoiceHandoffEvent(
label = "Resume rejected",
label = context.getString(R.string.voice_diag_resume_rejected),
detail = event.message,
route = route,
active = false,
@@ -2896,10 +2898,12 @@ class RelayVoiceClient(
val resultPreviewValue = (obj["result_preview"] as? JsonPrimitive)?.contentOrNull
?: (obj["result"] as? JsonPrimitive)?.contentOrNull
val reasonValue = (obj["reason"] as? JsonPrimitive)?.contentOrNull
val errorValue = (obj["error"] as? JsonPrimitive)?.contentOrNull
RealtimeVoiceEvent(
type = (obj["type"] as? JsonPrimitive)?.content ?: "unknown",
source = (obj["source"] as? JsonPrimitive)?.contentOrNull,
message = (obj["message"] as? JsonPrimitive)?.contentOrNull
?: errorValue
?: reasonValue,
reason = reasonValue,
statusKey = (obj["status_key"] as? JsonPrimitive)?.contentOrNull,
@@ -2924,7 +2928,7 @@ class RelayVoiceClient(
toolName = toolNameValue,
toolCallId = toolCallIdValue,
resultPreview = resultPreviewValue,
success = (obj["success"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull(),
success = realtimeEventSuccess(obj),
audioBase64 = (obj["audio_base64"] as? JsonPrimitive)?.contentOrNull,
byteCount = (obj["byte_count"] as? JsonPrimitive)?.intOrNull,
sampleRate = (obj["sample_rate"] as? JsonPrimitive)?.intOrNull,
@@ -2937,7 +2941,8 @@ class RelayVoiceClient(
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull,
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
elapsedMs = (obj["elapsed_ms"] as? JsonPrimitive)?.longOrNull,
queuedCount = (obj["queued_count"] as? JsonPrimitive)?.intOrNull,
delivery = (obj["delivery"] as? JsonPrimitive)?.contentOrNull,
@@ -3330,6 +3335,10 @@ data class RealtimeVoiceEvent(
get() = type == "voice.audio.delta" || type == "voice.output_audio.delta"
}
internal fun realtimeEventSuccess(obj: JsonObject): Boolean? =
(obj["success"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull()
?: (obj["ok"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull()
data class VoiceHandoffEvent(
val label: String,
val detail: String? = null,
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.shared
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -85,6 +87,12 @@ class EndpointResolver(
* tests feed a mutable clock to exercise the 30-second TTL.
*/
private val clock: () -> Long = { System.currentTimeMillis() },
/**
* Application context for localized string resources. When null the
* resolver falls back to hardcoded English strings — this is the
* expected path for plain JVM tests.
*/
private val context: Context? = null,
) {
/**
@@ -190,7 +198,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info,
title = "Endpoint selected",
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
detail = "priority=$priority",
endpointRole = winner.role,
url = winner.relay.url,
@@ -203,7 +211,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "No reachable endpoint",
title = context?.getString(R.string.endpoint_diag_no_reachable) ?: "No reachable endpoint",
detail = "${candidates.size} configured route(s) failed health probes",
)
return null
@@ -288,7 +296,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error,
title = "Endpoint probe invalid",
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "Invalid API URL",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -312,10 +320,15 @@ class EndpointResolver(
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
fastClient.newCall(request).execute().use { resp ->
val ok = resp.isSuccessful
val probeTitle = if (ok) {
context?.getString(R.string.endpoint_diag_probe_ok) ?: "Endpoint probe ok"
} else {
context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed"
}
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
title = if (ok) "Endpoint probe ok" else "Endpoint probe failed",
title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -332,7 +345,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe timeout",
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -345,7 +358,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe timeout",
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
@@ -359,7 +372,7 @@ class EndpointResolver(
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe failed",
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName,
endpointRole = candidate.role,
url = candidate.api.url,
@@ -2,8 +2,11 @@ package com.hermesandroid.relay.network.upstream
import android.util.Log
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.BackgroundTaskPhase
import com.hermesandroid.relay.data.BackgroundTaskState
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.MessageDeliveryStatus
import com.hermesandroid.relay.data.MessageRole
@@ -242,9 +245,8 @@ class ChatHandler {
// User-chosen Thread names (sessionId → name), authoritative over the
// server's auto-title — applied in [updateSessions] so the gateway's async
// auto-titler can't clobber the name. Fed by ChatViewModel. In-memory for
// now (survives list refreshes within a session); cross-restart persistence
// is a follow-up (see TODO).
// auto-titler can't clobber the name. ChatViewModel hydrates this map from
// ThreadNameStore, so names survive both list refreshes and app restarts.
private val userThreadNames = mutableMapOf<String, String>()
/** Record a user-chosen name for one Thread session + re-apply it now. */
@@ -366,6 +368,31 @@ class ChatHandler {
}
}
/** Attach the first Chat-visible state for a promoted/durable Hermes run. */
fun setBackgroundTask(messageId: String, task: BackgroundTaskState) {
_messages.update { list ->
list.map { message ->
if (message.id == messageId) message.copy(backgroundTask = task) else message
}
}
}
/** Update an existing task in place; no-op when the message/task is absent. */
fun updateBackgroundTask(
messageId: String,
transform: (BackgroundTaskState) -> BackgroundTaskState,
) {
_messages.update { list ->
list.map { message ->
if (message.id == messageId && message.backgroundTask != null) {
message.copy(backgroundTask = transform(message.backgroundTask))
} else {
message
}
}
}
}
/**
* Append a SYSTEM-role notice bubble (e.g. a gateway interactive ask the
* phone can't answer). SYSTEM role keeps it out of the voice TTS observer
@@ -863,6 +890,136 @@ class ChatHandler {
}
}
/**
* Rehydrate the last client-owned state of an unfinished turn.
*
* The caller loads server history first. That means the user row may already
* be present while the assistant row is not yet durable; positional matching
* avoids duplicating short repeated prompts. Rich assistant-only state is
* then restored so thinking and tool cards do not reset to an empty spinner.
*/
fun restoreInFlightTurn(
checkpoint: ChatTurnCheckpoint,
upstreamAssistantText: String? = null,
) {
val user = checkpoint.user
val assistant = checkpoint.assistant
val upstreamText = upstreamAssistantText.orEmpty()
val currentAssistant = _messages.value.lastOrNull { it.id == assistant.id }
val restoredContent = listOf(
assistant.content,
upstreamText,
currentAssistant?.content.orEmpty(),
).maxByOrNull { it.length }.orEmpty()
val checkpointTools = assistant.toolCalls.map { tool ->
ToolCall(
id = tool.id,
name = tool.name,
args = null,
result = tool.result,
success = tool.success,
isComplete = tool.isComplete,
error = tool.error,
runId = tool.runId,
provenance = tool.provenance,
startedAt = tool.startedAt,
completedAt = tool.completedAt,
isGenerating = tool.isGenerating,
taskIndex = tool.taskIndex,
taskLabel = tool.taskLabel,
)
}
val currentTools = currentAssistant?.toolCalls.orEmpty()
val restoredTools = buildList {
checkpointTools.forEach { checkpointTool ->
val live = currentTools.firstOrNull {
(it.id != null && it.id == checkpointTool.id) ||
(it.id == null && checkpointTool.id == null &&
it.name == checkpointTool.name &&
it.taskIndex == checkpointTool.taskIndex)
}
add(live ?: checkpointTool)
}
currentTools.filterTo(this) { live ->
checkpointTools.none { checkpointTool ->
(live.id != null && live.id == checkpointTool.id) ||
(live.id == null && checkpointTool.id == null &&
live.name == checkpointTool.name &&
live.taskIndex == checkpointTool.taskIndex)
}
}
}
val restoredBackgroundTask = assistant.backgroundTask?.let { task ->
BackgroundTaskState(
id = task.id,
title = task.title,
tier = task.tier,
phase = runCatching { BackgroundTaskPhase.valueOf(task.phase) }
.getOrDefault(BackgroundTaskPhase.RUNNING),
statusLine = task.statusLine,
completedToolCount = task.completedToolCount,
queuedCount = task.queuedCount,
startedAt = task.startedAt,
)
}
val restoredAssistant = ChatMessage(
id = assistant.id,
role = MessageRole.ASSISTANT,
content = restoredContent,
timestamp = assistant.timestamp,
isStreaming = true,
toolCalls = restoredTools,
thinkingContent = listOf(
assistant.thinkingContent,
currentAssistant?.thinkingContent.orEmpty(),
).maxByOrNull { it.length }.orEmpty(),
isThinkingStreaming = currentAssistant?.isThinkingStreaming
?: assistant.isThinkingStreaming,
inputTokens = currentAssistant?.inputTokens ?: assistant.inputTokens,
outputTokens = currentAssistant?.outputTokens ?: assistant.outputTokens,
totalTokens = currentAssistant?.totalTokens ?: assistant.totalTokens,
estimatedCost = currentAssistant?.estimatedCost ?: assistant.estimatedCost,
agentName = currentAssistant?.agentName ?: assistant.agentName ?: activeAgentName,
badges = (assistant.badges + currentAssistant?.badges.orEmpty()).distinct(),
cards = currentAssistant?.cards?.takeIf { it.isNotEmpty() } ?: assistant.cards,
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
?: assistant.cardDispatches,
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
)
activeAgentName = restoredAssistant.agentName ?: activeAgentName
_messages.update { current ->
val withoutOldAssistant = current.filterNot { it.id == assistant.id }
val users = withoutOldAssistant.filter { it.role == MessageRole.USER }
val positionalUser = users.getOrNull(checkpoint.priorUserMessageCount)
val hasUser = withoutOldAssistant.any { it.id == user.id } ||
positionalUser?.content?.trim() == user.content.trim()
val withUser = if (hasUser) {
withoutOldAssistant
} else {
withoutOldAssistant + ChatMessage(
id = user.id,
role = MessageRole.USER,
content = user.content,
timestamp = user.timestamp,
)
}
val insertBeforeAsk = withUser.indexOfFirst {
it.clientOnly && it.id.startsWith("ask-")
}
val restored = if (insertBeforeAsk >= 0) {
withUser.toMutableList().apply { add(insertBeforeAsk, restoredAssistant) }
} else {
withUser + restoredAssistant
}
restored.let { list ->
if (list.size > MAX_MESSAGES) list.drop(list.size - MAX_MESSAGES) else list
}
}
_isStreaming.value = true
_turnStatus.value = checkpoint.turnStatus ?: "Reconnecting to the active turn…"
}
fun clearMessages() {
_messages.value = emptyList()
// Drop any pending line buffers / dedupe state so a fresh session
@@ -2815,6 +2972,10 @@ class ChatHandler {
fun setLastSentMessage(text: String) {
_lastSentMessage.value = text
}
fun clearLastSentMessage() {
_lastSentMessage.value = null
}
}
/**
@@ -25,6 +25,7 @@ import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -32,7 +33,9 @@ import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
/**
@@ -49,8 +52,9 @@ import java.util.concurrent.atomic.AtomicLong
* in a background thread that keeps emitting on the id it was STARTED with,
* regardless of WS state. So a mid-turn socket drop is recovered by
* reconnecting the socket and KEEPING the in-flight session id (see
* [attemptMidTurnRejoin]) — NOT by `session.resume`, which mints a brand-new
* id + a fresh agent rebuilt from DB and would orphan the still-running turn.
* [attemptMidTurnRejoin]). Current upstream Hermes can also rebind a detached
* live session through `session.activate` / `session.resume`; the direct socket
* rejoin remains compatible with older gateways and avoids an extra RPC.
* No background reconnect loops; a fresh send reconnects on demand.
*
* Auth: every connect attempt mints a FRESH single-use ws-ticket (30s TTL)
@@ -151,6 +155,8 @@ class GatewayChatClient(
private const val CONNECT_FAILURE_COOLDOWN_MS = 5_000L
private const val RATE_LIMIT_COOLDOWN_MS = 300_000L
private const val CONNECT_ATTEMPTS = 2
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
private const val CANCELLED_TURN_SUBMIT_WAIT_MS = 2_000L
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
private const val MAX_TURN_REJOINS = 4
@@ -213,6 +219,14 @@ class GatewayChatClient(
private val _connectionState = MutableStateFlow(GatewayConnectionState.Idle)
val connectionState: StateFlow<GatewayConnectionState> = _connectionState.asStateFlow()
/**
* Per-socket feature probe for upstream's session-scoped `process.*` RPCs.
* Method-not-found marks the current socket unsupported; reconnecting resets
* this to [GatewayProcessCapability.Unknown] so a server upgrade is noticed.
*/
private val _processCapability = MutableStateFlow(GatewayProcessCapability.Unknown)
val processCapability: StateFlow<GatewayProcessCapability> = _processCapability.asStateFlow()
/**
* Active personality the gateway is applying, as a config value ("none" when
* the overlay is cleared, otherwise the personality name). Tracks the
@@ -286,6 +300,8 @@ class GatewayChatClient(
private var readySignal: CompletableDeferred<Unit>? = null
private val rpcId = AtomicLong(1)
/** Invalidates an older async prewarm when a newer session selection wins. */
private val prewarmRequestGeneration = AtomicLong(0)
private val pendingRpcs = ConcurrentHashMap<Long, CompletableDeferred<JsonObject>>()
/** Live (per-connection) session id ←→ the stored DB id it was resumed/created from. */
@@ -295,6 +311,10 @@ class GatewayChatClient(
@Volatile
private var storedSessionId: String? = null
/** Profile namespace that owns [liveSessionId]; stored IDs are not globally unique. */
@Volatile
private var liveSessionProfile: String? = null
/**
* Supplies the profile to bind each `session.create` / `session.resume` to —
* the upstream `tui_gateway` opens that profile's HERMES_HOME/db and builds
@@ -331,6 +351,49 @@ class GatewayChatClient(
@Volatile
private var activeTurn: GatewayTurn? = null
/**
* Upstream may emit the interrupted turn's tail and terminal event after
* `session.interrupt` returns. Keep a short exact-session tombstone so that
* tail cannot be mistaken for an unsolicited completion or complete a
* newly submitted turn. A same-session send briefly waits for this drain
* before `prompt.submit`; once a turn actually started, its tombstone stays
* until the required terminal event even when that submit wait elapses.
*/
@Volatile
private var cancelledTurnDrain: CancelledTurnDrain? = null
private data class CancelledTurnDrain(
val storedSessionId: String,
val liveSessionId: String,
val submitWaitUntilMs: Long,
val terminalRequired: Boolean,
)
/**
* Creates UI callbacks when the server starts a turn that has no matching
* [sendTurn] call (for example a background-process completion). The
* provider is consulted only for an explicit `message.start` whose live
* session id exactly matches this client's active session.
*/
@Volatile
private var unsolicitedTurnProvider: ((storedSessionId: String) -> GatewayInboundTurnRegistration?)? = null
/** Recover persisted events that may have completed while the socket was closed. */
@Volatile
private var coldPrewarmSessionReadyListener: ((storedSessionId: String) -> Unit)? = null
/** Exact-session completion observed without a bound live mapper. */
@Volatile
private var unmatchedTurnCompleteListener:
((storedSessionId: String, expectedAssistantText: String?) -> Unit)? = null
/**
* Connection-level process listener. Unlike [GatewayTurnCallbacks], this is
* consulted even when there is no locally initiated [activeTurn].
*/
@Volatile
private var processEventListener: ((GatewayProcessEvent) -> Unit)? = null
/**
* Which upload RPC name this socket understands — set after the first
* successful upload so the legacy fallback is probed at most once per
@@ -411,7 +474,10 @@ class GatewayChatClient(
// alive AND the requested session already live). A "cold" turn re-pays
// ticket/ws/session — exactly the asymmetry vs always-connected desktop.
val socketWarm = webSocket != null && readySignal?.isCompleted == true
val sessionWarm = liveSessionId != null && storedSessionId == sessionId && sessionId != null
val sessionWarm = liveSessionId != null &&
storedSessionId == sessionId &&
sessionId != null &&
liveSessionProfile == currentSessionProfile()
turn.tracer.warm(socketWarm && sessionWarm)
scope.launch {
try {
@@ -428,6 +494,7 @@ class GatewayChatClient(
}
}
if (turn.cancelled) return@launch
if (!awaitCancelledTurnDrain(turn, storedSessionId)) return@launch
activeTurn = turn
turn.armWatchdog()
val submitted = rpc(
@@ -457,7 +524,7 @@ class GatewayChatClient(
)
return@launch
}
activeTurn = null
if (activeTurn === turn) activeTurn = null
turn.disarmWatchdog()
throw GatewayPreflightException(
submitted.exceptionOrNull()?.message ?: "prompt.submit failed",
@@ -470,7 +537,7 @@ class GatewayChatClient(
// read-the-absence exercise.
Log.i(TAG, "Gateway turn submitted (session=$storedSessionId)")
} catch (e: Exception) {
activeTurn = null
if (activeTurn === turn) activeTurn = null
if (!turn.cancelled) {
Log.w(TAG, "Gateway preflight failed: ${e.message}")
turn.tracer.done("preflight-fail")
@@ -483,8 +550,11 @@ class GatewayChatClient(
/** Drop the remembered session so the next send creates a fresh one. */
fun clearSession() {
prewarmRequestGeneration.incrementAndGet()
liveSessionId = null
storedSessionId = null
liveSessionProfile = null
cancelledTurnDrain = null
}
/**
@@ -496,6 +566,10 @@ class GatewayChatClient(
*/
fun hasActiveTurn(): Boolean = activeTurn?.ended == false
/** Live id to persist beside a durable stored id while a turn is active. */
fun currentLiveSessionId(storedId: String): String? =
liveSessionId?.takeIf { storedSessionId == storedId }
/**
* Point this client at a new dashboard route (e.g. LAN→Tailscale after a
* sustained network change). If a turn is in flight, the current socket is
@@ -528,6 +602,30 @@ class GatewayChatClient(
if (!enabled && !AppForegroundTracker.isForeground.value) scheduleBackgroundClose()
}
/** Process polling must not silently undo the normal background socket close. */
fun isBackgroundProcessPollingAllowed(): Boolean =
AppForegroundTracker.isForeground.value || keepAliveInBackground
fun setUnsolicitedTurnProvider(
provider: ((storedSessionId: String) -> GatewayInboundTurnRegistration?)?,
) {
unsolicitedTurnProvider = provider
}
fun setColdPrewarmSessionReadyListener(listener: ((storedSessionId: String) -> Unit)?) {
coldPrewarmSessionReadyListener = listener
}
fun setUnmatchedTurnCompleteListener(
listener: ((storedSessionId: String, expectedAssistantText: String?) -> Unit)?,
) {
unmatchedTurnCompleteListener = listener
}
fun setProcessEventListener(listener: ((GatewayProcessEvent) -> Unit)?) {
processEventListener = listener
}
/**
* Establish the socket (and resume an existing session) ahead of the
* user's first send, so a warm turn reaches first token in tens of ms
@@ -557,15 +655,150 @@ class GatewayChatClient(
* session, so this path is the common case, not the edge case).
*/
suspend fun prewarmAwait(storedSessionId: String?): Boolean {
val requestGeneration = prewarmRequestGeneration.incrementAndGet()
val requestedProfile = currentSessionProfile()
val wasLiveForRequestedSession = storedSessionId != null &&
liveSessionId != null &&
this.storedSessionId == storedSessionId &&
liveSessionProfile == requestedProfile
try {
connectMutex.withLock {
ensureConnected()
if (storedSessionId != null) resumeForPrewarm(storedSessionId)
if (storedSessionId != null) {
resumeForPrewarm(storedSessionId, requestedProfile, requestGeneration)
}
}
} catch (e: Exception) {
Log.d(TAG, "Gateway prewarm skipped: ${e.message}")
}
return liveSessionId != null
val sessionReady = storedSessionId != null &&
liveSessionId != null &&
this.storedSessionId == storedSessionId &&
liveSessionProfile == requestedProfile
if (!wasLiveForRequestedSession && sessionReady) {
callbackDispatcher {
coldPrewarmSessionReadyListener?.invoke(storedSessionId)
}
}
return sessionReady
}
/**
* Reattach callbacks to a turn that survived the Android UI/process.
*
* New Hermes gateways expose `session.activate`, which attaches the new
* WebSocket transport to the exact live id saved in the client checkpoint.
* If that id has already been reaped (or the method is unavailable), fall
* back to `session.resume` by durable session id. Its `running` + `inflight`
* fields decide whether a live mapper is installed or history should settle
* the turn instead.
*/
suspend fun recoverTurn(
storedId: String,
preferredLiveId: String?,
callbacks: GatewayTurnCallbacks,
): Result<GatewaySessionRecovery> = runCatching {
require(storedId.isNotBlank()) { "stored session id required" }
val requestedProfile = currentSessionProfile()
connectMutex.withLock {
val existing = activeTurn
if (existing != null && !existing.ended) {
throw GatewayRpcException("a gateway turn is already attached")
}
ensureConnected()
var response: JsonObject? = null
var boundTurn: GatewayTurn? = null
if (!preferredLiveId.isNullOrBlank()) {
// Bind before session.activate: upstream swaps the live session's
// transport during the RPC, so an immediate next delta must not
// fall through the active-turn gate while the ack is in flight.
liveSessionId = preferredLiveId
storedSessionId = storedId
liveSessionProfile = requestedProfile
boundTurn = GatewayTurn(
callbacks = dispatchOn(callbacks),
dedupeAdjacentMessageStarts = true,
).also { turn ->
turn.markRecoveredStarted()
activeTurn = turn
}
val activated = rpc(
"session.activate",
buildJsonObject {
put("session_id", preferredLiveId)
},
)
response = activated.getOrNull()
if (response == null) {
if (activeTurn === boundTurn) activeTurn = null
boundTurn.detach()
boundTurn = null
Log.d(
TAG,
"Exact live-session activation unavailable; resuming durable session " +
"(${activated.exceptionOrNull()?.message})",
)
}
}
if (response == null) {
response = rpc(
"session.resume",
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
requestedProfile?.let { put("profile", it) }
},
).getOrElse { error -> throw error }
}
val recoveredLiveId = response.stringField("session_id")
?: throw GatewayRpcException("session recovery returned no session_id")
liveSessionId = recoveredLiveId
storedSessionId = storedId
liveSessionProfile = requestedProfile
updateCancelledDrainLiveSession(storedId, recoveredLiveId)
(response["info"] as? JsonObject)?.let { applySessionInfo(it) }
val inflight = (response["inflight"] as? JsonObject)?.let { value ->
GatewayInflightTurn(
user = value.stringField("user").orEmpty(),
assistant = value.stringField("assistant").orEmpty(),
streaming = value.booleanField("streaming") == true,
)
}
val running = response.booleanField("running") == true || inflight?.streaming == true
if (running) {
if (boundTurn == null || boundTurn.ended) {
boundTurn = GatewayTurn(
callbacks = dispatchOn(callbacks),
dedupeAdjacentMessageStarts = true,
).also { turn ->
turn.markRecoveredStarted()
activeTurn = turn
}
}
boundTurn.armWatchdog()
} else {
if (boundTurn != null) {
if (activeTurn === boundTurn) activeTurn = null
boundTurn.detach()
}
boundTurn = null
}
GatewaySessionRecovery(
storedSessionId = storedId,
liveSessionId = recoveredLiveId,
running = running,
status = response.stringField("status"),
inflight = inflight,
handle = boundTurn?.takeUnless { it.ended },
)
}
}
/**
@@ -669,6 +902,61 @@ class GatewayChatClient(
.onSuccess { commandsCatalogCache = it }
}
/**
* Fetch the current chat session's running and recently-finished background
* processes. Callers never provide a session id: this wrapper resolves and
* sends the exact LIVE gateway id, not the stored history id exposed to UI.
*
* A remembered stored session is resumed after a socket reconnect. A brand-
* new chat has no server process ownership yet and therefore returns an empty
* snapshot without creating an otherwise-empty session.
*/
suspend fun listProcesses(): Result<List<GatewayProcess>> {
if (_processCapability.value == GatewayProcessCapability.Unsupported) {
return Result.failure(processFeatureUnsupported())
}
if (liveSessionId == null && storedSessionId == null) {
return Result.success(emptyList())
}
val sid = ensureLiveProcessSession().getOrElse { return Result.failure(it) }
val result = rpc(
"process.list",
buildJsonObject { put("session_id", sid) },
)
if (result.isFailure) {
markProcessUnsupportedIfNeeded(result.exceptionOrNull())
return Result.failure(result.exceptionOrNull() ?: GatewayRpcException("process.list failed"))
}
_processCapability.value = GatewayProcessCapability.Supported
return Result.success(
((result.getOrThrow()["processes"] as? JsonArray).orEmpty()).mapNotNull(::parseGatewayProcess),
)
}
/** Stop one process owned by the current live gateway session. */
suspend fun killProcess(processId: String): Result<Unit> {
if (processId.isBlank()) {
return Result.failure(GatewayRpcException("process id required"))
}
if (_processCapability.value == GatewayProcessCapability.Unsupported) {
return Result.failure(processFeatureUnsupported())
}
val sid = ensureLiveProcessSession().getOrElse { return Result.failure(it) }
val result = rpc(
"process.kill",
buildJsonObject {
put("session_id", sid)
put("process_id", processId)
},
)
if (result.isFailure) {
markProcessUnsupportedIfNeeded(result.exceptionOrNull())
return Result.failure(result.exceptionOrNull() ?: GatewayRpcException("process.kill failed"))
}
_processCapability.value = GatewayProcessCapability.Supported
return Result.success(Unit)
}
/**
* Run a full slash command line (`slash.exec {session_id, command}`) on
* the live session. Returns the raw result object; failures carry the
@@ -911,6 +1199,11 @@ class GatewayChatClient(
fun shutdown() {
activeTurn?.cancel()
activeTurn = null
cancelledTurnDrain = null
unsolicitedTurnProvider = null
coldPrewarmSessionReadyListener = null
unmatchedTurnCompleteListener = null
processEventListener = null
closeSocket("client shutdown")
backgroundCloseJob?.cancel()
// Stop the foreground collector — a replaced client must not keep
@@ -951,6 +1244,7 @@ class GatewayChatClient(
private suspend fun connectOnce() {
val connectStart = System.nanoTime()
_processCapability.value = GatewayProcessCapability.Unknown
_connectionState.value = GatewayConnectionState.MintingTicket
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
@@ -989,28 +1283,39 @@ class GatewayChatClient(
* (no [GatewayTurn] context). Failure is silent — the real send's
* [ensureSession] will resume-or-create properly.
*/
private suspend fun resumeForPrewarm(storedId: String) {
// Never resume while a turn is in flight: a resume mints a NEW live
// session id, and the running turn's events (still tagged with the
// OLD id) would then be filtered out as "foreign" — orphaning the
// turn and letting a stale reconcile repaint an earlier reply. This
// is the screen-return (prewarm) variant of the same hazard the
// mid-turn rejoin avoids by NOT resuming.
private suspend fun resumeForPrewarm(
storedId: String,
requestedProfile: String?,
requestGeneration: Long,
) {
// Never change session binding while this client already owns a live
// mapper. Current upstream may reuse the same live session, while older
// builds mint a new id; either way the existing mapper owns recovery.
if (activeTurn != null) return
if (liveSessionId != null && storedSessionId == storedId) return
if (
liveSessionId != null &&
storedSessionId == storedId &&
liveSessionProfile == requestedProfile
) return
val resumed = rpc(
"session.resume",
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
currentSessionProfile()?.let { put("profile", it) }
requestedProfile?.let { put("profile", it) }
},
)
val result = resumed.getOrNull()
val live = result?.stringField("session_id")
if (live != null) {
if (
live != null &&
activeTurn == null &&
prewarmRequestGeneration.get() == requestGeneration
) {
liveSessionId = live
storedSessionId = storedId
liveSessionProfile = requestedProfile
updateCancelledDrainLiveSession(storedId, live)
// Paint the session's real model/provider/effort/etc NOW from the
// resume result's embedded `info` (same shape session.info carries),
// so a reopened session shows its ACTUAL model immediately instead of
@@ -1055,13 +1360,84 @@ class GatewayChatClient(
}
}
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
private suspend fun ensureLiveProcessSession(): Result<String> {
val requestedProfile = currentSessionProfile()
liveSessionId?.takeIf { liveSessionProfile == requestedProfile }
?.let { return Result.success(it) }
val rememberedStoredId = storedSessionId
?: return Result.failure(GatewayRpcException("no live session"))
val requestGeneration = prewarmRequestGeneration.incrementAndGet()
return try {
connectMutex.withLock {
ensureConnected()
if (liveSessionId == null || liveSessionProfile != requestedProfile) {
resumeForPrewarm(rememberedStoredId, requestedProfile, requestGeneration)
}
}
val resumedLiveId = liveSessionId
if (
resumedLiveId != null &&
storedSessionId == rememberedStoredId &&
liveSessionProfile == requestedProfile
) {
Result.success(resumedLiveId)
} else {
Result.failure(GatewayRpcException("could not resume live session"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseGatewayProcess(element: kotlinx.serialization.json.JsonElement): GatewayProcess? {
val process = element as? JsonObject ?: return null
val id = process.stringField("session_id")?.takeIf { it.isNotBlank() } ?: return null
return GatewayProcess(
id = id,
command = process.stringField("command").orEmpty(),
cwd = process.stringField("cwd"),
pid = (process["pid"] as? JsonPrimitive)?.longOrNull,
startedAt = process.stringField("started_at"),
uptimeSeconds = (process["uptime_seconds"] as? JsonPrimitive)?.longOrNull ?: 0L,
status = process.stringField("status") ?: "unknown",
outputPreview = process.stringField("output_preview")?.takeIf { it.isNotEmpty() },
outputTail = process.stringField("output_tail")?.takeIf { it.isNotEmpty() },
exitCode = (process["exit_code"] as? JsonPrimitive)?.intOrNull,
detached = (process["detached"] as? JsonPrimitive)?.booleanOrNull ?: false,
notifyOnComplete = (process["notify_on_complete"] as? JsonPrimitive)?.booleanOrNull ?: false,
sessionScoped = (process["session_scoped"] as? JsonPrimitive)?.booleanOrNull ?: false,
watchPatterns = (process["watch_patterns"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
watchHit = (process["watch_hit"] as? JsonPrimitive)?.booleanOrNull ?: false,
)
}
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
if (error.isMethodNotFound()) {
_processCapability.value = GatewayProcessCapability.Unsupported
}
}
private fun processFeatureUnsupported(): GatewayRpcException =
GatewayRpcException("background process RPCs are not supported by this gateway", JSONRPC_METHOD_NOT_FOUND)
/** Must hold [connectMutex]. Resolves [liveSessionId] for the requested stored id. */
private suspend fun ensureSession(
requestedStoredId: String?,
newSessionTitle: String?,
turn: GatewayTurn,
) {
if (liveSessionId != null && storedSessionId == requestedStoredId && requestedStoredId != null) {
val requestedProfile = currentSessionProfile()
if (requestedStoredId != null && requestedStoredId != storedSessionId) {
cancelledTurnDrain = null
}
if (
liveSessionId != null &&
storedSessionId == requestedStoredId &&
requestedStoredId != null &&
liveSessionProfile == requestedProfile
) {
return
}
@@ -1071,7 +1447,7 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", requestedStoredId)
put("cols", DEFAULT_COLS)
currentSessionProfile()?.let { put("profile", it) }
requestedProfile?.let { put("profile", it) }
},
)
val result = resumed.getOrNull()
@@ -1079,6 +1455,8 @@ class GatewayChatClient(
if (live != null) {
liveSessionId = live
storedSessionId = requestedStoredId
liveSessionProfile = requestedProfile
updateCancelledDrainLiveSession(requestedStoredId, live)
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
return
}
@@ -1094,7 +1472,7 @@ class GatewayChatClient(
buildJsonObject {
put("cols", DEFAULT_COLS)
if (!newSessionTitle.isNullOrBlank()) put("title", newSessionTitle)
currentSessionProfile()?.let { put("profile", it) }
requestedProfile?.let { put("profile", it) }
// Bind the in-chat overrides to the new session as its
// per-session overrides. Upstream tui_gateway session.create
// reads `model`/`provider` (→ model_override), `reasoning_effort`
@@ -1121,6 +1499,8 @@ class GatewayChatClient(
val stored = created.stringField("stored_session_id") ?: live
liveSessionId = live
storedSessionId = stored
liveSessionProfile = requestedProfile
if (cancelledTurnDrain?.storedSessionId != stored) cancelledTurnDrain = null
turn.callbacks.onSessionId(stored)
}
@@ -1201,7 +1581,7 @@ class GatewayChatClient(
// delta types log length only, everything else logs a payload
// excerpt so on-device diagnosis doesn't read absences.
when (type) {
"message.delta", "reasoning.delta", "thinking.delta" ->
"message.delta", "reasoning.delta", "thinking.delta", "agent.terminal.output" ->
Log.d(TAG, "GW ← $type (${payload?.toString()?.length ?: 0} chars)")
else ->
Log.d(TAG, "GW ← $type | ${payload?.toString()?.take(300) ?: "{}"}")
@@ -1226,18 +1606,110 @@ class GatewayChatClient(
payload?.let { applySessionInfo(it) }
}
val turn = activeTurn ?: return
// Foreign-session events (another client's chat on the same gateway) are not ours.
if (eventSessionId != null && liveSessionId != null && eventSessionId != liveSessionId) {
return
}
dispatchProcessEvent(type, payload, eventSessionId)
if (consumeCancelledTurnEvent(type, eventSessionId)) return
var turn = activeTurn
if (turn == null && type == "message.start") {
// Unsolicited turns are accepted only with an explicit exact live-
// session match. This gateway stream is process-wide; treating a
// missing id as ours would leak another desktop tab's response.
val liveId = liveSessionId
val storedId = storedSessionId
if (!eventSessionId.isNullOrBlank() &&
eventSessionId == liveId &&
!storedId.isNullOrBlank()
) {
val registration = unsolicitedTurnProvider?.invoke(storedId)
if (registration != null) {
val inboundTurn = GatewayTurn(
callbacks = dispatchOn(registration.callbacks),
dedupeAdjacentMessageStarts = true,
)
if (bindInboundTurn(registration, inboundTurn)) {
turn = inboundTurn
activeTurn = inboundTurn
inboundTurn.armWatchdog()
Log.i(TAG, "Accepted unsolicited gateway turn for session=$storedId")
} else {
Log.i(TAG, "Deferred unsolicited gateway turn for session=$storedId")
}
}
}
}
if (turn == null) {
// A foreground SSE/realtime turn may already own Chat, or the
// socket may have reconnected after message.start. The exact
// terminal event is still authoritative and persisted upstream;
// recover it through history once the UI becomes idle.
val storedId = storedSessionId
if (type == "message.complete" &&
!eventSessionId.isNullOrBlank() &&
eventSessionId == liveSessionId &&
!storedId.isNullOrBlank()
) {
val expectedText = payload?.stringField("text")
callbackDispatcher {
unmatchedTurnCompleteListener?.invoke(storedId, expectedText)
}
}
return
}
turn.onEvent(type, payload)
if (turn.ended) {
activeTurn = null
if (activeTurn === turn) activeTurn = null
if (AppForegroundTracker.isForeground.value.not()) scheduleBackgroundClose()
}
}
/**
* Deliver session-scoped process events before the active-turn gate. The
* gateway socket is process-wide, so an exact non-blank live id match is
* required; missing/foreign ids must never leak another window's process.
*/
private fun dispatchProcessEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
val liveId = liveSessionId ?: return
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
val event = when (type) {
"tool.complete" -> when (payload?.stringField("name")) {
"terminal", "process" -> GatewayProcessEvent.Invalidated(
GatewayProcessEvent.Trigger.TOOL_COMPLETE,
)
else -> null
}
"status.update" -> if (payload?.stringField("kind") == "process") {
GatewayProcessEvent.Invalidated(GatewayProcessEvent.Trigger.STATUS_UPDATE)
} else {
null
}
// Some upstream tool paths complete a background terminal launch
// without emitting tool.start/tool.complete to this UI session.
// The assistant still closes the initiating turn normally, so use
// that exact-session boundary as a cheap authoritative discovery
// fallback. process.list then starts the running-only poller.
"message.complete" -> GatewayProcessEvent.Invalidated(
GatewayProcessEvent.Trigger.MESSAGE_COMPLETE,
)
"agent.terminal.output" -> {
val processId = payload?.stringField("process_id")
val chunk = payload?.stringField("chunk")
if (!processId.isNullOrBlank() && chunk != null) {
GatewayProcessEvent.Output(processId, chunk)
} else {
null
}
}
"terminal.close" -> payload?.stringField("process_id")
?.takeIf { it.isNotBlank() }
?.let { GatewayProcessEvent.TerminalClosed(it) }
else -> null
} ?: return
callbackDispatcher { processEventListener?.invoke(event) }
}
private fun onSocketDown(reason: String) {
Log.i(TAG, "Gateway socket down ($reason)")
// Capture the in-flight session id BEFORE clearing it — the mid-turn
@@ -1249,6 +1721,7 @@ class GatewayChatClient(
liveSessionId = null
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
pendingRpcs.values.forEach {
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
@@ -1256,7 +1729,7 @@ class GatewayChatClient(
pendingRpcs.clear()
val turn = activeTurn ?: return
if (turn.ended) {
activeTurn = null
if (activeTurn === turn) activeTurn = null
return
}
// A rejoin already owns recovery — a connect attempt failing inside
@@ -1276,7 +1749,7 @@ class GatewayChatClient(
}
}
} else {
activeTurn = null
if (activeTurn === turn) activeTurn = null
turn.failFromTransport("Connection to the gateway was lost")
}
}
@@ -1288,13 +1761,10 @@ class GatewayChatClient(
* Recover an in-flight turn after a mid-turn socket loss by reconnecting
* the SOCKET ONLY and keeping [preservedLiveId] as the live session id.
*
* Why not `session.resume`: upstream resume mints a brand-new session id
* and rebuilds a fresh agent from persisted DB history — it does NOT
* reattach to the running turn's thread, which keeps emitting on the OLD
* id over the shared gateway stream. Resuming would point our event filter
* at the wrong id and orphan the turn (the server finishes it and the
* answer is dropped — confirmed on-device). A bare reconnect lets the tail
* — including the final `message.complete` — keep matching this turn.
* A bare reconnect lets the tail — including the final
* `message.complete` — keep matching without another RPC. Current upstream
* can rebind live sessions too, but older builds cannot, so this remains the
* lowest-common-denominator same-client recovery path.
*
* Retries with backoff for up to [midTurnRejoinWindowMs] so a multi-second
* radio blip doesn't abandon the turn. Events emitted while the socket was
@@ -1354,6 +1824,7 @@ class GatewayChatClient(
liveSessionId = null
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
}
@@ -1499,8 +1970,9 @@ class GatewayChatClient(
private inner class GatewayTurn(
val callbacks: GatewayTurnCallbacks,
dedupeAdjacentMessageStarts: Boolean = false,
) : ActiveTurnHandle {
private val mapper = GatewayEventMapper(callbacks)
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
/** t0 = construction ≈ sendTurn entry (the moment the user sent). */
val tracer = TurnLatencyTracer("gateway")
@@ -1564,6 +2036,11 @@ class GatewayChatClient(
watchdog = null
}
/** Recovery attaches after the original prompt.submit, so it is already started. */
fun markRecoveredStarted() {
started = true
}
/** Transport-level failure after submit — surface as a stream error once. */
fun failFromTransport(message: String) {
disarmWatchdog()
@@ -1578,10 +2055,21 @@ class GatewayChatClient(
cancelled = true
disarmWatchdog()
tracer.done("cancelled")
if (activeTurn === this) activeTurn = null
if (activeTurn === this) {
armCancelledTurnDrain(terminalRequired = started)
activeTurn = null
}
interruptServerSide()
}
override fun detach() {
if (ended) return
cancelled = true
disarmWatchdog()
tracer.done("detached")
if (activeTurn === this) activeTurn = null
}
private fun interruptServerSide() {
val sid = liveSessionId ?: return
scope.launch {
@@ -1592,9 +2080,104 @@ class GatewayChatClient(
}
}
private fun armCancelledTurnDrain(terminalRequired: Boolean) {
val storedId = storedSessionId ?: return
val liveId = liveSessionId ?: return
cancelledTurnDrain = CancelledTurnDrain(
storedSessionId = storedId,
liveSessionId = liveId,
submitWaitUntilMs = System.currentTimeMillis() + CANCELLED_TURN_SUBMIT_WAIT_MS,
terminalRequired = terminalRequired,
)
}
private fun updateCancelledDrainLiveSession(storedId: String, liveId: String) {
val drain = cancelledTurnDrain ?: return
if (drain.storedSessionId == storedId) {
cancelledTurnDrain = drain.copy(liveSessionId = liveId)
}
}
/** Ignore the canceled turn's exact-session tail through its terminal event. */
private fun consumeCancelledTurnEvent(type: String, eventSessionId: String?): Boolean {
val drain = cancelledTurnDrain ?: return false
if (!drain.terminalRequired && System.currentTimeMillis() >= drain.submitWaitUntilMs) {
if (cancelledTurnDrain === drain) cancelledTurnDrain = null
return false
}
if (eventSessionId != drain.liveSessionId) return false
if (type == "message.complete" || type == "error") {
if (cancelledTurnDrain === drain) cancelledTurnDrain = null
}
Log.d(TAG, "Ignored canceled gateway turn event: $type")
return true
}
/**
* Preserve same-session event ordering after Stop. The interrupt terminal
* normally drains immediately. The wait is bounded so a slow cooperative
* interrupt does not indefinitely delay the next submit, but a started
* turn's tombstone remains until its terminal event and continues draining
* the old tail in front of the server-serialized next prompt.
*/
private suspend fun awaitCancelledTurnDrain(
turn: GatewayTurn,
targetStoredSessionId: String?,
): Boolean {
while (!turn.cancelled) {
val drain = cancelledTurnDrain ?: return true
if (drain.storedSessionId != targetStoredSessionId) return true
if (System.currentTimeMillis() >= drain.submitWaitUntilMs) {
if (!drain.terminalRequired && cancelledTurnDrain === drain) {
cancelledTurnDrain = null
}
return true
}
delay(25L)
}
return false
}
/**
* Serialize inbound ownership onto the callback/main dispatcher before any
* mapper callback is posted. A local SSE turn can otherwise start between
* the socket event and UI binding and lose its cancellable handle.
*/
private fun bindInboundTurn(
registration: GatewayInboundTurnRegistration,
turn: GatewayTurn,
): Boolean {
val pending = AtomicBoolean(true)
val completed = CountDownLatch(1)
var accepted = false
try {
callbackDispatcher {
try {
if (pending.compareAndSet(true, false)) {
accepted = registration.onHandle(turn)
}
} finally {
completed.countDown()
}
}
} catch (e: Exception) {
Log.w(TAG, "Inbound turn dispatcher rejected callback", e)
return false
}
if (!completed.await(INBOUND_BIND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
// If the callback has not started, expire it so a late main-thread
// delivery cannot bind a handle the client already discarded. If
// it has started, wait for that short ownership check to finish.
if (pending.compareAndSet(true, false)) return false
completed.await()
}
return accepted
}
/** Wrap callbacks so every invocation lands on the callback dispatcher (main thread). */
private fun dispatchOn(callbacks: GatewayTurnCallbacks) = GatewayTurnCallbacks(
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
onStart = { callbackDispatcher { callbacks.onStart() } },
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
@@ -1664,3 +2247,6 @@ private fun Throwable?.isMethodNotFound(): Boolean {
private fun JsonObject.stringField(key: String): String? =
(get(key) as? JsonPrimitive)?.contentOrNull
private fun JsonObject.booleanField(key: String): Boolean? =
(get(key) as? JsonPrimitive)?.booleanOrNull
@@ -21,13 +21,17 @@ import kotlinx.serialization.json.intOrNull
* why dispatch is a manual `when (type)` over [JsonObject] rather than a
* sealed polymorphic hierarchy (which throws on unknown discriminators).
*/
class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
class GatewayEventMapper(
private val callbacks: GatewayTurnCallbacks,
private val dedupeAdjacentMessageStarts: Boolean = false,
) {
/** True once `message.complete` or `error` has been seen — the turn is over. */
var turnEnded: Boolean = false
private set
private var sawMessageStart = false
private var previousEventType: String? = null
private var sawTextDelta = false
private var sawThinkingDelta = false
private var syntheticToolCounter = 0
@@ -77,11 +81,18 @@ class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
}
"message.start" -> {
// The upstream background-completion poller currently emits
// message.start immediately before _run_prompt_submit(), which
// emits the same start again. Treat an adjacent pair as one
// boundary; a later start after any other event still closes
// the previous assistant message as before.
if (dedupeAdjacentMessageStarts && previousEventType == "message.start") return
// Gateway has no server-side message id (placeholder UUID
// stays). A second start inside one turn means a new
// assistant message began — close out the previous one.
if (sawMessageStart) callbacks.onTurnComplete()
sawMessageStart = true
callbacks.onStart()
}
"tool.generating" -> {
@@ -228,6 +239,7 @@ class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
// alike: ignore.
else -> Unit
}
previousEventType = type
}
private fun syntheticToolId(name: String): String {
@@ -150,8 +150,8 @@ class GatewayKeepAliveService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes connection active")
.setContentText("Keeping your connection to Hermes open in the background.")
.setContentTitle(getString(R.string.gateway_keepalive_title))
.setContentText(getString(R.string.gateway_keepalive_body))
.setContentIntent(tapPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
@@ -81,8 +81,33 @@ fun resolveStreamingEndpointPreference(
*/
fun interface ActiveTurnHandle {
fun cancel()
/**
* Release this client's callbacks without interrupting server-side work.
* Gateway turns override this for process/UI teardown; transports that
* cannot be reattached retain their existing cancel behavior.
*/
fun detach() = cancel()
}
/** Partial text checkpoint returned by current upstream Hermes on live resume. */
data class GatewayInflightTurn(
val user: String,
val assistant: String,
val streaming: Boolean,
)
/** Result of reattaching Android to an existing durable Gateway session. */
data class GatewaySessionRecovery(
val storedSessionId: String,
val liveSessionId: String,
val running: Boolean,
val status: String?,
val inflight: GatewayInflightTurn?,
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
val handle: ActiveTurnHandle?,
)
/**
* One server-side interactive ask. The agent thread upstream is BLOCKED
* until the matching respond RPC arrives, the ask times out (resolves to ""
@@ -135,6 +160,67 @@ data class GatewaySubagentEvent(
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
}
/**
* One session-owned background process returned by the upstream gateway's
* `process.list` RPC. The registry calls its process id `session_id`; Android
* exposes it as [id] so it cannot be confused with either the stored chat id or
* the gateway's live, per-connection session id.
*
* [outputPreview] is the registry's short preview, while [outputTail] is the
* gateway's larger (currently 4,000-character) snapshot used to recover output
* missed while the WebSocket was unavailable. Unknown/new fields are ignored
* by the parser so this remains compatible with older and newer gateways.
*/
data class GatewayProcess(
val id: String,
val command: String,
val cwd: String? = null,
val pid: Long? = null,
val startedAt: String? = null,
val uptimeSeconds: Long = 0L,
val status: String,
val outputPreview: String? = null,
val outputTail: String? = null,
val exitCode: Int? = null,
val detached: Boolean = false,
val notifyOnComplete: Boolean = false,
val sessionScoped: Boolean = false,
val watchPatterns: List<String> = emptyList(),
val watchHit: Boolean = false,
) {
val isRunning: Boolean get() = status.equals("running", ignoreCase = true)
}
/** Whether this gateway socket supports the session-scoped process RPCs. */
enum class GatewayProcessCapability {
/** Not probed on this socket yet (or no socket is currently connected). */
Unknown,
/** A `process.list` / `process.kill` call succeeded. */
Supported,
/** The gateway returned JSON-RPC method-not-found for the process surface. */
Unsupported,
}
/**
* Connection-level background-process events. These are deliberately separate
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
* while no app-initiated turn is active.
*/
sealed interface GatewayProcessEvent {
enum class Trigger { TOOL_COMPLETE, STATUS_UPDATE, MESSAGE_COMPLETE }
/** The process snapshot may have changed and should be refreshed. */
data class Invalidated(val trigger: Trigger) : GatewayProcessEvent
/** Live output from `agent.terminal.output`. */
data class Output(val processId: String, val chunk: String) : GatewayProcessEvent
/** The agent requested that its read-only terminal view be closed. */
data class TerminalClosed(val processId: String) : GatewayProcessEvent
}
/**
* One provider from the gateway `model.options` RPC — the curated, authenticated
* provider/model list the upstream desktop + TUI model picker uses (NOT the
@@ -208,6 +294,8 @@ data class GatewayReasoningSettings(
class GatewayTurnCallbacks(
/** Stored (DB) session id — fired on session create/rotate so the drawer + persistence stay correct. */
val onSessionId: (String) -> Unit,
/** A gateway `message.start` opened an assistant response for this turn. */
val onStart: () -> Unit,
val onTextDelta: (String) -> Unit,
val onThinkingDelta: (String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
@@ -238,3 +326,18 @@ class GatewayTurnCallbacks(
*/
val onStatusUpdate: (kind: String?, text: String) -> Unit = { _, _ -> },
)
/**
* UI registration for one server-initiated gateway turn.
*
* Background-process completion is converted upstream into a normal assistant
* turn on the originating session. It has no matching client [GatewayChatClient.sendTurn]
* call, so the client asks the active conversation for callbacks when the first
* `message.start` arrives. [onHandle] binds the resulting cancellable turn into
* the same Stop/steer lifecycle as a locally submitted turn.
*/
class GatewayInboundTurnRegistration(
val callbacks: GatewayTurnCallbacks,
/** Main-thread admission. False leaves the server turn unbound for history recovery. */
val onHandle: (ActiveTurnHandle) -> Boolean,
)
@@ -235,7 +235,7 @@ object NotificationTriggerPromptNotifier {
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val tapPending = PendingIntent.getActivity(context, notificationId(entry), tapIntent, pendingFlags)
val title = "Ask Hermes about this?"
val title = context.getString(R.string.notification_trigger_prompt_title)
val source = entry.title?.takeIf { it.isNotBlank() } ?: entry.packageName
val body = entry.text?.takeIf { it.isNotBlank() }
?: "Rule matched: ${rule.summary()}"
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.lifecycle.createSavedStateHandle
@@ -67,6 +68,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.CrashReportGate
import com.hermesandroid.relay.ui.components.DemoModeBanner
import com.hermesandroid.relay.ui.components.DemoUnavailableContent
@@ -1066,11 +1068,19 @@ fun RelayApp() {
bridgePrimaryReturnLabel = null
}
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let { "Return to $it" }
val bridgeReturnLabelResId = when (bridgePrimaryReturnLabel) {
"Chat" -> R.string.bridge_return_chat_label
"Manage" -> R.string.bridge_return_manage_label
else -> R.string.bridge_return_default_label
}
val bridgeReturnDisplayLabel = stringResource(bridgeReturnLabelResId)
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let {
stringResource(R.string.bridge_return_title_format, bridgeReturnDisplayLabel)
}
val bridgeReturnSubtitle = when (bridgePrimaryReturnLabel) {
"Chat" -> "Back to conversation"
"Manage" -> "Back to management"
else -> "Back to previous tab"
"Chat" -> stringResource(R.string.bridge_return_chat_subtitle)
"Manage" -> stringResource(R.string.bridge_return_manage_subtitle)
else -> stringResource(R.string.bridge_return_default_subtitle)
}
val bridgeReturnAction: (() -> Unit)? = bridgePrimaryReturnRoute?.let { route ->
{
@@ -1343,6 +1353,7 @@ fun RelayApp() {
connectionId = connection.id,
dashboardUrl = effectiveDashboardUrl,
cacheDir = hydrateContext.cacheDir,
context = hydrateContext,
)
}
@@ -1350,13 +1361,17 @@ fun RelayApp() {
// so voice/chat/settings screens can call showHumanError from their
// error-collector LaunchedEffects without threading state downwards.
val snackbarHostState = remember { SnackbarHostState() }
val profilesUpdatedLabel = stringResource(R.string.relay_app_profiles_updated)
val reconnectingRelayLabel = stringResource(R.string.relay_app_reconnecting)
val renameFailedLabel = stringResource(R.string.relay_app_rename_failed)
val revokeOnlyActiveLabel = stringResource(R.string.relay_app_revoke_only_active)
// Relay-pushed `profiles.updated` announcements. AuthManager
// filters out idempotent pushes (same names + same count), so
// this only fires when the profile list actually changed.
LaunchedEffect(connectionViewModel) {
connectionViewModel.profilesUpdatedEvents.collect {
UiMessageBus.success("Profiles updated")
UiMessageBus.success(profilesUpdatedLabel)
}
}
@@ -1576,7 +1591,7 @@ fun RelayApp() {
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
val routeLabel = activeEndpoint?.displayLabel()
?: activeConnection?.label
?: "no route"
?: stringResource(R.string.status_no_route)
val transportStatus = resolveChatTransportStatus(
streamingEndpoint = streamingEndpoint,
gatewayAvailability = gatewayAvailability,
@@ -1587,7 +1602,8 @@ fun RelayApp() {
} else {
routeLabel
}
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() } ?: "default"
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.status_profile_default)
val displayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
@@ -1595,11 +1611,12 @@ fun RelayApp() {
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(displayProfile?.model)
?: AgentDisplay.displayModelName(serverModelName)
?: "model pending"
?: stringResource(R.string.status_model_pending)
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
"safety: ${if (unattendedEnabled) "unattended" else "on"}"
if (unattendedEnabled) stringResource(R.string.status_safety_unattended)
else stringResource(R.string.status_safety_on)
} else {
"profile: $profileLabel"
stringResource(R.string.status_profile_format, profileLabel)
}
val openConnections = {
navController.navigate(Screen.ConnectionsSettings.route) {
@@ -1714,6 +1731,8 @@ fun RelayApp() {
val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val screenChatLabel = stringResource(R.string.screen_chat_label)
ChatScreen(
chatViewModel = chatViewModel,
connectionViewModel = connectionViewModel,
@@ -1754,7 +1773,7 @@ fun RelayApp() {
onNavigateToBridge = {
rememberBridgeReturn(
route = Screen.Chat.route(openAgentSheet = false),
label = "Chat",
label = screenChatLabel,
)
navController.navigate(Screen.Bridge.route) {
popUpTo(navController.graph.findStartDestination().id) {
@@ -1792,10 +1811,11 @@ fun RelayApp() {
// so show a friendly demo empty state instead of
// attempting a sign-in / fetch.
DemoUnavailableContent(
feature = "Manage",
feature = stringResource(R.string.demo_feature_manage),
onConnect = exitDemoToConnect,
)
} else {
val screenManageLabel = stringResource(R.string.screen_manage_label)
DashboardManagementScreen(
connectionViewModel = connectionViewModel,
onNavigateToConnections = {
@@ -1811,7 +1831,7 @@ fun RelayApp() {
onNavigateToBridge = {
rememberBridgeReturn(
route = Screen.Manage.route,
label = "Manage",
label = screenManageLabel,
)
navController.navigate(Screen.Bridge.route) {
popUpTo(navController.graph.findStartDestination().id) {
@@ -1843,8 +1863,8 @@ fun RelayApp() {
)
} else {
PowerFeatureGateScreen(
title = "Terminal",
summary = "Open a server shell through your paired relay session.",
title = stringResource(R.string.power_gate_terminal_title),
summary = stringResource(R.string.power_gate_terminal_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -1856,8 +1876,8 @@ fun RelayApp() {
composable(Screen.Bridge.route) {
if (coldStartAuthState !is AuthState.Paired) {
PowerFeatureGateScreen(
title = "Bridge",
summary = "Let Hermes send approved bridge commands to this phone.",
title = stringResource(R.string.power_gate_bridge_title),
summary = stringResource(R.string.power_gate_bridge_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -1870,7 +1890,9 @@ fun RelayApp() {
connectionViewModel = connectionViewModel,
returnTitle = bridgeReturnTitle,
returnSubtitle = bridgeReturnSubtitle,
returnLabel = bridgePrimaryReturnLabel ?: "Back",
returnLabel = bridgePrimaryReturnLabel?.let {
stringResource(bridgeReturnLabelResId)
} ?: stringResource(R.string.bridge_return_default_label),
onReturn = bridgeReturnAction,
onNavigateToBridgeSafety = {
navController.navigate(Screen.BridgeSafetySettings.route)
@@ -1904,7 +1926,9 @@ fun RelayApp() {
connectionViewModel = connectionViewModel,
returnTitle = bridgeReturnTitle,
returnSubtitle = bridgeReturnSubtitle,
returnLabel = bridgePrimaryReturnLabel ?: "Back",
returnLabel = bridgePrimaryReturnLabel?.let {
stringResource(bridgeReturnLabelResId)
} ?: stringResource(R.string.bridge_return_default_label),
onReturn = bridgeReturnAction,
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
@@ -2034,7 +2058,7 @@ fun RelayApp() {
// Voice runs through the live server (transcribe /
// synthesize) — show the demo empty state offline.
DemoUnavailableContent(
feature = "Voice",
feature = stringResource(R.string.screen_voice_label),
onConnect = exitDemoToConnect,
)
} else {
@@ -2157,8 +2181,8 @@ fun RelayApp() {
)
} else {
PowerFeatureGateScreen(
title = "Relay sessions",
summary = "Review and revoke devices paired with this relay.",
title = stringResource(R.string.screen_relay_sessions_label),
summary = stringResource(R.string.power_gate_relay_sessions_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -2233,14 +2257,14 @@ fun RelayApp() {
onBack = { navController.popBackStack() },
onReconnect = {
connectionViewModel.connectRelay()
UiMessageBus.status("Reconnecting to relay…")
UiMessageBus.status(reconnectingRelayLabel)
},
onRename = { id, newLabel ->
connectionSwitchScope.launch {
connectionViewModel.renameConnection(id, newLabel)
.onFailure { err ->
snackbarHostState.showSnackbar(
err.message ?: "Rename failed",
err.message ?: renameFailedLabel,
)
}
}
@@ -2255,9 +2279,7 @@ fun RelayApp() {
connectionSwitchScope.launch {
val result = connectionViewModel.revokeConnection(id)
if (result.isFailure) {
snackbarHostState.showSnackbar(
"Only the active connection can be revoked right now",
)
snackbarHostState.showSnackbar(revokeOnlyActiveLabel)
}
}
},
@@ -2429,8 +2451,8 @@ fun RelayApp() {
?: Screen.ProfileInspector.SECTION_CONFIG
if (coldStartAuthState !is AuthState.Paired) {
PowerFeatureGateScreen(
title = "Profile Inspector",
summary = "Inspect relay-backed profile config, SOUL, memory files, and skills.",
title = stringResource(R.string.screen_profile_inspector_label),
summary = stringResource(R.string.power_gate_profile_inspector_summary),
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
@@ -2559,13 +2581,13 @@ fun RelayApp() {
.padding(bottom = 120.dp)
) {
Text(
text = "Hermes-Relay",
text = stringResource(R.string.app_title),
style = MaterialTheme.typography.headlineMedium,
color = RelayRefresh.Paper.copy(alpha = 0.92f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "agent interface",
text = stringResource(R.string.agent_interface),
style = MaterialTheme.typography.bodyMedium,
color = RelayRefresh.Muted.copy(alpha = 0.72f),
letterSpacing = 2.sp
@@ -55,12 +55,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
@@ -130,29 +132,29 @@ fun ActiveCardStandardStatusSection(
)
ConnectionStatusRow(
label = "API Server",
label = stringResource(R.string.active_section_api_server),
isConnected = apiReachable,
isProbing = apiHealth == ConnectionViewModel.HealthStatus.Probing,
statusText = when {
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking…"
apiReachable -> "Reachable"
else -> "Unreachable"
apiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.active_section_checking)
apiReachable -> stringResource(R.string.active_section_reachable)
else -> stringResource(R.string.active_section_unreachable)
},
onClick = onOpenApiInfo,
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Dashboard",
label = stringResource(R.string.active_section_dashboard),
isConnected = dashboardStatus?.reachable == true && !dashboardSignInRequired,
statusText = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Not configured"
dashboardStatus == null -> "Not checked"
!dashboardStatus.reachable -> "Unreachable"
dashboardSignInRequired -> "Sign-in required"
dashboardStatus.authenticated == true -> "Signed in"
dashboardStatus.authRequired == false -> "Available"
else -> "Available"
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> stringResource(R.string.active_section_not_configured)
dashboardStatus == null -> stringResource(R.string.active_section_not_checked)
!dashboardStatus.reachable -> stringResource(R.string.active_section_unreachable)
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
dashboardStatus.authenticated == true -> stringResource(R.string.active_section_signed_in)
dashboardStatus.authRequired == false -> stringResource(R.string.active_section_available)
else -> stringResource(R.string.active_section_available)
},
onClick = onOpenDashboard,
modifier = Modifier.fillMaxWidth(),
@@ -165,13 +167,13 @@ fun ActiveCardStandardStatusSection(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Dashboard controls need a sign-in for this route.",
text = stringResource(R.string.active_section_dashboard_sign_in_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onOpenDashboard) {
Text("Sign in")
Text(stringResource(R.string.active_section_sign_in))
}
}
}
@@ -194,18 +196,22 @@ fun ActiveCardRelayStatusSection(
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
// Pre-resolve strings for Toast (non-composable context)
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
val connectedLabel = stringResource(R.string.conn_info_connected)
// ADR 24: relayRowState carries both the phase and the active endpoint
// role. statusText appends " · <Role>" when the resolver has picked one.
ConnectionStatusRow(
label = "Relay",
label = stringResource(R.string.active_section_relay),
state = relayRowState.asBadgeState(),
statusText = relayRowState.statusText(connectedLabel = "Connected"),
statusText = relayRowState.statusText(connectedLabel = connectedLabel),
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.connectRelay()
Toast.makeText(
context,
"Reconnecting to relay…",
reconnectingRelayToast,
Toast.LENGTH_SHORT,
).show()
} else {
@@ -215,15 +221,21 @@ fun ActiveCardRelayStatusSection(
modifier = Modifier.fillMaxWidth(),
)
// Pre-resolve strings for Session status
val pairedLabel = stringResource(R.string.conn_info_paired)
val pairingLabel = stringResource(R.string.conn_info_pairing)
val unpairedLabel = stringResource(R.string.conn_info_unpaired)
val failedReasonLabel = stringResource(R.string.active_section_failed_reason)
ConnectionStatusRow(
label = "Session",
label = stringResource(R.string.active_section_session),
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
is AuthState.Paired -> pairedLabel
is AuthState.Pairing -> pairingLabel
is AuthState.Unpaired -> unpairedLabel
is AuthState.Failed -> failedReasonLabel.format((authState as AuthState.Failed).reason)
},
onClick = onOpenSessionInfo,
modifier = Modifier.fillMaxWidth(),
@@ -261,38 +273,39 @@ fun ActiveCardFeaturesSection(
activeConnection?.routeCandidates.orEmpty().any { it.hasSecureProxy() }
val apiValue = when {
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
apiReachable -> "Ready"
activeConnection?.apiServerUrl.isNullOrBlank() -> "Missing"
else -> "Offline"
apiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.active_section_checking)
apiReachable -> stringResource(R.string.active_section_ready)
activeConnection?.apiServerUrl.isNullOrBlank() -> stringResource(R.string.active_section_missing)
else -> stringResource(R.string.active_section_offline)
}
val apiTone = when (apiValue) {
"Ready" -> CapabilityTone.Good
"Offline", "Missing" -> CapabilityTone.Warning
else -> CapabilityTone.Neutral
val apiTone = when {
apiReachable -> CapabilityTone.Good
apiHealth == ConnectionViewModel.HealthStatus.Probing -> CapabilityTone.Neutral
else -> CapabilityTone.Warning
}
val dashboardValue = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Missing"
dashboardStatus == null -> "Unchecked"
!dashboardStatus.reachable -> "Offline"
dashboardSignInRequired -> "Sign in"
dashboardStatus.authenticated == true -> "Signed in"
else -> "Available"
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> stringResource(R.string.active_section_missing)
dashboardStatus == null -> stringResource(R.string.active_section_unchecked)
!dashboardStatus.reachable -> stringResource(R.string.active_section_offline)
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in)
dashboardStatus.authenticated == true -> stringResource(R.string.active_section_signed_in)
else -> stringResource(R.string.active_section_available)
}
val dashboardTone = when (dashboardValue) {
"Signed in", "Available" -> CapabilityTone.Good
"Sign in" -> CapabilityTone.Info
"Offline", "Missing" -> CapabilityTone.Warning
val dashboardTone = when {
dashboardStatus?.authenticated == true -> CapabilityTone.Good
dashboardStatus?.reachable == true && !dashboardSignInRequired -> CapabilityTone.Good
dashboardSignInRequired -> CapabilityTone.Info
activeConnection?.resolvedDashboardUrl.isNullOrBlank() || dashboardStatus?.reachable != true -> CapabilityTone.Warning
else -> CapabilityTone.Neutral
}
val voiceValue = when (standardVoiceAvailability) {
StandardVoiceAvailability.Ready -> "Ready"
StandardVoiceAvailability.SignInRequired -> "Sign in"
StandardVoiceAvailability.Unsupported -> "Unsupported"
StandardVoiceAvailability.Unreachable -> "Offline"
StandardVoiceAvailability.Unknown -> "Checking"
StandardVoiceAvailability.Ready -> stringResource(R.string.active_section_ready)
StandardVoiceAvailability.SignInRequired -> stringResource(R.string.active_section_sign_in)
StandardVoiceAvailability.Unsupported -> stringResource(R.string.active_section_unsupported)
StandardVoiceAvailability.Unreachable -> stringResource(R.string.active_section_offline)
StandardVoiceAvailability.Unknown -> stringResource(R.string.active_section_checking)
}
val voiceTone = when (standardVoiceAvailability) {
StandardVoiceAvailability.Ready -> CapabilityTone.Good
@@ -303,11 +316,11 @@ fun ActiveCardFeaturesSection(
}
val relayValue = when {
!relayEnabled -> "Disabled"
!relayConfigured -> "Optional"
relayReady -> "Ready"
relayUiState == RelayUiState.Stale -> "Reconnect"
else -> "Configured"
!relayEnabled -> stringResource(R.string.active_section_disabled)
!relayConfigured -> stringResource(R.string.active_section_optional)
relayReady -> stringResource(R.string.active_section_ready)
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
else -> stringResource(R.string.active_section_configured)
}
val relayTone = when {
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
@@ -317,20 +330,28 @@ fun ActiveCardFeaturesSection(
}
val terminalValue = when {
!relayEnabled -> "Disabled"
authState is AuthState.Paired -> "Ready"
relayConfigured -> "Pair Relay"
else -> "Optional"
!relayEnabled -> stringResource(R.string.active_section_disabled)
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
relayConfigured -> stringResource(R.string.active_section_pair_relay)
else -> stringResource(R.string.active_section_optional)
}
val terminalTone = when (terminalValue) {
"Ready" -> CapabilityTone.Good
"Pair Relay" -> CapabilityTone.Info
val terminalTone = when {
authState is AuthState.Paired -> CapabilityTone.Good
relayConfigured && authState !is AuthState.Paired -> CapabilityTone.Info
else -> CapabilityTone.Neutral
}
val proxyValue = if (secureProxyAdvertised) "Available" else "Not advertised"
val proxyValue = if (secureProxyAdvertised) stringResource(R.string.active_section_available) else stringResource(R.string.active_section_not_advertised)
val proxyTone = if (secureProxyAdvertised) CapabilityTone.Good else CapabilityTone.Neutral
// Pre-resolve labels for CapabilityRow
val hermesApiLabel = stringResource(R.string.active_section_hermes_api)
val dashboardLabel = stringResource(R.string.active_section_dashboard)
val hermesVoiceLabel = stringResource(R.string.active_section_hermes_voice)
val relayToolsLabel = stringResource(R.string.active_section_relay_tools)
val terminalLabel = stringResource(R.string.active_section_terminal)
val secureProxyLabel = stringResource(R.string.active_section_secure_proxy)
// Lighter than the old six-filled-tile grid: one subtle grouped surface
// with a status dot + value per capability, dividers between rows. The
// header/glance pills used to duplicate API/Dashboard/Voice/Relay state;
@@ -342,21 +363,21 @@ fun ActiveCardFeaturesSection(
) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
CapabilityRow(
label = "Hermes API",
label = hermesApiLabel,
value = apiValue,
tone = apiTone,
onClick = onOpenApiInfo,
)
CapabilityDivider()
CapabilityRow(
label = "Dashboard",
label = dashboardLabel,
value = dashboardValue,
tone = dashboardTone,
onClick = onOpenDashboard,
)
CapabilityDivider()
CapabilityRow(
label = "Hermes voice",
label = hermesVoiceLabel,
value = voiceValue,
tone = voiceTone,
onClick = if (standardVoiceAvailability ==
@@ -369,21 +390,21 @@ fun ActiveCardFeaturesSection(
)
CapabilityDivider()
CapabilityRow(
label = "Relay tools",
label = relayToolsLabel,
value = relayValue,
tone = relayTone,
onClick = onOpenRelayInfo,
)
CapabilityDivider()
CapabilityRow(
label = "Terminal",
label = terminalLabel,
value = terminalValue,
tone = terminalTone,
onClick = onOpenSessionInfo,
)
CapabilityDivider()
CapabilityRow(
label = "Secure proxy",
label = secureProxyLabel,
value = proxyValue,
tone = proxyTone,
)
@@ -499,7 +520,7 @@ fun ActiveCardAdvancedSection(
var expanded by rememberSaveable { mutableStateOf(false) }
SettingsExpandableCard(
title = "Advanced",
title = stringResource(R.string.active_section_advanced),
expanded = expanded,
onToggle = { expanded = !expanded },
isDarkTheme = isDarkTheme,
@@ -557,6 +578,41 @@ private fun ManualUrlSubsection(
}
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
// Pre-resolve strings for Toast (non-composable context)
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
val cannotReachApiToast = stringResource(R.string.active_section_cannot_reach_api)
// Pre-resolve other strings
val apiServerUrlLabel = stringResource(R.string.active_section_api_server_url_label)
val apiServerUrlPlaceholder = stringResource(R.string.active_section_api_server_url_placeholder)
val apiKeyOptionalLabel = stringResource(R.string.active_section_api_key_optional)
val apiKeyAlreadySetPlaceholder = stringResource(R.string.active_section_api_key_already_set)
val apiKeyNotConfiguredPlaceholder = stringResource(R.string.active_section_api_key_not_configured)
val apiKeyStoredHint = stringResource(R.string.active_section_api_key_stored_hint)
val apiKeyNeededHint = stringResource(R.string.active_section_api_key_needed_hint)
val hideDesc = stringResource(R.string.active_section_hide)
val showDesc = stringResource(R.string.active_section_show)
val saveAndTestText = stringResource(R.string.active_section_save_and_test)
val relayUrlText = stringResource(R.string.active_section_relay_url)
val autoRelayUrlText = stringResource(R.string.active_section_auto_relay_url)
val manualOverrideText = stringResource(R.string.active_section_manual_override)
val relayOptionalForVoiceText = stringResource(R.string.active_section_relay_optional_for_voice)
val useAutoRelayUrlText = stringResource(R.string.active_section_use_auto_relay_url)
val useCustomRelayUrlText = stringResource(R.string.active_section_use_custom_relay_url)
val relayUrlOverrideLabel = stringResource(R.string.active_section_relay_url_override)
val relayUrlOverridePlaceholder = stringResource(R.string.active_section_relay_url_override_placeholder)
val relayUrlOverrideHint = stringResource(R.string.active_section_relay_url_override_hint)
val voiceReadyViaHermesApiText = stringResource(R.string.active_section_voice_ready_via_hermes_api)
val voiceReadyViaRelayText = stringResource(R.string.active_section_voice_ready_via_relay)
val voiceRouteNeedsReviewText = stringResource(R.string.active_section_voice_route_needs_review)
val testRelayText = stringResource(R.string.active_section_test_relay)
val disconnectText = stringResource(R.string.active_section_disconnect)
val probingHealthText = stringResource(R.string.active_section_probing_health)
val reachableRelayVersionText = stringResource(R.string.active_section_reachable_relay_version)
val unreachableRelayText = stringResource(R.string.active_section_unreachable_relay)
LaunchedEffect(apiUrlInput, relayOverrideVisible, autoRelayUrl) {
if (!relayOverrideVisible && autoRelayUrl != null && relayUrlInput != autoRelayUrl) {
relayUrlInput = autoRelayUrl
@@ -567,8 +623,8 @@ private fun ManualUrlSubsection(
OutlinedTextField(
value = apiUrlInput,
onValueChange = { apiUrlInput = it },
label = { Text("API Server URL") },
placeholder = { Text("http://your-server:8642") },
label = { Text(apiServerUrlLabel) },
placeholder = { Text(apiServerUrlPlaceholder) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -576,19 +632,19 @@ private fun ManualUrlSubsection(
OutlinedTextField(
value = apiKeyInput,
onValueChange = { apiKeyInput = it },
label = { Text("API Key (optional)") },
label = { Text(apiKeyOptionalLabel) },
placeholder = {
Text(
if (apiKeyPresent) "•••• already set (leave blank to keep)"
else "Leave empty if not configured",
if (apiKeyPresent) apiKeyAlreadySetPlaceholder
else apiKeyNotConfiguredPlaceholder,
)
},
supportingText = {
Text(
if (apiKeyPresent && apiKeyInput.isBlank()) {
"A key is already stored — leave blank to keep it, or type to replace"
apiKeyStoredHint
} else {
"Only needed if Hermes is configured with API_SERVER_KEY"
apiKeyNeededHint
},
)
},
@@ -603,7 +659,7 @@ private fun ManualUrlSubsection(
Icon(
imageVector = if (apiKeyVisible) Icons.Filled.VisibilityOff
else Icons.Filled.Visibility,
contentDescription = if (apiKeyVisible) "Hide" else "Show",
contentDescription = if (apiKeyVisible) hideDesc else showDesc,
)
}
},
@@ -635,13 +691,13 @@ private fun ManualUrlSubsection(
when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
"API and Hermes voice reachable"
apiHermesVoiceReachableToast
} else {
"API and relay voice reachable"
apiRelayVoiceReachableToast
}
result.apiReachable ->
"API reachable; voice route needs review"
else -> "Cannot reach API server"
apiReachableVoiceReviewToast
else -> cannotReachApiToast
},
Toast.LENGTH_SHORT,
).show()
@@ -649,7 +705,7 @@ private fun ManualUrlSubsection(
},
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
) {
Text("Save & Test")
Text(saveAndTestText)
}
if (isTestingApi) {
CircularProgressIndicator(
@@ -664,20 +720,20 @@ private fun ManualUrlSubsection(
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Relay URL",
text = relayUrlText,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = if (autoRelayUrl != null && !relayOverrideVisible) {
"Auto: $autoRelayUrl"
autoRelayUrlText.format(autoRelayUrl)
} else {
"Manual override"
manualOverrideText
},
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Relay is optional for voice. Hermes voice uses the Hermes API; Relay voice uses this route when selected or needed.",
text = relayOptionalForVoiceText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -691,7 +747,7 @@ private fun ManualUrlSubsection(
},
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text(if (relayOverrideVisible) "Use auto relay URL" else "Use custom relay URL")
Text(if (relayOverrideVisible) useAutoRelayUrlText else useCustomRelayUrlText)
}
}
@@ -703,11 +759,11 @@ private fun ManualUrlSubsection(
// Stale reachability results belong to the prior URL.
connectionViewModel.clearRelayReachableResult()
},
label = { Text("Relay URL override") },
placeholder = { Text("wss://your-server:8767") },
label = { Text(relayUrlOverrideLabel) },
placeholder = { Text(relayUrlOverridePlaceholder) },
singleLine = true,
supportingText = {
Text("Only needed when the optional Relay route cannot be auto-derived")
Text(relayUrlOverrideHint)
},
modifier = Modifier.fillMaxWidth(),
)
@@ -722,12 +778,12 @@ private fun ManualUrlSubsection(
Text(
text = if (result.voiceConfigReachable) {
if (result.voiceRoute == "standard") {
"Voice ready via Hermes API"
voiceReadyViaHermesApiText
} else {
"Voice ready via ${result.relayUrl ?: "relay"}"
voiceReadyViaRelayText.format(result.relayUrl ?: "relay")
}
} else {
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
voiceRouteNeedsReviewText.format(result.voiceConfigError ?: stringResource(R.string.active_section_voice_config_probe_failed))
},
style = MaterialTheme.typography.bodySmall,
color = color,
@@ -751,13 +807,13 @@ private fun ManualUrlSubsection(
enabled = (if (relayOverrideVisible) relayUrlInput else autoRelayUrl.orEmpty()).isNotBlank() &&
relayReachable !is ConnectionViewModel.RelayReachable.Probing,
) {
Text("Test Relay")
Text(testRelayText)
}
OutlinedButton(
onClick = { connectionViewModel.disconnectRelay() },
enabled = relayConnectionState != ConnectionState.Disconnected,
) {
Text("Disconnect")
Text(disconnectText)
}
}
@@ -772,15 +828,16 @@ private fun ManualUrlSubsection(
strokeWidth = 2.dp,
)
Text(
text = "Probing /health…",
text = probingHealthText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is ConnectionViewModel.RelayReachable.Ok -> {
val sessionsSuffix = if (r.sessions == 1) "" else stringResource(R.string.active_section_sessions_suffix)
Text(
text = "✓ Reachable — hermes-relay v${r.version} (${r.clients} client, ${r.sessions} session${if (r.sessions == 1) "" else "s"})",
text = reachableRelayVersionText.format(r.version, r.clients, r.sessions, sessionsSuffix),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF4CAF50),
)
@@ -792,7 +849,7 @@ private fun ManualUrlSubsection(
)
Column {
Text(
text = "✗ ${humanErr.title}",
text = unreachableRelayText.format(humanErr.title),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -824,6 +881,11 @@ private fun InsecureToggleSubsection(
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
// Pre-resolve strings
val plainConnectionNotEncrypted = stringResource(R.string.active_section_plain_connection_not_encrypted)
val allowPlainConnections = stringResource(R.string.active_section_allow_plain_connections)
val enableWsHttpForDev = stringResource(R.string.active_section_enable_ws_http_for_dev)
if (isInsecureConnection && relayConnectionState == ConnectionState.Connected) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -839,7 +901,7 @@ private fun InsecureToggleSubsection(
modifier = Modifier.size(16.dp),
)
Text(
text = "Plain connection — traffic is not encrypted",
text = plainConnectionNotEncrypted,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -853,11 +915,11 @@ private fun InsecureToggleSubsection(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Allow plain (unencrypted) connections",
text = allowPlainConnections,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Enable ws:// and http:// for local dev/testing only",
text = enableWsHttpForDev,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -910,6 +972,23 @@ private fun ManualPairingCodeSubsection(
var connectAttempt by remember { mutableStateOf(0) }
var explainerExpanded by rememberSaveable { mutableStateOf(false) }
// Pre-resolve strings for non-composable contexts (LaunchedEffect, UiMessageBus)
val pairedSuccessfullyToast = stringResource(R.string.active_section_paired_successfully)
val noResponseFromRelayToast = stringResource(R.string.active_section_no_response_from_relay)
val pairingCodeCopiedToast = stringResource(R.string.active_section_pairing_code_copied)
val commandCopiedToast = stringResource(R.string.active_section_command_copied)
val hermesPairCommandLabel = stringResource(R.string.active_section_hermes_pair_command)
val copyPairingCodeDesc = stringResource(R.string.conn_info_copy_pairing_code)
val generateNewCodeDesc = stringResource(R.string.active_section_generate_new_code)
val copyHermesPairCommandDesc = stringResource(R.string.active_section_copy_hermes_pair_command)
val connectingText = stringResource(R.string.active_section_connecting)
val connectText = stringResource(R.string.active_section_connect)
val relayUrlNotSetText = stringResource(R.string.active_section_relay_url_not_set)
val hideExplanationText = stringResource(R.string.active_section_hide_explanation)
val howDoesThisWorkText = stringResource(R.string.active_section_how_does_this_work)
val manualPairingExplanation = stringResource(R.string.active_section_manual_pairing_explanation)
val pairingCodeLabel = stringResource(R.string.conn_info_pairing_code)
LaunchedEffect(connectAttempt) {
if (connectAttempt == 0) return@LaunchedEffect
try {
@@ -919,7 +998,7 @@ private fun ManualPairingCodeSubsection(
}
connectInProgress = false
when (terminal) {
is AuthState.Paired -> UiMessageBus.success("Paired successfully")
is AuthState.Paired -> UiMessageBus.success(pairedSuccessfullyToast)
is AuthState.Failed -> {
val human = classifyError(
IllegalStateException(terminal.reason),
@@ -932,7 +1011,7 @@ private fun ManualPairingCodeSubsection(
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
connectInProgress = false
val human = classifyError(
java.io.IOException("No response from relay"),
java.io.IOException(noResponseFromRelayToast),
context = "pair",
)
snackbarHost.showHumanError(human)
@@ -942,20 +1021,25 @@ private fun ManualPairingCodeSubsection(
}
}
// Pre-resolve other UI strings
val manualPairingCodeTitle = stringResource(R.string.active_section_manual_pairing_code_title)
val manualPairingCodeDesc = stringResource(R.string.active_section_manual_pairing_code_desc)
val step1CopyCode = stringResource(R.string.active_section_step_1_copy_code)
val step2RunCommand = stringResource(R.string.active_section_step_2_run_command)
val step3TapConnect = stringResource(R.string.active_section_step_3_tap_connect)
Text(
text = "Manual pairing code (fallback)",
text = manualPairingCodeTitle,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "Use this when you can't scan the pairing QR. " +
"Follow the three steps — they're meant to be done in order on " +
"whatever machine you have shell access to.",
text = manualPairingCodeDesc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Step 1 — display + copy + regenerate
ManualPairStep(number = 1, title = "Copy the code below") {
ManualPairStep(number = 1, title = step1CopyCode) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -973,27 +1057,27 @@ private fun ManualPairingCodeSubsection(
IconButton(onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Pairing code", pairingCode)),
ClipEntry(ClipData.newPlainText(pairingCodeLabel, pairingCode)),
)
UiMessageBus.info("Pairing code copied")
UiMessageBus.info(pairingCodeCopiedToast)
}
}) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy pairing code",
contentDescription = copyPairingCodeDesc,
)
}
IconButton(onClick = { connectionViewModel.regeneratePairingCode() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Generate new code",
contentDescription = generateNewCodeDesc,
)
}
}
}
// Step 2 — host command
ManualPairStep(number = 2, title = "On the host running Hermes-Relay, run:") {
ManualPairStep(number = 2, title = step2RunCommand) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(6.dp),
@@ -1016,16 +1100,16 @@ private fun ManualPairingCodeSubsection(
val cmd = "hermes pair --register-code $pairingCode"
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
ClipEntry(ClipData.newPlainText(hermesPairCommandLabel, cmd)),
)
UiMessageBus.info("Command copied")
UiMessageBus.info(commandCopiedToast)
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy hermes pair command",
contentDescription = copyHermesPairCommandDesc,
modifier = Modifier.size(16.dp),
)
}
@@ -1034,7 +1118,7 @@ private fun ManualPairingCodeSubsection(
}
// Step 3 — Connect button + relay-URL prerequisite check
ManualPairStep(number = 3, title = "Come back here and tap Connect") {
ManualPairStep(number = 3, title = step3TapConnect) {
val canConnect = !connectInProgress &&
relayUrl.isNotBlank() &&
pairingCode.isNotBlank()
@@ -1061,14 +1145,14 @@ private fun ManualPairingCodeSubsection(
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.size(8.dp))
Text("Connecting…")
Text(connectingText)
} else {
Text("Connect")
Text(connectText)
}
}
if (relayUrl.isBlank()) {
Text(
text = "Relay URL not set — open the Manual URL section above to set it first.",
text = relayUrlNotSetText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -1082,24 +1166,13 @@ private fun ManualPairingCodeSubsection(
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text(
text = if (explainerExpanded) "Hide explanation" else "How does this work?",
text = if (explainerExpanded) hideExplanationText else howDoesThisWorkText,
style = MaterialTheme.typography.bodySmall,
)
}
if (explainerExpanded) {
Text(
text = "This is a fallback for when you can't scan the pairing QR " +
"— for example, no camera, the host can't render a QR, or you " +
"only have SSH access from a single device. The canonical flow " +
"is the QR scan from `/hermes-relay-pair` or `hermes pair`.\n\n" +
"How it works: the phone generates a 6-character code locally. " +
"You paste that code into the host's `hermes pair --register-code` " +
"command, which pre-registers it with the relay. When you tap " +
"Connect here, the phone presents the same code to the relay " +
"and gets a long-lived session token in return.\n\n" +
"Bridge / device-control is gated by the master toggle on the " +
"Bridge tab, NOT by this pairing code. Pairing only authorizes " +
"the relay session.",
text = manualPairingExplanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1136,6 +1209,13 @@ fun ActiveCardSecurityPosture(
modifier = Modifier.fillMaxWidth(),
)
// Pre-resolve strings
val tailscaleDetectedText = stringResource(R.string.active_section_tailscale_detected)
val sessionTokenHardwareKeystoreText = stringResource(R.string.active_section_session_token_hardware_keystore)
val relaySessionsLabel = stringResource(R.string.active_section_relay_sessions)
val activeSessionsOnServerText = stringResource(R.string.active_section_active_sessions_on_server)
val managePhonesConnectText = stringResource(R.string.active_section_manage_phones_connect)
if (isTailscaleDetected) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1148,7 +1228,7 @@ fun ActiveCardSecurityPosture(
modifier = Modifier.size(16.dp),
)
Text(
text = "Tailscale detected",
text = tailscaleDetectedText,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF2E7D32),
)
@@ -1167,7 +1247,7 @@ fun ActiveCardSecurityPosture(
modifier = Modifier.size(16.dp),
)
Text(
text = "Session token stored in hardware keystore",
text = sessionTokenHardwareKeystoreText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -1184,14 +1264,14 @@ fun ActiveCardSecurityPosture(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Relay sessions",
text = relaySessionsLabel,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = if (pairedDevices.isNotEmpty()) {
"${pairedDevices.size} active sessions on this server"
activeSessionsOnServerText.format(pairedDevices.size)
} else {
"Manage which phones can connect"
managePhonesConnectText
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -1253,22 +1333,42 @@ fun ActiveCardRoutesSection(
val probeCameUpEmpty = activeEndpoint == null &&
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Done &&
routeProbeStatus.winner == null
// Pre-resolve strings for route status labels
val checkingRoutesText = stringResource(R.string.active_section_checking_routes)
val noRouteReachableText = stringResource(R.string.active_section_no_route_reachable)
val resolvingText = stringResource(R.string.active_section_resolving)
val usingSavedUrlText = stringResource(R.string.active_section_using_saved_url)
val activeRouteLabel = when {
activeEndpoint != null -> activeEndpoint!!.displayLabel()
isRouteProbing -> "Checking routes…"
probeCameUpEmpty -> "No route reachable"
else -> "Resolving"
isRouteProbing -> checkingRoutesText
probeCameUpEmpty -> noRouteReachableText
else -> resolvingText
}
val activeRouteHost = activeEndpoint?.api?.url
?: "Using saved URL: ${connection.apiServerUrl.ifBlank { connection.relayUrl }}"
?: usingSavedUrlText.format(connection.apiServerUrl.ifBlank { connection.relayUrl })
// Pre-resolve other UI strings
val chooseHowPhoneReachesText = stringResource(R.string.active_section_choose_how_phone_reaches)
val currentRouteText = stringResource(R.string.active_section_current_route)
val noRoutesAnsweredProbeText = stringResource(R.string.active_section_no_routes_answered_probe)
val tailscaleRouteNotActiveText = stringResource(R.string.active_section_tailscale_route_not_active)
val connectPhoneInTailscaleText = stringResource(R.string.active_section_connect_phone_in_tailscale)
val openTailscaleText = stringResource(R.string.active_section_open_tailscale)
val checkingText = stringResource(R.string.active_section_checking)
val recheckText = stringResource(R.string.active_section_recheck)
val phoneOnTailscaleNoRouteText = stringResource(R.string.active_section_phone_on_tailscale_no_route)
val addServerTailscaleUrlText = stringResource(R.string.active_section_add_server_tailscale_url)
val addTailscaleRouteText = stringResource(R.string.active_section_add_tailscale_route)
val autoText = stringResource(R.string.active_section_auto)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Choose how this phone reaches Hermes. Features stay separate " +
"from the selected route.",
text = chooseHowPhoneReachesText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1287,7 +1387,7 @@ fun ActiveCardRoutesSection(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Current: $activeRouteLabel",
text = currentRouteText.format(activeRouteLabel),
style = MaterialTheme.typography.bodyMedium,
color = if (probeCameUpEmpty) {
MaterialTheme.colorScheme.error
@@ -1311,8 +1411,7 @@ fun ActiveCardRoutesSection(
)
if (probeCameUpEmpty) {
Text(
text = "None of the saved routes answered a health probe. " +
"Expand the routes below for per-route reasons.",
text = noRoutesAnsweredProbeText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -1331,12 +1430,12 @@ fun ActiveCardRoutesSection(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Tailscale route is not active on this phone",
text = tailscaleRouteNotActiveText,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = "Connect this phone in Tailscale, then re-check routes.",
text = connectPhoneInTailscaleText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
@@ -1351,7 +1450,7 @@ fun ActiveCardRoutesSection(
},
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text("Open Tailscale")
Text(openTailscaleText)
}
}
TextButton(
@@ -1359,7 +1458,7 @@ fun ActiveCardRoutesSection(
enabled = !isRouteProbing,
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text(if (isRouteProbing) "Checking…" else "Re-check")
Text(if (isRouteProbing) checkingText else recheckText)
}
}
}
@@ -1380,13 +1479,12 @@ fun ActiveCardRoutesSection(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Phone is on Tailscale — no Tailscale route yet",
text = phoneOnTailscaleNoRouteText,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = "Add your server's Tailscale URL so Hermes keeps " +
"working when this phone leaves the server's network.",
text = addServerTailscaleUrlText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
@@ -1397,7 +1495,7 @@ fun ActiveCardRoutesSection(
},
contentPadding = PaddingValues(horizontal = 0.dp),
) {
Text("Add Tailscale route")
Text(addTailscaleRouteText)
}
}
}
@@ -1411,7 +1509,7 @@ fun ActiveCardRoutesSection(
onClick = { connectionViewModel.probeNow() },
enabled = !isRouteProbing,
) {
Text(if (isRouteProbing) "Checking…" else "Re-check")
Text(if (isRouteProbing) checkingText else recheckText)
}
if (preferredRole != null || manualSwitchActive) {
TextButton(
@@ -1420,7 +1518,7 @@ fun ActiveCardRoutesSection(
preferredRole = null
},
) {
Text("Auto")
Text(autoText)
}
}
}
@@ -24,6 +24,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import java.io.File
@@ -42,7 +44,7 @@ fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Agent icon",
text = stringResource(R.string.agent_icon_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -61,23 +63,23 @@ fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
if (!path.isNullOrBlank()) {
AsyncImage(
model = File(path),
contentDescription = "Agent icon",
contentDescription = stringResource(R.string.agent_icon_title),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
OutlinedButton(onClick = { launcher.launch(arrayOf("image/*")) }) {
Text(if (iconPath.isNullOrBlank()) "Set image" else "Change")
Text(if (iconPath.isNullOrBlank()) stringResource(R.string.agent_icon_set) else stringResource(R.string.agent_icon_change))
}
if (!iconPath.isNullOrBlank()) {
TextButton(onClick = { connectionViewModel.clearProfileIcon() }) {
Text("Clear")
Text(stringResource(R.string.agent_icon_clear))
}
}
}
Text(
text = "Shown beside this profile's name in chat. Stays on this device — never sent to Hermes.",
text = stringResource(R.string.agent_icon_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -72,12 +72,14 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlinx.coroutines.delay
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
// --- Text-flow tuning constants -------------------------------------------
//
@@ -188,8 +190,8 @@ private fun Modifier.topFadeEdge(fade: Dp = 28.dp): Modifier = this
)
}
/** Resolved motion/accessibility posture for clean mode. */
private data class CleanMotionState(
/** Shared OS motion/accessibility posture for animated chat affordances. */
internal data class AccessibleMotionState(
/** OS animator scale is non-zero (i.e. system animations are ON). */
val osAnimations: Boolean,
/** TalkBack-style touch exploration is active — faded text is unreadable
@@ -198,7 +200,7 @@ private data class CleanMotionState(
)
@Composable
private fun rememberCleanMotionState(): CleanMotionState {
internal fun rememberAccessibleMotionState(): AccessibleMotionState {
val context = LocalContext.current
// ANIMATOR_DURATION_SCALE == 0 is the platform "remove animations" / many
// OEM "reduce motion" toggles. Read once on entry; a mid-mode toggle is
@@ -225,7 +227,10 @@ private fun rememberCleanMotionState(): CleanMotionState {
a11y?.addTouchExplorationStateChangeListener(listener)
onDispose { a11y?.removeTouchExplorationStateChangeListener(listener) }
}
return CleanMotionState(osAnimations = osAnimations, touchExploration = touchExploration)
return AccessibleMotionState(
osAnimations = osAnimations,
touchExploration = touchExploration,
)
}
/**
@@ -450,7 +455,7 @@ private fun CleanModeComposer(
Box(contentAlignment = Alignment.CenterStart) {
if (text.isEmpty()) {
Text(
text = "Message",
text = stringResource(R.string.agent_text_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = RelayRefresh.Dim,
)
@@ -466,7 +471,7 @@ private fun CleanModeComposer(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
contentDescription = stringResource(R.string.agent_text_send_cd),
tint = if (canSend) {
MaterialTheme.colorScheme.primary
} else {
@@ -511,7 +516,7 @@ fun CleanChatMode(
onExit: () -> Unit,
modifier: Modifier = Modifier,
) {
val motion = rememberCleanMotionState()
val motion = rememberAccessibleMotionState()
val sphereAnimated = animationEnabled && motion.osAnimations
// Faded text is unreadable to touch exploration, so the text path goes
// static (readable + announced) whenever TalkBack is exploring.
@@ -547,8 +552,9 @@ fun CleanChatMode(
BackHandler(enabled = true) { onExit() }
val sphereDescLabel = stringResource(R.string.agent_text_sphere_desc)
val sphereDescription = remember(sphereState) {
"Agent ${sphereState.name.lowercase()}"
"$sphereDescLabel ${sphereState.name.lowercase()}"
}
Box(
@@ -586,7 +592,7 @@ fun CleanChatMode(
IconButton(onClick = onExit) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Exit clean mode",
contentDescription = stringResource(R.string.agent_text_exit_clean),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -0,0 +1,355 @@
package com.hermesandroid.relay.ui.components
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
import com.hermesandroid.relay.data.AttachmentState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* One item in a message's attachment render order. Loaded images are grouped
* into [Gallery] only when there are at least two; every other attachment
* keeps its original index so retry/manual-fetch callbacks still target the
* exact [com.hermesandroid.relay.data.ChatMessage.attachments] entry.
*/
internal sealed interface AttachmentLayoutItem {
data class Single(val attachmentIndex: Int) : AttachmentLayoutItem
data class Gallery(val attachmentIndices: List<Int>) : AttachmentLayoutItem
}
/**
* Build the attachment render plan without reordering non-image cards. The
* gallery occupies the first eligible image's slot and absorbs the remaining
* loaded images, including images separated by a PDF/file card.
*/
internal fun attachmentLayoutItems(attachments: List<Attachment>): List<AttachmentLayoutItem> {
return buildList {
var index = 0
while (index < attachments.size) {
if (!attachments[index].isGalleryImage()) {
add(AttachmentLayoutItem.Single(index))
index++
continue
}
val run = buildList {
var cursor = index
while (cursor < attachments.size && attachments[cursor].isGalleryImage()) {
add(cursor)
cursor++
}
}
if (run.size >= 2) add(AttachmentLayoutItem.Gallery(run))
else add(AttachmentLayoutItem.Single(index))
index += run.size
}
}
}
private fun Attachment.isGalleryImage(): Boolean =
state == AttachmentState.LOADED && renderMode == AttachmentRenderMode.IMAGE
/** Two-column, non-lazy rows for a gallery nested inside the chat LazyColumn. */
internal fun galleryRows(itemCount: Int): List<List<Int>> =
(0 until itemCount.coerceAtLeast(0)).chunked(GALLERY_COLUMNS)
internal fun galleryPreviewIndices(itemCount: Int): List<Int> =
(0 until itemCount.coerceAtLeast(0)).take(GALLERY_PREVIEW_LIMIT)
/**
* Telegram-style media group for two or more loaded image attachments.
*
* The chat bubble shows a compact two-column grid. Tapping a tile opens the
* full-screen horizontal pager at that image; per-image blur reveal, long-
* press actions, and one-tap Save remain available instead of regressing the
* single-image attachment behavior.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AttachmentGallery(
attachments: List<Attachment>,
modifier: Modifier = Modifier,
maxWidth: Dp = 280.dp,
) {
if (attachments.size < 2) return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val blurMode = LocalMediaBlurMode.current
val revealed = remember { mutableStateMapOf<String, Boolean>() }
var viewerStartIndex by remember { mutableStateOf<Int?>(null) }
viewerStartIndex?.let { startIndex ->
AttachmentGalleryViewer(
attachments = attachments,
initialIndex = startIndex.coerceIn(attachments.indices),
initiallyRevealedKeys = revealed
.filterValues { it }
.keys,
onDismiss = { viewerStartIndex = null },
)
}
Column(
modifier = modifier
.widthIn(max = maxWidth)
.fillMaxWidth()
.semantics { contentDescription = "${attachments.size} image gallery" },
verticalArrangement = Arrangement.spacedBy(GALLERY_GAP),
) {
val previewIndices = galleryPreviewIndices(attachments.size)
galleryRows(previewIndices.size).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(GALLERY_GAP),
) {
row.forEach { previewIndex ->
val galleryIndex = previewIndices[previewIndex]
val attachment = attachments[galleryIndex]
val attachmentKey = galleryAttachmentKey(attachment, galleryIndex)
val blurred = revealed[attachmentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
var menuExpanded by remember(attachment, galleryIndex) { mutableStateOf(false) }
Box(
modifier = Modifier
.weight(1f)
// An odd final tile spans both columns without
// becoming a full-width square taller than the grid.
.aspectRatio(if (row.size == 1) 2f else 1f),
) {
BlurredMedia(
blurred = blurred,
onReveal = { revealed[attachmentKey] = true },
modifier = Modifier.fillMaxSize(),
) {
GalleryImageTile(
attachment = attachment,
position = galleryIndex,
count = attachments.size,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-tile-$galleryIndex")
.clip(RoundedCornerShape(GALLERY_CORNER))
.combinedClickable(
onClick = { viewerStartIndex = galleryIndex },
onLongClick = { menuExpanded = true },
),
)
}
if (!blurred) {
SaveOverlayButton(
onClick = {
scope.launch { saveAttachment(context, attachment) }
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
)
}
AttachmentActionsMenu(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
attachment = attachment,
)
val hiddenCount = attachments.size - GALLERY_PREVIEW_LIMIT
if (
hiddenCount > 0 &&
previewIndex == GALLERY_PREVIEW_LIMIT - 1
) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(7.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.68f))
.padding(horizontal = 9.dp, vertical = 4.dp),
) {
Text(
text = "+$hiddenCount",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
)
}
}
}
}
}
}
}
}
@Composable
private fun GalleryImageTile(
attachment: Attachment,
position: Int,
count: Int,
modifier: Modifier,
) {
val description = listOfNotNull(
attachment.fileName?.takeIf { it.isNotBlank() },
"image ${position + 1} of $count",
).joinToString(", ")
val cachedUri = attachment.cachedUri?.takeIf { it.isNotBlank() }
if (cachedUri != null) {
SubcomposeAsyncImage(
model = Uri.parse(cachedUri),
contentDescription = description,
contentScale = ContentScale.Crop,
modifier = modifier,
) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Loading -> GalleryImagePlaceholder(modifier = Modifier.fillMaxSize())
else -> GalleryImageFailure(description, Modifier.fillMaxSize())
}
}
return
}
var bitmap by remember(attachment.content) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(attachment.content) { mutableStateOf(false) }
LaunchedEffect(attachment.content) {
val decoded = withContext(Dispatchers.IO) {
runCatching {
val bytes = android.util.Base64.decode(
attachment.content,
android.util.Base64.DEFAULT,
)
decodeGalleryBitmap(bytes)?.asImageBitmap()
}.getOrNull()
}
if (decoded != null) bitmap = decoded else failed = true
}
when {
bitmap != null -> Image(
bitmap = bitmap!!,
contentDescription = description,
contentScale = ContentScale.Crop,
modifier = modifier,
)
failed -> GalleryImageFailure(description, modifier)
else -> GalleryImagePlaceholder(modifier)
}
}
@Composable
private fun GalleryImagePlaceholder(modifier: Modifier) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
}
@Composable
private fun GalleryImageFailure(description: String, modifier: Modifier) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.BrokenImage,
contentDescription = stringResource(R.string.attachment_load_failed_a11y, description),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
}
}
/** Decode a bounded thumbnail rather than retaining every full-size image. */
private fun decodeGalleryBitmap(bytes: ByteArray): android.graphics.Bitmap? {
if (bytes.isEmpty()) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (
bounds.outWidth / sample > GALLERY_DECODE_TARGET_PX ||
bounds.outHeight / sample > GALLERY_DECODE_TARGET_PX
) {
sample *= 2
}
val options = BitmapFactory.Options().apply { inSampleSize = sample }
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}
private const val GALLERY_COLUMNS = 2
private const val GALLERY_PREVIEW_LIMIT = 4
private const val GALLERY_DECODE_TARGET_PX = 512
private val GALLERY_GAP = 3.dp
private val GALLERY_CORNER = 8.dp
internal fun galleryAttachmentKey(attachment: Attachment, index: Int): String =
attachment.relayToken?.takeIf { it.isNotBlank() }
?: attachment.cachedUri?.takeIf { it.isNotBlank() }
?: buildString {
append(attachment.fileName.orEmpty())
append('|')
append(attachment.contentType)
append('|')
append(attachment.content.hashCode())
append('|')
append(index)
}
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.components
import android.content.Context
@@ -15,7 +17,8 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -34,6 +37,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
@@ -59,6 +64,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -77,8 +83,10 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -91,6 +99,8 @@ import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
import com.hermesandroid.relay.data.BlurMode
import com.hermesandroid.relay.util.MediaSaver
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -98,6 +108,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.math.abs
import kotlin.math.sqrt
// ---------------------------------------------------------------------------
@@ -160,18 +171,18 @@ fun BlurredMedia(
) {
Icon(
imageVector = Icons.Filled.VisibilityOff,
contentDescription = "Sensitive content",
contentDescription = stringResource(R.string.attach_viewer_cd_sensitive),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
Text(
text = "Sensitive",
text = stringResource(R.string.attachment_sensitive),
style = MaterialTheme.typography.labelLarge,
color = Color.White,
)
if (revealOnTap) {
Text(
text = "Tap to reveal",
text = stringResource(R.string.attachment_tap_reveal),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.8f),
)
@@ -191,13 +202,62 @@ fun BlurredMedia(
fun Modifier.zoomable(maxScale: Float = 6f): Modifier {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
return this
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, maxScale)
offset = if (scale > 1f) offset + pan else Offset.Zero
}
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
fun maxOffset(forScale: Float): Offset = Offset(
x = ((forScale - 1f) * viewportSize.width / 2f).coerceAtLeast(0f),
y = ((forScale - 1f) * viewportSize.height / 2f).coerceAtLeast(0f),
)
fun clampOffset(candidate: Offset, forScale: Float): Offset {
val max = maxOffset(forScale)
return Offset(
x = candidate.x.coerceIn(-max.x, max.x),
y = candidate.y.coerceIn(-max.y, max.y),
)
}
val transformState = rememberTransformableState { _, zoomChange, panChange, _ ->
val nextScale = (scale * zoomChange).coerceIn(1f, maxScale)
offset = if (nextScale > 1f) {
clampOffset(offset + panChange, nextScale)
} else {
Offset.Zero
}
scale = nextScale
}
return this
.onSizeChanged {
viewportSize = it
offset = clampOffset(offset, scale)
}
// Let a one-finger drag bubble to HorizontalPager at 1×. Once the
// image is zoomed, the image owns panning; pinch zoom always works.
.transformable(
state = transformState,
canPan = { pan ->
if (scale <= 1f) {
false
} else {
val max = maxOffset(scale)
val canMoveHorizontally = when {
pan.x > 0f -> offset.x < max.x
pan.x < 0f -> offset.x > -max.x
else -> false
}
val canMoveVertically = when {
pan.y > 0f -> offset.y < max.y
pan.y < 0f -> offset.y > -max.y
else -> false
}
if (abs(pan.x) >= abs(pan.y)) {
canMoveHorizontally
} else {
canMoveVertically
}
}
},
)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
@@ -266,7 +326,7 @@ fun AttachmentViewer(
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Attachment" }
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
@@ -275,7 +335,7 @@ fun AttachmentViewer(
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
viewerToast(context, "Couldn't read this file")
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
return@launch
}
action(bytes)
@@ -298,13 +358,13 @@ fun AttachmentViewer(
}
when (result) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, "Saved to ${result.location}")
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, "Save failed: ${result.message}")
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
}
@@ -345,6 +405,7 @@ fun AttachmentViewer(
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
@@ -355,11 +416,204 @@ fun AttachmentViewer(
}
}
/**
* Full-screen viewer for an image attachment group. The pager starts at the
* tapped tile, swipes horizontally at 1×, and keeps the existing per-image
* zoom, blur, Save, Share, and Open-externally behavior.
*
* [initiallyRevealedKeys] carries reveal state from the grid so a sensitive
* image that was already uncovered is not unexpectedly hidden again on open.
*/
@Composable
internal fun AttachmentGalleryViewer(
attachments: List<Attachment>,
initialIndex: Int,
onDismiss: () -> Unit,
initiallyRevealedKeys: Set<String> = emptySet(),
modifier: Modifier = Modifier,
) {
if (attachments.isEmpty()) return
if (attachments.size == 1) {
AttachmentViewer(
attachment = attachments.first(),
onDismiss = onDismiss,
modifier = modifier,
initiallyRevealed = galleryAttachmentKey(attachments.first(), 0) in
initiallyRevealedKeys,
)
return
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val revealed = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(initiallyRevealedKeys) {
initiallyRevealedKeys.forEach { revealed[it] = true }
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(attachments.indices),
pageCount = { attachments.size },
)
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return
val target = attachment
scope.launch {
busy = true
try {
val bytes = attachmentBytes(context, target)
if (bytes == null) {
viewerToast(context, "Couldn't read this image")
return@launch
}
action(target, bytes)
} catch (error: Exception) {
viewerToast(
context,
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
} finally {
busy = false
}
}
}
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
}
val onSave = {
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage(
context,
bytes,
target.fileName,
target.contentType,
)) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, "Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, "Save failed: ${result.message}")
}
}
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
viewerToast(context, "Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
)
MediaSaver.open(context, uri, item.contentType)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
}
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
/** The single shared control bar used across every attachment type. */
@Composable
private fun MediaViewerToolbar(
title: String,
busy: Boolean,
actionsEnabled: Boolean = true,
onShare: () -> Unit,
onSave: () -> Unit,
onOpenExternal: () -> Unit,
@@ -376,7 +630,7 @@ private fun MediaViewerToolbar(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onClose, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = "Close")
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.attach_viewer_cd_close))
}
Text(
text = title,
@@ -393,14 +647,18 @@ private fun MediaViewerToolbar(
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
}
IconButton(onClick = onOpenExternal, colors = tint) {
Icon(Icons.Filled.OpenInNew, contentDescription = "Open externally")
IconButton(
onClick = onOpenExternal,
enabled = actionsEnabled && !busy,
colors = tint,
) {
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
}
IconButton(onClick = onShare, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = "Share")
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = "Save")
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
}
}
}
@@ -452,7 +710,7 @@ private fun ImageBody(
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().zoomable(),
)
failed -> CenteredNotice("Couldn't load this image")
failed -> CenteredNotice(stringResource(R.string.attach_viewer_load_failed))
else -> CircularProgressIndicator(color = Color.White)
}
}
@@ -468,7 +726,7 @@ private fun VideoBody(attachment: Attachment) {
val uri = rememberPlayableUri(attachment)
if (uri == null) {
CenteredNotice("Preparing video…", spinner = true)
CenteredNotice(stringResource(R.string.attach_viewer_preparing_video), spinner = true)
return
}
@@ -526,7 +784,7 @@ private fun VideoBody(attachment: Attachment) {
) {
Icon(
imageVector = if (muted) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp,
contentDescription = if (muted) "Unmute" else "Mute",
contentDescription = if (muted) stringResource(R.string.attach_viewer_cd_unmute) else stringResource(R.string.attach_viewer_cd_mute),
)
}
},
@@ -543,7 +801,7 @@ private fun AudioBody(attachment: Attachment) {
val uri = rememberPlayableUri(attachment)
if (uri == null) {
CenteredNotice("Preparing audio…", spinner = true)
CenteredNotice(stringResource(R.string.attach_viewer_preparing_audio), spinner = true)
return
}
@@ -605,7 +863,7 @@ private fun AudioBody(attachment: Attachment) {
AmplitudeMeter(amplitude = amplitude, active = isPlaying)
Spacer(Modifier.height(24.dp))
Text(
text = attachment.fileName ?: "Audio",
text = attachment.fileName ?: stringResource(R.string.attachment_audio_label),
style = MaterialTheme.typography.titleMedium,
color = Color.White,
maxLines = 2,
@@ -684,6 +942,8 @@ private fun PdfBody(attachment: Attachment) {
var pdfError by remember(attachment.cachedUri, attachment.content) { mutableStateOf<String?>(null) }
var widthPx by remember { mutableStateOf(0) }
val pdfFailedText = stringResource(R.string.attach_viewer_pdf_failed)
LaunchedEffect(attachment.cachedUri, attachment.content) {
val opened = withContext(Dispatchers.IO) {
runCatching {
@@ -692,14 +952,14 @@ private fun PdfBody(attachment: Attachment) {
PdfDoc(PdfRenderer(pfd), pfd, Mutex())
}.getOrNull()
}
if (opened == null) pdfError = "Couldn't open this PDF" else doc = opened
if (opened == null) pdfError = pdfFailedText else doc = opened
}
DisposableEffect(doc) { onDispose { doc?.close() } }
val current = doc
when {
pdfError != null -> CenteredNotice(pdfError!!)
current == null -> CenteredNotice("Rendering PDF…", spinner = true)
current == null -> CenteredNotice(stringResource(R.string.attach_viewer_rendering_pdf), spinner = true)
else -> LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -747,7 +1007,7 @@ private fun PdfPage(doc: PdfDoc, index: Int, widthPx: Int) {
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = "Page ${index + 1}",
contentDescription = stringResource(R.string.attach_viewer_page_label, index + 1),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(2.dp)),
)
@@ -783,7 +1043,7 @@ private fun TextBody(attachment: Attachment) {
val body = text
when {
body == null -> CenteredNotice("Loading…", spinner = true)
body == null -> CenteredNotice(stringResource(R.string.attach_viewer_loading), spinner = true)
else -> SelectionContainer(
modifier = Modifier
.fillMaxSize()
@@ -812,19 +1072,19 @@ private fun GenericBody(attachment: Attachment, onOpenExternal: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = attachment.fileName ?: "File",
text = attachment.fileName ?: stringResource(R.string.attach_viewer_file_label),
style = MaterialTheme.typography.titleMedium,
color = Color.White,
)
Spacer(Modifier.height(8.dp))
Text(
text = "No in-app preview for this type.",
text = stringResource(R.string.attachment_no_preview),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
)
Spacer(Modifier.height(16.dp))
androidx.compose.material3.OutlinedButton(onClick = onOpenExternal) {
Text("Open externally")
Text(stringResource(R.string.attachment_open_ext))
}
}
}
@@ -862,7 +1122,7 @@ private fun PlaybackControls(
IconButton(onClick = onPlayPause, colors = tint) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
contentDescription = if (isPlaying) stringResource(R.string.attach_viewer_cd_pause) else stringResource(R.string.attach_viewer_cd_play),
)
}
Text(
@@ -0,0 +1,224 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.BackgroundTaskPhase
import com.hermesandroid.relay.data.BackgroundTaskState
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
/**
* The Chat-side identity for one promoted/durable Hermes run. It stays in the
* owning assistant turn while [BackgroundTaskState.phase] advances, rather
* than creating a running system notice and a second completion row.
*
* Tool activity is deliberately subordinate: the compact timeline expands
* inside this card and reuses [CompactToolCall]/[SubagentLane], so background
* work reads like the same task at every stage instead of a mini dashboard.
*/
@Composable
fun BackgroundTaskCard(
task: BackgroundTaskState,
toolCalls: List<ToolCall>,
showTimeline: Boolean,
modifier: Modifier = Modifier,
) {
val terminal = task.phase in terminalBackgroundTaskPhases
val timelineCalls = if (showTimeline) toolCalls else emptyList()
val hasTimeline = timelineCalls.isNotEmpty()
var expanded by rememberSaveable(task.id) { mutableStateOf(hasTimeline && !terminal) }
LaunchedEffect(terminal, hasTimeline) {
if (!hasTimeline || terminal) expanded = false
}
val phaseLabel = backgroundTaskPhaseLabel(task.phase)
val meta = backgroundTaskMeta(task, timelineCalls)
val icon: ImageVector
val iconTint = when (task.phase) {
BackgroundTaskPhase.COMPLETE -> {
icon = Icons.Filled.Check
MaterialTheme.colorScheme.primary
}
BackgroundTaskPhase.FAILED, BackgroundTaskPhase.CANCELLED -> {
icon = Icons.Filled.Close
MaterialTheme.colorScheme.error
}
else -> {
icon = Icons.Filled.HourglassTop
MaterialTheme.colorScheme.tertiary
}
}
Card(
modifier = modifier
.fillMaxWidth()
.semantics {
contentDescription = buildString {
append("Background task, ")
append(task.title)
append(", ")
append(phaseLabel.lowercase())
task.statusLine?.takeIf { it.isNotBlank() }?.let {
append(", ")
append(it)
}
if (meta.isNotBlank()) {
append(", ")
append(meta)
}
}
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
),
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = hasTimeline) { expanded = !expanded }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
task.statusLine?.takeIf { it.isNotBlank() }?.let { status ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Column(horizontalAlignment = Alignment.End) {
Text(
text = phaseLabel,
style = relayMetadataStyle(),
color = iconTint,
)
if (meta.isNotBlank()) {
Text(
text = meta,
style = relayMetadataStyle(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (hasTimeline) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse task timeline" else "Expand task timeline",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
if (!terminal) {
// A fixed accent rail communicates active state without adding
// another indeterminate animation to an already-live transcript.
Box(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f)),
)
}
if (expanded) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
Column(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val lanes = timelineCalls.groupBy { it.taskIndex }
lanes[null].orEmpty().forEach { call ->
CompactToolCall(toolCall = call)
}
lanes.keys.filterNotNull().sorted().forEach { taskIndex ->
SubagentLane(
taskIndex = taskIndex,
calls = lanes.getValue(taskIndex),
)
}
}
}
}
}
}
internal fun backgroundTaskPhaseLabel(phase: BackgroundTaskPhase): String = when (phase) {
BackgroundTaskPhase.RUNNING -> "Working"
BackgroundTaskPhase.WAITING -> "Needs input"
BackgroundTaskPhase.DELIVERING -> "Delivering"
BackgroundTaskPhase.COMPLETE -> "Complete"
BackgroundTaskPhase.FAILED -> "Failed"
BackgroundTaskPhase.CANCELLED -> "Cancelled"
}
internal fun backgroundTaskMeta(task: BackgroundTaskState, toolCalls: List<ToolCall>): String {
val completed = maxOf(task.completedToolCount, toolCalls.count { it.isComplete })
return buildList {
if (completed > 0) add("$completed step${if (completed == 1) "" else "s"}")
if (task.queuedCount > 0) add("+${task.queuedCount} queued")
}.joinToString(" · ")
}
private val terminalBackgroundTaskPhases = setOf(
BackgroundTaskPhase.COMPLETE,
BackgroundTaskPhase.FAILED,
BackgroundTaskPhase.CANCELLED,
)
@@ -36,8 +36,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.BridgeActivityEntry
@@ -89,14 +91,14 @@ fun BridgeActivityLog(
)
Spacer(modifier = Modifier.size(6.dp))
Text(
text = "Activity Log",
text = stringResource(R.string.bal_activity_log),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
if (entries.isNotEmpty()) {
TextButton(onClick = onClear) { Text("Clear") }
TextButton(onClick = onClear) { Text(stringResource(R.string.bal_clear)) }
}
}
@@ -104,8 +106,7 @@ fun BridgeActivityLog(
if (entries.isEmpty()) {
Text(
text = "No bridge commands yet. Every tap, type, and " +
"screenshot the agent performs will show up here.",
text = stringResource(R.string.bal_no_activity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -162,7 +163,7 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
if (entry.thumbnailToken != null) {
Icon(
imageVector = Icons.Filled.InsertPhoto,
contentDescription = "Has screenshot",
contentDescription = stringResource(R.string.bal_has_screenshot),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp)
)
@@ -175,13 +176,13 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Full timestamp: ${formatFullTime(entry.timestampMs)}",
text = stringResource(R.string.bal_full_timestamp, formatFullTime(entry.timestampMs)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace
)
Text(
text = "Status: ${entry.status.name}",
text = stringResource(R.string.bal_status_label, entry.status.name),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -199,7 +200,7 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
// label so the expand affordance still communicates the
// shape of the future feature.
Text(
text = "Screenshot token: ${entry.thumbnailToken}",
text = stringResource(R.string.bal_screenshot_token, entry.thumbnailToken),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace
@@ -33,9 +33,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.BridgeStatus
/**
@@ -61,6 +63,7 @@ fun BridgeMasterToggle(
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String = "Agent Control",
// Note: label default is intentionally a literal — it's resolved from the caller, not from stringResource
// Called when the user taps the switch to enable but accessibility
// hasn't been granted yet. The default no-op keeps the v0.4 behaviour
// for callers that don't wire this up; BridgeScreen hooks a snackbar
@@ -81,17 +84,15 @@ fun BridgeMasterToggle(
val isSideloadLabel = label.contains("Agent", ignoreCase = true)
val subtitle = if (isSideloadLabel) {
if (enabled) {
"Master switch — agent can read screen and act via the " +
"sub-features below."
stringResource(R.string.bmt_master_switch_on)
} else {
"Master switch — off. All bridge features (unattended, " +
"commands, voice intents) are inactive."
stringResource(R.string.bmt_master_switch_off)
}
} else {
if (enabled) {
"Master switch — bridge is providing screen content to chat."
stringResource(R.string.bmt_master_switch_on_googleplay)
} else {
"Master switch — off. Bridge is not reading screen content."
stringResource(R.string.bmt_master_switch_off_googleplay)
}
}
@@ -125,7 +126,7 @@ fun BridgeMasterToggle(
IconButton(onClick = { showExplain = true }) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = "What does this do?",
contentDescription = stringResource(R.string.bmt_what_does_this_do),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -149,7 +150,7 @@ fun BridgeMasterToggle(
if (!accessibilityGranted) {
Text(
text = "Grant the Accessibility Service permission below to enable.",
text = stringResource(R.string.bmt_grant_accessibility),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
@@ -159,22 +160,22 @@ fun BridgeMasterToggle(
Spacer(modifier = Modifier.height(2.dp))
StatusInlineRow(
icon = Icons.Filled.PhoneAndroid,
label = "Device",
label = stringResource(R.string.bmt_device),
value = status.deviceName
)
StatusInlineRow(
icon = Icons.Filled.BatteryFull,
label = "Battery",
label = stringResource(R.string.bmt_battery),
value = status.batteryPercent?.let { "$it%" } ?: "—"
)
StatusInlineRow(
icon = Icons.Filled.ScreenLockPortrait,
label = "Screen",
value = if (status.screenOn) "ON" else "OFF"
label = stringResource(R.string.bmt_screen),
value = if (status.screenOn) stringResource(R.string.bmt_on) else stringResource(R.string.bmt_off)
)
StatusInlineRow(
icon = Icons.Filled.Smartphone,
label = "Current app",
label = stringResource(R.string.bmt_current_app),
value = status.currentApp ?: "—"
)
}
@@ -184,40 +185,29 @@ fun BridgeMasterToggle(
if (showExplain) {
AlertDialog(
onDismissRequest = { showExplain = false },
title = { Text("About Agent Control") },
title = { Text(stringResource(R.string.bmt_about_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Agent Control lets your Hermes agent read what's on " +
"your screen and interact with apps on your behalf " +
"(tap, type, scroll, screenshot).",
stringResource(R.string.bmt_about_body1),
style = MaterialTheme.typography.bodyMedium
)
Text(
"This uses Android's Accessibility Service API, which " +
"is the same permission screen readers use. You must " +
"enable it in Android Settings before this switch works.",
stringResource(R.string.bmt_about_body2),
style = MaterialTheme.typography.bodyMedium
)
Text(
"While this is on, a 'Hermes has device control' " +
"notification stays in your notification shade — " +
"that's tied to this master switch, not to any " +
"sub-feature (like Unattended Access), and goes " +
"away the moment you turn this off.",
stringResource(R.string.bmt_about_body3),
style = MaterialTheme.typography.bodyMedium
)
Text(
"You can turn Agent Control off at any time from this " +
"screen or by disabling the service in Android " +
"Settings. All bridge commands are logged in the " +
"Activity Log below.",
stringResource(R.string.bmt_about_body4),
style = MaterialTheme.typography.bodyMedium
)
}
},
confirmButton = {
TextButton(onClick = { showExplain = false }) { Text("Got it") }
TextButton(onClick = { showExplain = false }) { Text(stringResource(R.string.bmt_got_it)) }
}
)
}
@@ -254,7 +244,7 @@ private fun MasterPill() {
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Text(
text = "MASTER",
text = stringResource(R.string.bmt_master),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
@@ -44,9 +44,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
/**
@@ -117,13 +119,13 @@ fun BridgePermissionChecklist(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Permissions",
text = stringResource(R.string.bpc_permissions),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Tap a row to grant or open Android Settings · Tap Test to verify.",
text = stringResource(R.string.bpc_permissions_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -132,16 +134,16 @@ fun BridgePermissionChecklist(
// ── Core bridge (required, both flavors) ──────────────────────
TierHeader(
label = "Core bridge",
subtitle = "Required for the agent to read and act on screen content.",
label = stringResource(R.string.bpc_core_bridge),
subtitle = stringResource(R.string.bpc_core_bridge_desc),
)
PermissionRow(
icon = Icons.Filled.Accessibility,
title = "Accessibility Service",
title = stringResource(R.string.bpc_accessibility),
subtitle = if (BuildFlavor.isSideload)
"Read screen content, dispatch taps/types"
stringResource(R.string.bpc_accessibility_desc_sideload)
else
"Read screen content for chat context",
stringResource(R.string.bpc_accessibility_desc_googleplay),
granted = status.accessibilityServiceEnabled,
onClick = { openAccessibilitySettings(context) },
onTest = onTestAccessibility,
@@ -153,11 +155,11 @@ fun BridgePermissionChecklist(
if (BuildFlavor.isSideload) {
PermissionRow(
icon = Icons.Filled.ScreenShare,
title = "Screen Capture",
title = stringResource(R.string.bpc_screen_capture),
subtitle = if (status.screenCapturePermitted)
"Granted for this session — agent can take screenshots"
stringResource(R.string.bpc_screen_capture_granted)
else
"Tap to grant — agent needs this for /screenshot",
stringResource(R.string.bpc_screen_capture_not_granted),
granted = status.screenCapturePermitted,
onClick = onRequestScreenCapture,
onTest = onTestScreenCapture,
@@ -170,8 +172,8 @@ fun BridgePermissionChecklist(
if (BuildFlavor.isSideload) {
PermissionRow(
icon = Icons.Filled.PictureInPicture,
title = "Display over other apps",
subtitle = "Status overlay while bridge is active",
title = stringResource(R.string.bpc_overlay),
subtitle = stringResource(R.string.bpc_overlay_desc),
granted = status.overlayPermitted,
onClick = { openOverlaySettings(context) },
onTest = onTestOverlay,
@@ -180,11 +182,11 @@ fun BridgePermissionChecklist(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
PermissionRow(
icon = Icons.Filled.Notifications,
title = "Notifications",
title = stringResource(R.string.bpc_notifications),
subtitle = if (status.notificationsPermitted)
"Bridge service notification can display"
stringResource(R.string.bpc_notifications_granted)
else
"Required for the bridge foreground service indicator",
stringResource(R.string.bpc_notifications_not_granted),
granted = status.notificationsPermitted,
onClick = onRequestNotifications,
)
@@ -193,13 +195,13 @@ fun BridgePermissionChecklist(
// ── Notification companion (optional, both flavors) ─────────────
TierSpacer()
TierHeader(
label = "Notification companion",
subtitle = "Optional. Lets the agent see incoming notifications for summaries and replies.",
label = stringResource(R.string.bpc_notification_companion),
subtitle = stringResource(R.string.bpc_notification_companion_desc),
)
PermissionRow(
icon = Icons.Filled.Notifications,
title = "Notification Listener",
subtitle = "Read notifications for agent summaries",
title = stringResource(R.string.bpc_notification_listener),
subtitle = stringResource(R.string.bpc_notification_listener_desc),
granted = status.notificationListenerPermitted,
onClick = { openNotificationListenerSettings(context) },
onTest = onTestNotificationListener,
@@ -209,21 +211,21 @@ fun BridgePermissionChecklist(
// ── Voice & camera (optional, both flavors) ────────────────────
TierSpacer()
TierHeader(
label = "Voice & camera",
subtitle = "Required when you use voice mode or attach camera media.",
label = stringResource(R.string.bpc_voice_camera),
subtitle = stringResource(R.string.bpc_voice_camera_desc),
)
PermissionRow(
icon = Icons.Filled.Mic,
title = "Microphone",
subtitle = "Required for voice mode (record + transcribe).",
title = stringResource(R.string.bpc_microphone),
subtitle = stringResource(R.string.bpc_microphone_desc),
granted = status.microphonePermitted,
onClick = onRequestMicrophone,
optional = true,
)
PermissionRow(
icon = Icons.Filled.CameraAlt,
title = "Camera",
subtitle = "Required to attach photos taken in-app.",
title = stringResource(R.string.bpc_camera),
subtitle = stringResource(R.string.bpc_camera_desc),
granted = status.cameraPermitted,
onClick = onRequestCamera,
optional = true,
@@ -233,37 +235,37 @@ fun BridgePermissionChecklist(
if (BuildFlavor.isSideload) {
TierSpacer()
TierHeader(
label = "Sideload features",
subtitle = "Optional. Powers contact lookup, SMS, dialer, and location tools.",
label = stringResource(R.string.bpc_sideload_features),
subtitle = stringResource(R.string.bpc_sideload_features_desc),
)
PermissionRow(
icon = Icons.Filled.Contacts,
title = "Contacts",
subtitle = "Resolve names to phone numbers (android_search_contacts).",
title = stringResource(R.string.bpc_contacts),
subtitle = stringResource(R.string.bpc_contacts_desc),
granted = status.contactsPermitted,
onClick = onRequestContacts,
optional = true,
)
PermissionRow(
icon = Icons.Filled.Sms,
title = "SMS",
subtitle = "Send text messages directly (android_send_sms).",
title = stringResource(R.string.bpc_sms),
subtitle = stringResource(R.string.bpc_sms_desc),
granted = status.smsPermitted,
onClick = onRequestSms,
optional = true,
)
PermissionRow(
icon = Icons.Filled.Call,
title = "Phone",
subtitle = "Place calls directly without opening the dialer (android_call).",
title = stringResource(R.string.bpc_phone),
subtitle = stringResource(R.string.bpc_phone_desc),
granted = status.phonePermitted,
onClick = onRequestPhone,
optional = true,
)
PermissionRow(
icon = Icons.Filled.LocationOn,
title = "Location",
subtitle = "Last-known GPS fix for context-aware queries (android_location).",
title = stringResource(R.string.bpc_location),
subtitle = stringResource(R.string.bpc_location_desc),
granted = status.locationPermitted,
onClick = onRequestLocation,
optional = true,
@@ -329,7 +331,7 @@ private fun OptionalBadge() {
// badge drops to the next line cleanly when space is tight, instead
// of compressing awkwardly in-line.
Text(
text = "Optional",
text = stringResource(R.string.bpc_optional),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
softWrap = false,
@@ -403,7 +405,7 @@ private fun PermissionRow(
),
) {
Text(
text = "Test",
text = stringResource(R.string.bpc_test),
style = MaterialTheme.typography.labelSmall,
)
}
@@ -412,9 +414,9 @@ private fun PermissionRow(
// Optional rows that are *not* granted use a neutral tint instead of
// error red so users don't perceive them as urgent action items.
val (statusTint, statusDescription) = when {
granted -> Color(0xFF4CAF50) to "Granted"
optional -> MaterialTheme.colorScheme.onSurfaceVariant to "Not granted (optional)"
else -> MaterialTheme.colorScheme.error to "Not granted"
granted -> Color(0xFF4CAF50) to stringResource(R.string.bpc_granted)
optional -> MaterialTheme.colorScheme.onSurfaceVariant to stringResource(R.string.bpc_not_granted_optional)
else -> MaterialTheme.colorScheme.error to stringResource(R.string.bpc_not_granted)
}
Icon(
imageVector = if (granted) Icons.Filled.CheckCircle
@@ -23,7 +23,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.BridgeSafetySettings
@@ -90,7 +92,7 @@ fun BridgeSafetySummaryCard(
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "Safety",
text = stringResource(R.string.bssc_safety),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -98,21 +100,21 @@ fun BridgeSafetySummaryCard(
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Manage",
contentDescription = stringResource(R.string.bssc_manage),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SafetySummaryRow(
label = "Apps the agent can't touch",
label = stringResource(R.string.bssc_blocked_apps),
value = "${settings.blocklist.size}",
)
SafetySummaryRow(
label = "Words that always ask first",
label = stringResource(R.string.bssc_destructive_verbs),
value = "${settings.destructiveVerbs.size}",
)
SafetySummaryRow(
label = "Turns itself off when idle",
label = stringResource(R.string.bssc_auto_disable),
value = if (autoDisableAtMs != null) {
val remainMs = (autoDisableAtMs - nowMs).coerceAtLeast(0L)
val remainMin = (remainMs / 60_000L).toInt()
@@ -124,7 +126,7 @@ fun BridgeSafetySummaryCard(
)
Text(
text = "These guardrails keep Hermes in bounds. Tap to adjust.",
text = stringResource(R.string.bssc_guardrails_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -14,9 +14,11 @@ import androidx.compose.material3.Text
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.BridgeStatus
/**
@@ -53,7 +55,7 @@ fun BridgeStatusCard(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Status",
text = stringResource(R.string.bsc_status),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
@@ -64,7 +66,7 @@ fun BridgeStatusCard(
isConnecting = false,
)
Text(
text = if (isConnected) "Connected" else "Disconnected",
text = if (isConnected) stringResource(R.string.bsc_connected) else stringResource(R.string.bsc_disconnected),
style = MaterialTheme.typography.labelMedium,
color = if (isConnected) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -75,22 +77,21 @@ fun BridgeStatusCard(
if (status == null) {
Text(
text = "Bridge runtime not yet reporting status. Enable " +
"Agent Control above to begin receiving device telemetry.",
text = stringResource(R.string.bsc_no_status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
StatusKeyValue("Device", status.deviceName)
StatusKeyValue(stringResource(R.string.bmt_device), status.deviceName)
StatusKeyValue(
"Battery",
status.batteryPercent?.let { "$it%" } ?: "Unknown"
stringResource(R.string.bmt_battery),
status.batteryPercent?.let { "$it%" } ?: stringResource(R.string.bsc_unknown)
)
StatusKeyValue("Screen", if (status.screenOn) "ON" else "OFF")
StatusKeyValue("Current app", status.currentApp ?: "—")
StatusKeyValue(stringResource(R.string.bmt_screen), if (status.screenOn) stringResource(R.string.bmt_on) else stringResource(R.string.bmt_off))
StatusKeyValue(stringResource(R.string.bmt_current_app), status.currentApp ?: "—")
StatusKeyValue(
"Accessibility service",
if (status.accessibilityEnabled) "Enabled" else "Disabled"
stringResource(R.string.bsc_accessibility_service),
if (status.accessibilityEnabled) stringResource(R.string.bsc_enabled) else stringResource(R.string.bsc_disabled)
)
}
}
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.components
import android.widget.Toast
@@ -31,10 +33,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.MediaSaver
import kotlinx.coroutines.launch
@@ -152,6 +156,10 @@ fun ChatImageViewer(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
IconButton(
onClick = {
scope.launch {
@@ -159,7 +167,7 @@ fun ChatImageViewer(
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
if (bytes == null) {
toast(context, "Couldn't load this image")
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
@@ -168,8 +176,10 @@ fun ChatImageViewer(
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = "Share")
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
@@ -177,13 +187,13 @@ fun ChatImageViewer(
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
busy = false
toast(context, "Couldn't load this image")
toast(context, errorMsg)
return@launch
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, "Saved to ${result.location}")
toast(context, savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
@@ -192,17 +202,17 @@ fun ChatImageViewer(
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, "Save failed: ${result.message}")
toast(context, failedFmt.format(result.message))
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = "Save")
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = "Close")
Icon(Icons.Filled.Close, contentDescription = cdClose)
}
}
}
@@ -57,8 +57,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.purpleGlow
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
@@ -213,7 +215,7 @@ fun ChatInputBar(
exit = fadeOut(),
) {
Text(
text = "Live voice conversation",
text = stringResource(R.string.chat_input_live_voice_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
@@ -291,15 +293,15 @@ fun ChatInputBar(
.clip(CircleShape)
.combinedClickable(
onClick = { attachMenuExpanded = true },
onClickLabel = "Add attachment",
onClickLabel = stringResource(R.string.chat_input_add_attachment),
onLongClick = onLongPressAttach,
onLongClickLabel = "Browse commands",
onLongClickLabel = stringResource(R.string.chat_input_browse_commands),
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add attachment; hold for commands",
contentDescription = stringResource(R.string.chat_input_add_attachment_hold),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -308,7 +310,7 @@ fun ChatInputBar(
onDismissRequest = { attachMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text("Photos") },
text = { Text(stringResource(R.string.chat_input_photos)) },
leadingIcon = {
Icon(Icons.Filled.PhotoLibrary, contentDescription = null)
},
@@ -318,7 +320,7 @@ fun ChatInputBar(
},
)
DropdownMenuItem(
text = { Text("Files") },
text = { Text(stringResource(R.string.chat_input_files)) },
leadingIcon = {
Icon(Icons.Filled.InsertDriveFile, contentDescription = null)
},
@@ -328,7 +330,7 @@ fun ChatInputBar(
},
)
DropdownMenuItem(
text = { Text("Camera") },
text = { Text(stringResource(R.string.chat_input_camera)) },
leadingIcon = {
Icon(Icons.Filled.PhotoCamera, contentDescription = null)
},
@@ -338,7 +340,7 @@ fun ChatInputBar(
},
)
DropdownMenuItem(
text = { Text("Paste image") },
text = { Text(stringResource(R.string.chat_input_paste_image)) },
leadingIcon = {
Icon(Icons.Filled.ContentPaste, contentDescription = null)
},
@@ -393,7 +395,7 @@ fun ChatInputBar(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
contentDescription = stringResource(R.string.chat_input_send_message),
tint = if (enabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -403,8 +405,8 @@ fun ChatInputBar(
IconButton(onClick = onVoice) {
Icon(
imageVector = Icons.Filled.GraphicEq,
contentDescription = if (voiceReady) "Start voice conversation"
else "Voice conversation — setup needed",
contentDescription = if (voiceReady) stringResource(R.string.chat_input_start_voice)
else stringResource(R.string.chat_input_voice_setup_needed),
tint = MaterialTheme.colorScheme.primary,
)
}
@@ -431,7 +433,7 @@ fun ChatInputBar(
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = "Stop streaming",
contentDescription = stringResource(R.string.chat_input_stop_streaming),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
@@ -444,7 +446,7 @@ fun ChatInputBar(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Steer the response",
contentDescription = stringResource(R.string.chat_input_steer_response),
tint = MaterialTheme.colorScheme.tertiary,
)
}
@@ -456,7 +458,7 @@ fun ChatInputBar(
Box {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Queue message",
contentDescription = stringResource(R.string.chat_input_queue_message),
tint = MaterialTheme.colorScheme.tertiary,
)
Icon(
@@ -43,9 +43,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* A slash command entry — built-in, personality, server skill, or (on the
* gateway transport) a server-catalog command from `commands.catalog`.
@@ -140,11 +141,11 @@ fun CommandPalette(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Commands",
text = stringResource(R.string.command_palette_header),
style = MaterialTheme.typography.titleMedium
)
Text(
text = "${filtered.size} available",
text = stringResource(R.string.command_palette_count, filtered.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -159,7 +160,7 @@ fun CommandPalette(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Search commands...") },
placeholder = { Text(stringResource(R.string.command_palette_search)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -172,7 +173,7 @@ fun CommandPalette(
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
contentDescription = stringResource(R.string.command_palette_cd_clear),
modifier = Modifier.size(20.dp)
)
}
@@ -202,7 +203,7 @@ fun CommandPalette(
FilterChip(
selected = selectedCategory == null,
onClick = { selectedCategory = null },
label = { Text("All") },
label = { Text(stringResource(R.string.command_palette_filter_all)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
@@ -233,8 +234,8 @@ fun CommandPalette(
modifier = Modifier.padding(horizontal = 8.dp)
) {
Text(
text = if (categoriesExpanded) "Show less"
else "Show all (${categories.size})",
text = if (categoriesExpanded) stringResource(R.string.command_palette_show_less)
else stringResource(R.string.command_palette_show_all, categories.size),
style = MaterialTheme.typography.labelMedium
)
}
@@ -283,7 +284,7 @@ fun CommandPalette(
contentAlignment = Alignment.Center
) {
Text(
text = "No commands match your search",
text = stringResource(R.string.command_palette_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -24,9 +24,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
@Composable
@@ -36,16 +38,18 @@ fun CompactToolCall(
) {
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
isPreparing -> "preparing"
else -> "running"
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
isPreparing -> stringResource(R.string.tool_preparing_a11y)
else -> stringResource(R.string.tool_progress_status_running)
}
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
String.format("%.1fs", seconds)
} else null
val durationDescription = duration?.let { stringResource(R.string.tool_duration_a11y, it) }.orEmpty()
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription)
Row(
modifier = modifier
@@ -53,7 +57,7 @@ fun CompactToolCall(
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics {
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
contentDescription = toolDescription
},
verticalAlignment = Alignment.CenterVertically
) {
@@ -91,7 +95,7 @@ fun CompactToolCall(
).value
Icon(
imageVector = Icons.Filled.MoreHoriz,
contentDescription = "Preparing",
contentDescription = stringResource(R.string.tool_preparing_a11y),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)
)
@@ -106,7 +110,7 @@ fun CompactToolCall(
toolCall.success == true -> {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Completed",
contentDescription = stringResource(R.string.tool_completed_a11y),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -114,7 +118,7 @@ fun CompactToolCall(
else -> {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Failed",
contentDescription = stringResource(R.string.tool_failed_a11y),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.error
)
@@ -12,8 +12,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* Compact top-bar chip showing the currently-active Hermes connection.
@@ -40,7 +42,7 @@ fun ConnectionChip(
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Switch connection",
contentDescription = stringResource(R.string.cd_switch_connection),
modifier = Modifier,
)
}
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurity
@@ -79,7 +81,7 @@ fun ConnectionSecuritySheet(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = "Connection security",
text = stringResource(R.string.security_sheet_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
@@ -93,8 +95,7 @@ fun ConnectionSecuritySheet(
if (security.surfaces.isEmpty()) {
Text(
text = "No active route yet. Connect to a server to see how each " +
"part of the connection is protected.",
text = stringResource(R.string.security_sheet_no_route),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -104,14 +105,17 @@ fun ConnectionSecuritySheet(
HorizontalDivider()
Text(
text = explainer(security.level),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val explainerTextRes = explainerResOrNull(security.level)
if (explainerTextRes != null) {
Text(
text = stringResource(explainerTextRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = { uriHandler.openUri(LEARN_MORE_URL) }) {
Text("Learn about connection security →")
Text(stringResource(R.string.security_sheet_learn_more))
}
}
}
@@ -145,17 +149,10 @@ private fun SurfaceSecurityRow(surface: SurfaceSecurity) {
}
}
private fun explainer(level: ConnectionSecurityLevel): String = when (level) {
ConnectionSecurityLevel.Tls ->
"Encrypted with TLS. The server's certificate is pinned on first connect."
ConnectionSecurityLevel.Overlay ->
"Encrypted by your overlay network (e.g. Tailscale/WireGuard), not TLS. " +
"Cert pinning applies only to TLS routes."
ConnectionSecurityLevel.Mixed ->
"Some parts of this connection are encrypted and some are plain. The app " +
"prefers a secure route when one is reachable."
ConnectionSecurityLevel.Plain ->
"Not encrypted. Only safe on a network you fully trust — anyone in between " +
"could read this traffic."
ConnectionSecurityLevel.Unknown -> ""
private fun explainerResOrNull(level: ConnectionSecurityLevel): Int? = when (level) {
ConnectionSecurityLevel.Tls -> R.string.security_sheet_explain_tls
ConnectionSecurityLevel.Overlay -> R.string.security_sheet_explain_overlay
ConnectionSecurityLevel.Mixed -> R.string.security_sheet_explain_mixed
ConnectionSecurityLevel.Plain -> R.string.security_sheet_explain_plain
ConnectionSecurityLevel.Unknown -> null
}
@@ -23,8 +23,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
/**
@@ -61,7 +63,7 @@ fun ConnectionSwitcherSheet(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Switch connection",
text = stringResource(R.string.conn_switcher_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
@@ -70,7 +72,7 @@ fun ConnectionSwitcherSheet(
// Defensive: the legacy migration should always seed connection 0,
// but fall back to a Manage-only state if the list is empty.
Text(
text = "No connections yet",
text = stringResource(R.string.conn_switcher_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 12.dp),
@@ -79,7 +81,7 @@ fun ConnectionSwitcherSheet(
onClick = onManageConnections,
modifier = Modifier.fillMaxWidth(),
) {
Text("Manage connections…")
Text(stringResource(R.string.conn_switcher_manage))
}
} else {
LazyColumn(
@@ -105,7 +107,7 @@ fun ConnectionSwitcherSheet(
onClick = onManageConnections,
modifier = Modifier.fillMaxWidth(),
) {
Text("Manage connections…")
Text(stringResource(R.string.conn_switcher_manage))
}
}
}
@@ -119,11 +121,9 @@ private fun ConnectionRow(
onClick: () -> Unit,
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val statusLine = if (connection.pairedAt == null) {
"$hostname • Hermes"
} else {
"$hostname • Paired"
}
val hermesStatus = stringResource(R.string.conn_info_hostname_hermes, hostname)
val pairedStatus = stringResource(R.string.conn_info_hostname_paired, hostname)
val statusLine = if (connection.pairedAt == null) hermesStatus else pairedStatus
Row(
modifier = Modifier
File diff suppressed because it is too large Load Diff
@@ -35,9 +35,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.roundToInt
@@ -120,13 +122,15 @@ private fun ContextMeterBarContent(
}
val label = "$percent%$tokenSuffix"
val contextUsedDesc = stringResource(R.string.cd_context_used, percent)
Row(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 12.dp, vertical = 3.dp)
.semantics {
contentDescription = "Context $percent% used" +
contentDescription = contextUsedDesc +
if (tokenSuffix.isNotEmpty()) ", ${fmtTokens(usedTokens!!)} of ${fmtTokens(maxTokens!!)} tokens" else ""
},
verticalAlignment = Alignment.CenterVertically,
@@ -158,7 +162,7 @@ private fun ContextMeterBarContent(
Spacer(Modifier.width(6.dp))
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "View injected context",
contentDescription = stringResource(R.string.cd_view_injected),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(13.dp),
)
@@ -38,12 +38,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.CrashReport
import com.hermesandroid.relay.util.CrashReporter
import com.hermesandroid.relay.util.IssueReport
@@ -111,7 +113,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
)
Spacer(Modifier.width(12.dp))
Text(
text = "Hermes-Relay closed unexpectedly",
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
@@ -119,8 +121,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
Spacer(Modifier.height(8.dp))
Text(
text = "The last session crashed. Sending this report helps get it fixed — " +
"nothing is sent automatically.",
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -155,13 +156,13 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text("Dismiss") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, "Crash report copied")
},
) { Text("Copy") }
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
@@ -180,7 +181,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
}
onDismiss()
},
) { Text("Share") }
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
// Copy the FULL report first; the URL only carries the
@@ -194,7 +195,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
)
onDismiss()
},
) { Text("Report") }
) { Text(stringResource(R.string.common_report)) }
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.material3.Text
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
@@ -32,6 +33,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
/**
@@ -76,7 +78,7 @@ fun DemoModeBanner(
modifier = Modifier.size(16.dp),
)
Text(
text = "Demo mode — sample data, not connected. Connect →",
text = stringResource(R.string.dmb_demo_mode),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = on,
@@ -125,19 +127,19 @@ fun DemoUnavailableContent(
modifier = Modifier.size(40.dp),
)
Text(
text = "This is a demo",
text = stringResource(R.string.dmb_this_is_demo),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Connect your Hermes server to use $feature.",
text = stringResource(R.string.dmb_connect_feature, feature),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Button(onClick = onConnect) {
Text("Connect")
Text(stringResource(R.string.dmb_connect))
}
}
}
@@ -35,6 +35,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* Phase 3 — safety-rails `bridge-safety-rails`
@@ -106,7 +108,7 @@ fun DestructiveVerbConfirmDialog(
tint = Color(0xFFFFA726),
)
Text(
text = "Confirm destructive action",
text = stringResource(R.string.destructive_confirm_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
@@ -135,7 +137,7 @@ fun DestructiveVerbConfirmDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = fullText.ifBlank { "(no text)" },
text = fullText.ifBlank { stringResource(R.string.destructive_no_text) },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -143,7 +145,7 @@ fun DestructiveVerbConfirmDialog(
}
Text(
text = "If you didn't expect this, tap Deny.",
text = stringResource(R.string.destructive_confirm_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -184,7 +186,7 @@ fun DestructiveVerbConfirmDialog(
modifier = Modifier.weight(1f),
onClick = onDeny,
) {
Text("Deny")
Text(stringResource(R.string.destructive_confirm_deny))
}
// Allow is intentionally lower-emphasis (amber caution, not a
// loud red CTA) so proceeding with a risky action never reads
@@ -196,7 +198,7 @@ fun DestructiveVerbConfirmDialog(
contentColor = Color(0xFFE65100),
),
) {
Text("Allow this action")
Text(stringResource(R.string.destructive_confirm_allow))
}
}
}
@@ -204,12 +206,14 @@ fun DestructiveVerbConfirmDialog(
}
}
@Composable
private fun labelFor(method: String): String = when (method) {
"/tap_text" -> "Target text (tap)"
"/type" -> "Text to type"
else -> "Payload"
"/tap_text" -> stringResource(R.string.destructive_label_tap_text)
"/type" -> stringResource(R.string.destructive_label_type)
else -> stringResource(R.string.destructive_label_payload)
}
@Composable
private fun verbPhrase(method: String, verb: String): String {
val action = when (method) {
"/tap_text" -> "tap a button containing"
@@ -236,7 +240,7 @@ fun BridgeStatusOverlayChip(unattended: Boolean = false) {
// the device. Default red dot + "Hermes active" preserves the v0.4
// appearance for the regular bridge-active path.
val dotColor = if (unattended) Color(0xFFFFA000) else Color(0xFFE53935)
val label = if (unattended) "Unattended ON" else "Hermes active"
val label = if (unattended) stringResource(R.string.destructive_chip_unattended) else stringResource(R.string.destructive_chip_active)
Box(
modifier = Modifier
.background(
@@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -40,6 +41,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.BuildConfig
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.util.DiagnosticIssuePrefill
@@ -133,7 +135,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
}
} else {
Text(
text = "No further detail captured for this entry.",
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -146,7 +148,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text("This is a routine log entry — telling us what looked wrong turns it into an answerable report.")
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
@@ -159,13 +161,13 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text("Close") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
toast(context, "Diagnostic copied")
},
) { Text("Copy") }
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
@@ -179,7 +181,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
toast(context, "Copied — no app found to share to")
}
},
) { Text("Export") }
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
@@ -207,7 +209,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
else "Copied — no browser found to open GitHub",
)
},
) { Text("Report") }
) { Text(stringResource(R.string.common_report)) }
}
}
}
@@ -31,10 +31,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -43,7 +45,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@Composable
fun DiagnosticsLogPanel(
modifier: Modifier = Modifier,
title: String = "Recent activity",
title: String? = null,
categories: Set<DiagnosticCategory>? = null,
limit: Int = 8,
showCategory: Boolean = false,
@@ -51,6 +53,7 @@ fun DiagnosticsLogPanel(
showSeverityFilter: Boolean = false,
) {
val entries by DiagnosticsLog.entries.collectAsState()
val resolvedTitle = title ?: stringResource(R.string.diagnostics_title)
// Self-contained detail-view state — tapping a row opens DiagnosticDetailDialog.
// No nav route; nothing to wire in RelayApp.
@@ -74,13 +77,13 @@ fun DiagnosticsLogPanel(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
text = resolvedTitle,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (showClear && entries.isNotEmpty()) {
TextButton(onClick = { DiagnosticsLog.clear() }) {
Text("Clear")
Text(stringResource(R.string.diagnostics_clear))
}
}
}
@@ -90,7 +93,7 @@ fun DiagnosticsLogPanel(
FilterChip(
selected = severityFilter == null,
onClick = { severityFilter = null },
label = { Text("All") },
label = { Text(stringResource(R.string.diagnostics_all)) },
)
DiagnosticSeverity.entries.forEach { sev ->
FilterChip(
@@ -104,7 +107,7 @@ fun DiagnosticsLogPanel(
if (visible.isEmpty()) {
Text(
text = "No recent activity",
text = stringResource(R.string.diagnostics_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -109,6 +109,12 @@ data class ThinkingIndicatorConfig(
/** Chat-root provided streaming-indicator config; see [ThinkingIndicatorConfig]. */
val LocalThinkingIndicator = compositionLocalOf { ThinkingIndicatorConfig() }
internal fun shouldAnimateDotMatrix(
appAnimationsEnabled: Boolean,
osAnimationsEnabled: Boolean,
touchExplorationEnabled: Boolean,
): Boolean = appAnimationsEnabled && osAnimationsEnabled && !touchExplorationEnabled
/**
* A compact dot-matrix "thinking" animation — a small grid of dots evoking a
* dot-matrix / LED display (the dot-anime-react concept reimplemented natively
@@ -142,10 +148,15 @@ fun DotMatrixIndicator(
fps: Int = 30,
animated: Boolean = true,
) {
val motion = rememberAccessibleMotionState()
val phase = rememberAmbientPhase(
periodMillis = pattern.periodMillis,
fps = fps,
running = animated,
running = shouldAnimateDotMatrix(
appAnimationsEnabled = animated,
osAnimationsEnabled = motion.osAnimations,
touchExplorationEnabled = motion.touchExploration,
),
)
val gridWidth = columnSpacing * (columns - 1)
val gridHeight = rowSpacing * (rows - 1)
@@ -44,8 +44,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.SurfaceSecurityKind
@@ -114,19 +116,25 @@ fun EndpointsCard(
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
) {
// Pre-resolve strings
val noRoutesStoredText = stringResource(R.string.endpoints_no_routes_stored)
val addRouteText = stringResource(R.string.endpoints_add_route)
val resolvingText = stringResource(R.string.endpoints_resolving)
val manualUntilDisconnectText = stringResource(R.string.endpoints_manual_until_disconnect)
val preferredText = stringResource(R.string.endpoints_preferred)
val automaticText = stringResource(R.string.endpoints_automatic)
val currentRouteText = stringResource(R.string.endpoints_current_route)
if (endpoints.isEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "No route candidates stored for this connection yet. " +
"Add a remote route (Tailscale, public URL) for automatic " +
"switching when the phone leaves this network — or scan a " +
"v3 pairing QR (Hermes 0.4.2+) if you use Relay.",
text = noRoutesStoredText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onAddRoute != null) {
TextButton(onClick = onAddRoute) {
Text("Add route")
Text(addRouteText)
}
}
}
@@ -139,13 +147,20 @@ fun EndpointsCard(
val manualSwitchActive = manualOverrideRole != null &&
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
// Pre-resolve cancel manual switch string (needs preferredRole which may be null)
val cancelManualSwitchText = stringResource(R.string.endpoints_cancel_manual_switch)
val stopPreferringText = stringResource(R.string.endpoints_stop_preferring)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}" + when {
manualSwitchActive -> " · manual (until disconnect)"
manualOverrideRole != null -> " · preferred"
else -> " · automatic"
},
text = currentRouteText.format(
activeEndpoint?.displayLabel() ?: resolvingText,
when {
manualSwitchActive -> manualUntilDisconnectText
manualOverrideRole != null -> preferredText
else -> automaticText
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -175,22 +190,20 @@ fun EndpointsCard(
if (onAddRoute != null) {
HorizontalDivider()
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
Text("Add route")
Text(addRouteText)
}
}
if (manualSwitchActive) {
HorizontalDivider()
TextButton(onClick = onCancelUseNow, modifier = Modifier.fillMaxWidth()) {
Text(
"Cancel manual switch (back to ${preferredRole ?: "automatic"})",
)
Text(cancelManualSwitchText.format(preferredRole ?: automaticText))
}
}
if (preferredRole != null) {
HorizontalDivider()
TextButton(onClick = onClearPreferred, modifier = Modifier.fillMaxWidth()) {
Text("Stop preferring $preferredRole (back to automatic)")
Text(stopPreferringText.format(preferredRole))
}
}
}
@@ -218,6 +231,7 @@ private fun EndpointRow(
var pinDialogText by remember { mutableStateOf<String?>(null) }
var confirmRemove by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -246,11 +260,11 @@ private fun EndpointRow(
)
SurfaceSecurityGlyph(kind = candidate.routeSecurityKind())
if (isActive) {
ActiveChip()
ActiveChip(stringResource(R.string.endpoints_active))
} else if (isPreferred) {
PreferredChip()
PreferredChip(stringResource(R.string.endpoints_preferred_chip))
} else {
FallbackChip()
FallbackChip(stringResource(R.string.endpoints_fallback))
}
if (!candidate.isKnownRole()) {
// Show the raw role for custom-VPN entries so users
@@ -277,18 +291,18 @@ private fun EndpointRow(
)
when {
isProbing -> Text(
text = "Checking…",
text = stringResource(R.string.endpoints_checking),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome == null -> Unit // never probed — say nothing
outcome.reachable -> Text(
text = "Reachable",
text = stringResource(R.string.endpoints_reachable),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
else -> Text(
text = "Unreachable — ${outcome.detail ?: "no detail"}",
text = stringResource(R.string.endpoints_unreachable, outcome.detail ?: stringResource(R.string.endpoints_unreachable_no_detail)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
@@ -304,14 +318,14 @@ private fun EndpointRow(
// sticky "Prefer this route" lives in the menu below.
if (!isActive) {
TextButton(onClick = onUseNow) {
Text("Use now")
Text(stringResource(R.string.endpoints_use_now))
}
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Endpoint actions",
contentDescription = stringResource(R.string.endpoints_actions),
)
}
DropdownMenu(
@@ -322,13 +336,13 @@ private fun EndpointRow(
text = {
Column {
Text(
if (isPreferred) "Stop preferring" else "Prefer this route",
text = if (isPreferred) stringResource(R.string.endpoints_stop_preferring_menu) else stringResource(R.string.endpoints_prefer_this_route),
)
Text(
text = if (isPreferred) {
"Back to automatic choice"
stringResource(R.string.endpoints_back_to_automatic)
} else {
"Always try this route first"
stringResource(R.string.endpoints_always_try_first)
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -341,26 +355,25 @@ private fun EndpointRow(
},
)
DropdownMenuItem(
text = { Text("Probe now") },
text = { Text(stringResource(R.string.endpoints_probe_now)) },
onClick = {
menuOpen = false
onProbeNow()
},
)
DropdownMenuItem(
text = { Text("View pin") },
text = { Text(stringResource(R.string.endpoints_view_pin)) },
onClick = {
menuOpen = false
scope.launch {
pinDialogText = onViewPin(candidate)
?: "No pin recorded yet — the phone will " +
"record one on first TOFU connect."
?: noPinRecordedText
}
},
)
if (onEdit != null) {
DropdownMenuItem(
text = { Text("Edit route") },
text = { Text(stringResource(R.string.endpoints_edit_route)) },
onClick = {
menuOpen = false
onEdit()
@@ -369,7 +382,7 @@ private fun EndpointRow(
}
if (onRemove != null) {
DropdownMenuItem(
text = { Text("Remove route") },
text = { Text(stringResource(R.string.endpoints_remove_route)) },
onClick = {
menuOpen = false
confirmRemove = true
@@ -384,11 +397,10 @@ private fun EndpointRow(
if (confirmRemove && onRemove != null) {
AlertDialog(
onDismissRequest = { confirmRemove = false },
title = { Text("Remove ${candidate.displayLabel()} route?") },
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
text = {
Text(
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
"probed as a fallback. You can add it back any time.",
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"),
style = MaterialTheme.typography.bodySmall,
)
},
@@ -398,10 +410,10 @@ private fun EndpointRow(
confirmRemove = false
onRemove()
},
) { Text("Remove") }
) { Text(stringResource(R.string.endpoints_remove)) }
},
dismissButton = {
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
TextButton(onClick = { confirmRemove = false }) { Text(stringResource(R.string.endpoints_cancel)) }
},
)
}
@@ -409,7 +421,7 @@ private fun EndpointRow(
pinDialogText?.let { body ->
AlertDialog(
onDismissRequest = { pinDialogText = null },
title = { Text("TOFU pin · ${candidate.api.host}") },
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) },
text = {
Text(
text = body,
@@ -418,14 +430,14 @@ private fun EndpointRow(
)
},
confirmButton = {
TextButton(onClick = { pinDialogText = null }) { Text("Close") }
TextButton(onClick = { pinDialogText = null }) { Text(stringResource(R.string.endpoints_close)) }
},
)
}
}
@Composable
private fun ActiveChip() {
private fun ActiveChip(label: String) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -441,7 +453,7 @@ private fun ActiveChip() {
)
Spacer(Modifier.size(4.dp))
Text(
text = "Active",
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -449,7 +461,7 @@ private fun ActiveChip() {
}
@Composable
private fun PreferredChip() {
private fun PreferredChip(label: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -457,7 +469,7 @@ private fun PreferredChip() {
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = "Preferred",
text = label,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFFB26A00),
)
@@ -471,7 +483,7 @@ private fun PreferredChip() {
* as "available fallback" not "something is happening here".
*/
@Composable
private fun FallbackChip() {
private fun FallbackChip(label: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -484,7 +496,7 @@ private fun FallbackChip() {
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = "Fallback",
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -560,13 +572,16 @@ fun RouteEditorDialog(
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text(if (original == null) "Add route" else "Edit route") },
title = {
Text(
if (original == null) stringResource(R.string.endpoints_add_route_title)
else stringResource(R.string.endpoints_edit_route_title)
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "A fallback route the phone switches to when the " +
"primary stops answering — e.g. your server's " +
"Tailscale or public URL.",
text = stringResource(R.string.endpoints_route_editor_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -574,25 +589,25 @@ fun RouteEditorDialog(
FilterChip(
selected = selectedRole == "tailscale",
onClick = { selectedRole = "tailscale" },
label = { Text("Tailscale") },
label = { Text(stringResource(R.string.endpoints_tailscale)) },
)
FilterChip(
selected = selectedRole == "public",
onClick = { selectedRole = "public" },
label = { Text("Public") },
label = { Text(stringResource(R.string.endpoints_public)) },
)
FilterChip(
selected = selectedRole == CUSTOM_ROLE,
onClick = { selectedRole = CUSTOM_ROLE },
label = { Text("Custom") },
label = { Text(stringResource(R.string.endpoints_custom)) },
)
}
if (selectedRole == CUSTOM_ROLE) {
OutlinedTextField(
value = customRole,
onValueChange = { customRole = it },
label = { Text("Route name") },
placeholder = { Text("wireguard-home") },
label = { Text(stringResource(R.string.endpoints_route_name)) },
placeholder = { Text(stringResource(R.string.endpoints_route_name_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -616,23 +631,21 @@ fun RouteEditorDialog(
url = it
errorText = null
},
label = { Text("API server URL or host") },
placeholder = { Text("100.64.0.1 or http://host:8642") },
label = { Text(stringResource(R.string.endpoints_api_url_host)) },
placeholder = { Text(stringResource(R.string.endpoints_url_host_placeholder)) },
singleLine = true,
isError = errorText != null,
supportingText = {
val supportingBlank = stringResource(R.string.endpoints_url_supporting_blank)
val supportingEnter = stringResource(R.string.endpoints_url_supporting_enter)
Text(
text = errorText ?: when {
url.isBlank() ->
"Use the API server port (8642 by default) — " +
"not the dashboard's 9119. http:// is " +
"assumed unless you type https://."
supportingBlank
previewCandidate != null ->
"Will save: ${previewCandidate.api.url} — relay " +
"and dashboard URLs are derived from the host"
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
else ->
"Enter a host/IP or an http(s):// URL " +
"(API port 8642, not dashboard 9119)"
supportingEnter
},
)
},
@@ -655,10 +668,17 @@ fun RouteEditorDialog(
}
}
},
) { Text(if (saving) "Saving…" else "Save") }
) {
Text(
if (saving) stringResource(R.string.endpoints_saving)
else stringResource(R.string.endpoints_save)
)
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
TextButton(onClick = onDismiss, enabled = !saving) {
Text(stringResource(R.string.endpoints_cancel))
}
},
)
}
@@ -30,6 +30,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.TerminalViewModel.SpecialKey
/**
@@ -88,20 +90,20 @@ fun ExtraKeysToolbar(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ToolbarKey(label = "ESC", onClick = onEsc)
ToolbarKey(label = "TAB", onClick = onTab)
ToolbarKey(label = "CTRL", active = ctrlActive, onClick = onCtrlToggle)
ToolbarKey(label = "ALT", active = altActive, onClick = onAltToggle)
ToolbarKey(label = stringResource(R.string.extra_keys_esc), onClick = onEsc)
ToolbarKey(label = stringResource(R.string.extra_keys_tab), onClick = onTab)
ToolbarKey(label = stringResource(R.string.extra_keys_ctrl), active = ctrlActive, onClick = onCtrlToggle)
ToolbarKey(label = stringResource(R.string.extra_keys_alt), active = altActive, onClick = onAltToggle)
// Clipboard + keyboard cluster — selecting/copying/pasting and
// raising the soft keyboard are all unreliable through long-press
// inside an Android WebView, so these explicit keys are the
// dependable path.
onCopy?.let { copy ->
ToolbarKey(label = "COPY", onClick = copy)
ToolbarKey(label = stringResource(R.string.extra_keys_copy), onClick = copy)
}
onPaste?.let { paste ->
ToolbarKey(label = "PASTE", onClick = paste)
ToolbarKey(label = stringResource(R.string.extra_keys_paste), onClick = paste)
}
onToggleKeyboard?.let { toggle ->
ToolbarKey(label = "⌨", onClick = toggle) // show/hide soft keyboard
@@ -0,0 +1,439 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayProcess
/**
* Composer-adjacent summary of upstream Hermes processes for the active chat.
* The process registry is session-scoped, so this deliberately does not live
* in the global session/navigation drawer.
*/
@Composable
fun GatewayBackgroundProcessStrip(
processes: List<GatewayProcess>,
loading: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Initial/switch refreshes are silent. The strip appears only after the
// session actually owns a process, avoiding a transient "Checking" row on
// every ordinary chat open.
if (processes.isEmpty()) return
val running = processes.count { it.isRunning }
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
val displayedCount = if (running > 0) running else processes.size
val status = when {
running > 0 -> "$running running"
failed > 0 -> "$failed failed"
else -> "Complete"
}
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 3.dp)
.heightIn(min = 48.dp)
.semantics {
contentDescription =
"Background processes, $status. Open current chat activity."
stateDescription = status
}
.clickable(
onClickLabel = "Open background processes",
onClick = onClick,
),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f),
tonalElevation = 1.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (running > 0 || loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(
imageVector = if (failed > 0) Icons.Filled.ErrorOutline else Icons.Filled.CheckCircle,
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = if (failed > 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
)
}
Spacer(Modifier.width(9.dp))
Text(
text = stringResource(R.string.background_process_count, displayedCount),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
Text(
text = status,
style = MaterialTheme.typography.labelMedium,
color = if (failed > 0 && running == 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Icon(
imageVector = Icons.Filled.ExpandLess,
contentDescription = null,
modifier = Modifier
.padding(start = 6.dp)
.size(17.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Mobile analogue of Hermes Desktop's composer process stack + terminal viewer. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GatewayBackgroundProcessSheet(
processes: List<GatewayProcess>,
loading: Boolean,
stoppingProcessIds: Set<String>,
onRefresh: () -> Unit,
onStop: (String) -> Unit,
onDismissProcess: (String) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val running = processes.filter { it.isRunning }
val recent = processes.filterNot { it.isRunning }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.background_processes_title), style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.background_processes_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRefresh, enabled = !loading) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.background_processes_refresh_a11y))
}
}
}
if (processes.isEmpty() && !loading) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"No background processes in this chat",
modifier = Modifier.padding(top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 560.dp),
) {
if (running.isNotEmpty()) {
item { ProcessSectionLabel("Running", running.size) }
items(running, key = { it.id }) { process ->
GatewayProcessRow(
process = process,
stopping = process.id in stoppingProcessIds,
onStop = { onStop(process.id) },
onDismiss = null,
)
}
}
if (running.isNotEmpty() && recent.isNotEmpty()) {
item { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) }
}
if (recent.isNotEmpty()) {
item { ProcessSectionLabel("Recent", recent.size) }
items(recent, key = { it.id }) { process ->
GatewayProcessRow(
process = process,
stopping = false,
onStop = null,
onDismiss = { onDismissProcess(process.id) },
)
}
}
}
}
}
}
}
@Composable
private fun ProcessSectionLabel(label: String, count: Int) {
Text(
text = "$label · $count",
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun GatewayProcessRow(
process: GatewayProcess,
stopping: Boolean,
onStop: (() -> Unit)?,
onDismiss: (() -> Unit)?,
) {
var expanded by remember(process.id) { mutableStateOf(false) }
val failed = !process.isRunning && (process.exitCode ?: 0) != 0
val output = sanitizeTerminalText(
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
).trimEnd()
val command = sanitizeTerminalText(process.command)
.lineSequence()
.firstOrNull()
?.trim()
.orEmpty()
.ifBlank {
"Background process"
}
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.clickable(
enabled = output.isNotBlank(),
onClickLabel = if (expanded) "Collapse process output" else "Expand process output",
) { expanded = !expanded }
.padding(horizontal = 20.dp, vertical = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
ProcessStateIcon(process = process, failed = failed, stopping = stopping)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 10.dp),
) {
Text(
text = command,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = processMetadata(process, failed),
style = MaterialTheme.typography.labelSmall,
color = if (failed) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (onStop != null) {
TextButton(onClick = onStop, enabled = !stopping) {
Icon(
Icons.Filled.Stop,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text(if (stopping) stringResource(R.string.background_processes_stopping) else stringResource(R.string.background_processes_stop))
}
} else if (onDismiss != null) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
}
if (output.isNotBlank()) {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse output" else "Expand output",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
AnimatedVisibility(visible = expanded && output.isNotBlank()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(12.dp),
) {
SelectionContainer {
Text(
text = output,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
val tint: Color = when {
failed -> MaterialTheme.colorScheme.error
process.isRunning || stopping -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.tertiary
}
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = tint.copy(alpha = 0.12f),
) {
Box(contentAlignment = Alignment.Center) {
when {
process.isRunning || stopping -> CircularProgressIndicator(
modifier = Modifier.size(17.dp),
strokeWidth = 2.dp,
color = tint,
)
failed -> Icon(
Icons.Filled.ErrorOutline,
contentDescription = stringResource(R.string.tool_failed_a11y),
modifier = Modifier.size(18.dp),
tint = tint,
)
else -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.tool_completed_a11y),
modifier = Modifier.size(18.dp),
tint = tint,
)
}
}
}
}
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
val state = when {
process.isRunning -> "Running"
failed -> "Failed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
else -> "Completed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
}
return "$state · ${formatElapsed(process.uptimeSeconds)}" +
if (process.detached) " · recovered" else ""
}
private val ansiTerminalEscape = Regex(
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
RegexOption.DOT_MATCHES_ALL,
)
private val unterminatedOsc = Regex("\u001B\\][^\\n]*")
/** Plain-text mobile output viewer: remove terminal control/ANSI while keeping layout text. */
internal fun sanitizeTerminalText(raw: String): String {
val withoutAnsi = unterminatedOsc.replace(
ansiTerminalEscape.replace(raw, ""),
"",
)
return withoutAnsi
.replace("\r\n", "\n")
.replace('\r', '\n')
.filter { char ->
char == '\n' || char == '\t' || (char.code >= 0x20 && char.code != 0x7F)
}
}
internal fun formatElapsed(totalSeconds: Long): String {
val seconds = totalSeconds.coerceAtLeast(0)
val hours = seconds / 3_600
val minutes = (seconds % 3_600) / 60
val remainder = seconds % 60
return when {
hours > 0 -> "${hours}h ${minutes}m"
minutes > 0 -> "${minutes}m ${remainder}s"
else -> "${remainder}s"
}
}
@@ -70,10 +70,12 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.HermesCardDispatch
@@ -134,6 +136,7 @@ fun HermesCardBubble(
val accentColor = accentToColor(card.accent)
val typeIcon = iconForType(card.type)
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
val cardDescription = stringResource(R.string.card_a11y, card.title ?: card.type)
// Expiry clock for timed asks. Ticks once a second while the deadline
// is ahead; freezes after. Keyed on the deadline so a re-used card id
@@ -155,7 +158,7 @@ fun HermesCardBubble(
.widthIn(max = maxWidth)
.fillMaxWidth()
.semantics {
contentDescription = "Card: ${card.title ?: card.type}"
contentDescription = cardDescription
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -260,7 +263,7 @@ fun HermesCardBubble(
expired -> {
Spacer(Modifier.height(10.dp))
ChoseRow(
text = "Expired — not granted",
text = stringResource(R.string.card_expired),
icon = Icons.Filled.HourglassBottom,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -293,7 +296,7 @@ fun HermesCardBubble(
val remainingSec = ((expiresAt - nowMillis) / 1000).coerceAtLeast(0)
Spacer(Modifier.height(8.dp))
Text(
text = "expires in %d:%02d".format(remainingSec / 60, remainingSec % 60),
text = stringResource(R.string.card_expires_in, remainingSec / 60, remainingSec % 60),
style = relayMetadataStyle(),
color = if (remainingSec < 30) RelayRefresh.Amber
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -459,7 +462,7 @@ private fun CardInputSlot(
)
Spacer(Modifier.height(6.dp))
Text(
text = "Not stored in chat history",
text = stringResource(R.string.card_not_stored),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
@@ -480,7 +483,7 @@ private fun CardInputSlot(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send answer",
contentDescription = stringResource(R.string.card_send_answer_a11y),
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
@@ -512,7 +515,7 @@ private fun CardInputSlot(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) { Text("Submit", style = MaterialTheme.typography.labelMedium) }
) { Text(stringResource(R.string.card_submit), style = MaterialTheme.typography.labelMedium) }
}
}
}
@@ -538,7 +541,7 @@ private fun InlineAnswerField(
) {
if (value.isEmpty()) {
Text(
text = "Type an answer…",
text = stringResource(R.string.card_answer_placeholder),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
@@ -7,6 +7,8 @@ import android.graphics.pdf.PdfRenderer
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.widget.Toast
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
@@ -153,7 +155,7 @@ private fun LoadingCard(
val sizeHint = attachment.fileSize?.takeIf { it > 0 }
?.let { " · ${formatBytes(it)}" } ?: ""
Text(
text = if (isManualCta) "Tap to download" else "Downloading…$sizeHint",
text = if (isManualCta) stringResource(R.string.inbound_attach_tap_download) else stringResource(R.string.inbound_attach_downloading, sizeHint),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -175,7 +177,7 @@ private fun LoadingCard(
IconButton(onClick = onCancel, modifier = Modifier.size(28.dp)) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Cancel download",
contentDescription = stringResource(R.string.inbound_attach_cd_cancel),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
@@ -219,12 +221,12 @@ private fun FailedCard(
Text(text = "\u26A0\uFE0F", style = MaterialTheme.typography.titleMedium)
Column(modifier = Modifier.weight(1f)) {
Text(
text = attachment.errorMessage ?: "Attachment failed",
text = attachment.errorMessage ?: stringResource(R.string.inbound_attach_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Tap to retry",
text = stringResource(R.string.inbound_attach_tap_retry),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
)
@@ -467,7 +469,7 @@ private fun FileCardRender(
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
@@ -491,7 +493,7 @@ private fun FileCardRender(
* the default tap previews in-app.
*/
@Composable
private fun AttachmentActionsMenu(
fun AttachmentActionsMenu(
expanded: Boolean,
onDismiss: () -> Unit,
context: Context,
@@ -500,7 +502,7 @@ private fun AttachmentActionsMenu(
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuItem(
text = { Text("Open externally") },
text = { Text(stringResource(R.string.inbound_attach_open)) },
leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
onClick = {
onDismiss()
@@ -508,14 +510,14 @@ private fun AttachmentActionsMenu(
},
)
DropdownMenuItem(
text = { Text("Share") },
text = { Text(stringResource(R.string.inbound_attach_share)) },
onClick = {
onDismiss()
scope.launch { shareAttachment(context, attachment) }
},
)
DropdownMenuItem(
text = { Text("Save to device") },
text = { Text(stringResource(R.string.inbound_attach_save)) },
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null) },
onClick = {
onDismiss()
@@ -527,7 +529,7 @@ private fun AttachmentActionsMenu(
/** Small circular download button overlaid on inline images (B2). */
@Composable
private fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
Surface(
shape = CircleShape,
color = Color.Black.copy(alpha = 0.45f),
@@ -536,7 +538,7 @@ private fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = Color.White,
modifier = Modifier.size(18.dp),
)
@@ -549,17 +551,17 @@ private fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier
private suspend fun shareAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, "Couldn't read this file")
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
return
}
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
private suspend fun saveAttachment(context: Context, attachment: Attachment) {
suspend fun saveAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, "Couldn't read this file")
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
return
}
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
@@ -569,13 +571,13 @@ private suspend fun saveAttachment(context: Context, attachment: Attachment) {
}
when (result) {
is MediaSaver.SaveResult.Saved ->
attachmentToast(context, "Saved to ${result.location}")
attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
attachmentToast(context, "Save failed: ${result.message}")
attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
@@ -591,7 +593,7 @@ private suspend fun openAttachmentExternally(context: Context, attachment: Attac
if (uri != null) {
MediaSaver.open(context, uri, attachment.contentType)
} else {
attachmentToast(context, "Couldn't open this file")
attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
}
}
@@ -707,16 +709,25 @@ private fun attachmentToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@Composable
private fun emojiAndLabelFor(
mode: AttachmentRenderMode,
contentType: String
): Pair<String, String> = when (mode) {
AttachmentRenderMode.IMAGE -> "\uD83D\uDDBC\uFE0F" to "Image"
AttachmentRenderMode.VIDEO -> "\uD83C\uDFAC" to "Video"
AttachmentRenderMode.AUDIO -> "\uD83C\uDFB5" to "Audio"
AttachmentRenderMode.PDF -> "\uD83D\uDCC4" to "PDF"
AttachmentRenderMode.TEXT -> "\uD83D\uDCDD" to contentTypeToLabel(contentType, fallback = "Text")
AttachmentRenderMode.GENERIC -> "\uD83D\uDCCE" to contentTypeToLabel(contentType, fallback = "File")
): Pair<String, String> {
val imageLabel = stringResource(R.string.inbound_attach_type_image)
val videoLabel = stringResource(R.string.inbound_attach_type_video)
val audioLabel = stringResource(R.string.inbound_attach_type_audio)
val pdfLabel = stringResource(R.string.inbound_attach_type_pdf)
val textLabel = stringResource(R.string.inbound_attach_type_text)
val fileLabel = stringResource(R.string.inbound_attach_type_file)
return when (mode) {
AttachmentRenderMode.IMAGE -> "\uD83D\uDDBC\uFE0F" to imageLabel
AttachmentRenderMode.VIDEO -> "\uD83C\uDFAC" to videoLabel
AttachmentRenderMode.AUDIO -> "\uD83C\uDFB5" to audioLabel
AttachmentRenderMode.PDF -> "\uD83D\uDCC4" to pdfLabel
AttachmentRenderMode.TEXT -> "\uD83D\uDCDD" to contentTypeToLabel(contentType, fallback = textLabel)
AttachmentRenderMode.GENERIC -> "\uD83D\uDCCE" to contentTypeToLabel(contentType, fallback = fileLabel)
}
}
private fun contentTypeToLabel(contentType: String, fallback: String): String {
@@ -16,8 +16,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.ChatViewModel
/**
@@ -45,66 +47,67 @@ fun InjectedContextSheet(
.padding(start = 20.dp, end = 20.dp, bottom = 28.dp),
) {
Text(
text = "What the agent sees",
text = stringResource(R.string.injected_context_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(4.dp))
Text(
text = "The exact extra context prepended to your next turn, for " +
"transparency. Transport: ${context.transport}.",
text = stringResource(R.string.injected_context_subtitle, context.transport),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
val personaTitle = stringResource(R.string.injected_context_persona_title)
val phoneStatusTitle = stringResource(R.string.injected_context_phone_status_title)
val mediaTitle = stringResource(R.string.injected_context_media_title)
val relayTitle = stringResource(R.string.injected_context_relay_title)
val turnTitle = stringResource(R.string.injected_context_turn_title)
val personaServerSide = stringResource(R.string.injected_context_persona_server_side)
val personaNotSet = stringResource(R.string.injected_context_persona_not_set)
val phoneStatusNotSet = stringResource(R.string.injected_context_phone_status_not_set)
val mediaRelayActive = stringResource(R.string.injected_context_media_relay_active)
val mediaNoRelay = stringResource(R.string.injected_context_media_no_relay)
val relayNotSet = stringResource(R.string.injected_context_relay_not_set)
val turnNotSet = stringResource(R.string.injected_context_turn_not_set)
ContextSection(
title = "Persona / profile",
title = personaTitle,
body = context.personaPrompt,
emptyNote = if (context.personaOwnedServerSide) {
"Added server-side by Hermes (profile soul + personality " +
"overlay) — not sent from this device."
personaServerSide
} else {
"No persona prompt is being sent — the server uses its " +
"configured default."
personaNotSet
},
)
ContextSection(
title = "Phone status",
title = phoneStatusTitle,
body = context.appContext,
emptyNote = "No phone-status block — enable it in App Context settings.",
emptyNote = phoneStatusNotSet,
)
ContextSection(
title = "Media capability",
title = mediaTitle,
body = context.mediaCapability,
emptyNote = if (context.relayMediaAvailable) {
// Gateway path: media WORKS (client renders server-local images
// via the relay) — just no injected hint, since the gateway has
// no per-turn system slot. Say so, rather than "not set".
"Relay route active — server-local images and files render " +
"in-app via the relay (client-side). The gateway transport " +
"has no system slot, so no hint is injected here, but media " +
"still works."
mediaRelayActive
} else {
"No relay route configured — the agent can't fetch server-local " +
"images or files by path."
mediaNoRelay
},
)
ContextSection(
title = "Relay context (server-side)",
title = relayTitle,
body = context.relayServerBlocks
.takeIf { it.isNotEmpty() }
?.joinToString("\n\n") { (name, text) ->
"[$name]\n$text"
},
emptyNote = "No relay server-side context blocks are active, or the relay " +
"context layer is disabled.",
emptyNote = relayNotSet,
)
ContextSection(
title = "This turn",
title = turnTitle,
body = context.interfaceContext,
emptyNote = "Nothing extra for a typed turn. Voice turns add a " +
"spoken-output hint here.",
emptyNote = turnNotSet,
)
}
}
@@ -23,8 +23,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.dp
import com.hermesandroid.relay.R
/**
* One-time acknowledgment dialog shown the first time the user flips
@@ -50,10 +52,13 @@ fun InsecureConnectionAckDialog(
) {
var selectedReason by remember { mutableStateOf<String?>(null) }
val reasonLan = stringResource(R.string.insecure_ack_reason_lan)
val reasonTailscale = stringResource(R.string.insecure_ack_reason_tailscale)
val reasonDev = stringResource(R.string.insecure_ack_reason_dev)
val reasonOptions = listOf(
"lan_only" to "LAN only (trusted network)",
"tailscale_vpn" to "Tailscale or VPN",
"local_dev" to "Local development only",
"lan_only" to reasonLan,
"tailscale_vpn" to reasonTailscale,
"local_dev" to reasonDev,
)
AlertDialog(
@@ -67,7 +72,7 @@ fun InsecureConnectionAckDialog(
},
title = {
Text(
text = "Allow insecure connections?",
text = stringResource(R.string.insecure_ack_title),
style = MaterialTheme.typography.titleLarge
)
},
@@ -77,16 +82,12 @@ fun InsecureConnectionAckDialog(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Insecure mode lets this app connect over plain " +
"ws:// and http://. Anyone on the network between " +
"your phone and the server can read your chat " +
"messages, session tokens, and terminal traffic.",
text = stringResource(R.string.insecure_ack_body_1),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Only use this on networks you control. Pick " +
"the reason that best describes your setup:",
text = stringResource(R.string.insecure_ack_body_2),
style = MaterialTheme.typography.bodyMedium
)
@@ -124,12 +125,12 @@ fun InsecureConnectionAckDialog(
enabled = selectedReason != null,
onClick = { selectedReason?.let(onConfirm) }
) {
Text("I understand")
Text(stringResource(R.string.insecure_ack_confirm))
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text("Cancel")
Text(stringResource(R.string.insecure_ack_cancel))
}
}
)
@@ -1,13 +1,18 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -26,8 +31,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
@@ -38,18 +48,33 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import androidx.compose.ui.unit.times
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans
import com.mikepenz.markdown.compose.extendedspans.RoundedCornerSpanPainter
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.markdownExtendedSpans
import com.hermesandroid.relay.ui.theme.LocalBrand
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import kotlinx.coroutines.delay
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes.HEADER
import org.intellij.markdown.flavours.gfm.GFMElementTypes.ROW
import org.intellij.markdown.flavours.gfm.GFMTokenTypes.CELL
import org.intellij.markdown.flavours.gfm.GFMTokenTypes.TABLE_SEPARATOR
@Composable
fun MarkdownContent(
@@ -61,7 +86,6 @@ fun MarkdownContent(
val highlightsBuilder = remember(isDarkTheme) {
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
}
Markdown(
content = content,
modifier = modifier,
@@ -133,6 +157,13 @@ fun MarkdownContent(
),
),
),
// Tables get a phone-friendly minimum measure. The stock renderer uses
// one-line cells; our table component below keeps the same AST/inline
// annotator path but permits wrapping and exposes horizontal overflow.
dimens = markdownDimens(
tableCellWidth = 110.dp,
tableCellPadding = 12.dp,
),
components = markdownComponents(
codeBlock = {
MarkdownHighlightedCodeBlock(
@@ -149,7 +180,8 @@ fun MarkdownContent(
highlightsBuilder = highlightsBuilder,
showHeader = true
)
}
},
table = { WideMarkdownTable(it) },
),
extendedSpans = markdownExtendedSpans {
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
@@ -157,20 +189,141 @@ fun MarkdownContent(
)
}
/**
* GFM table renderer tuned for a narrow chat bubble.
*
* Every column keeps the configured 110dp minimum and cells wrap instead of
* truncating to one line. Tables wider than the bubble scroll horizontally;
* the trailing fade is deliberately subtle and disappears once the reader has
* reached the final column.
*/
@Composable
private fun WideMarkdownTable(model: MarkdownComponentModel) {
val columnsCount = remember(model.node) {
model.node.findChildOfType(HEADER)?.children?.count { it.type == CELL } ?: 0
}
if (columnsCount == 0) {
MarkdownTable(
content = model.content,
node = model.node,
style = model.typography.table,
)
return
}
val rowsCount = remember(model.node) {
model.node.children.count { it.type == ROW } + 1
}
val tableCellWidth = LocalMarkdownDimens.current.tableCellWidth
val tableWidth = columnsCount * tableCellWidth
val tableCornerSize = LocalMarkdownDimens.current.tableCornerSize
val tableBackground = LocalMarkdownColors.current.tableBackground
val scrollState = rememberScrollState()
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(tableCornerSize))
.background(tableBackground)
.semantics {
collectionInfo = CollectionInfo(
rowCount = rowsCount,
columnCount = columnsCount,
)
},
) {
val scrollable = maxWidth < tableWidth
Box {
Column(
modifier = if (scrollable) {
Modifier
.horizontalScroll(scrollState)
.requiredWidth(tableWidth)
} else {
Modifier.fillMaxWidth()
},
) {
var rowIndex = 1
model.node.children.forEach { child ->
when (child.type) {
HEADER -> MarkdownTableHeader(
content = model.content,
header = child,
tableWidth = tableWidth,
style = model.typography.table,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
ROW -> {
MarkdownTableRow(
content = model.content,
header = child,
tableWidth = tableWidth,
style = model.typography.table,
rowIndex = rowIndex,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
rowIndex++
}
TABLE_SEPARATOR -> MarkdownDivider()
}
}
}
if (scrollable && scrollState.canScrollForward) {
Box(
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(28.dp)
.background(
Brush.horizontalGradient(
colors = listOf(Color.Transparent, tableBackground),
),
),
)
}
}
}
}
}
@Composable
fun StreamingMarkdownContent(
content: String,
textColor: Color,
modifier: Modifier = Modifier
isStreaming: Boolean = true,
modifier: Modifier = Modifier,
) {
val blocks = remember(content) { parseStreamingMarkdownBlocks(content) }
val blocks = remember(content, isStreaming) {
if (isStreaming) {
parseStreamingMarkdownBlocks(content)
} else {
listOf(StreamingMarkdownBlock.Markdown(content))
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(6.dp),
// Match the final renderer's block spacer so moving the active tail
// into the settled Markdown prefix does not add a second layout jump.
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
blocks.forEach { block ->
when (block) {
is StreamingMarkdownBlock.Markdown -> MarkdownContent(
content = block.content,
textColor = textColor,
)
is StreamingMarkdownBlock.Text -> Text(
text = block.text,
style = MaterialTheme.typography.bodyMedium,
@@ -267,23 +420,96 @@ private fun CodeCopyButton(code: String) {
}
}
private sealed interface StreamingMarkdownBlock {
internal sealed interface StreamingMarkdownBlock {
data class Markdown(val content: String) : StreamingMarkdownBlock
data class Text(val text: String) : StreamingMarkdownBlock
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
}
private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
/**
* Splits an in-flight response into stable Markdown and one structurally
* incomplete tail.
*
* Only conservative, blank-terminated top-level prose/heading blocks promote
* to the real renderer. Lists, quotes, tables, indented blocks, HTML, and code
* remain on the lightweight streaming surface until the message settles; those
* containers can legally absorb later lines, so promoting them early causes a
* visible re-parenting jump when the final CommonMark tree is parsed.
*/
internal fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
if (content.isBlank()) return emptyList()
val normalized = content
.replace("\r\n", "\n")
.replace('\r', '\n')
val blocks = mutableListOf<StreamingMarkdownBlock>()
val settledEnd = findStableMarkdownBoundary(normalized).coerceAtLeast(0)
if (settledEnd > 0) {
normalized.substring(0, settledEnd).trimEnd().let { stable ->
if (stable.isNotBlank()) blocks += StreamingMarkdownBlock.Markdown(stable)
}
}
val activeTail = normalized.substring(settledEnd)
if (activeTail.isNotBlank()) {
blocks += parseActiveStreamingTail(activeTail)
}
return blocks
}
/** Last unambiguous blank-line boundary in a contiguous simple-markdown prefix. */
private fun findStableMarkdownBoundary(content: String): Int {
var offset = 0
var blockStart = 0
var activeFence: StreamingFence? = null
var lastStableBoundary = 0
while (offset < content.length) {
val newline = content.indexOf('\n', offset)
val lineEnd = if (newline >= 0) newline else content.length
val line = content.substring(offset, lineEnd)
activeFence = when (val current = activeFence) {
null -> streamingFence(line)
else -> if (isClosingFence(line, current)) null else current
}
// Whitespace-only lines can be meaningful indentation inside a list.
// Require a truly empty delimiter and stop at the first ambiguous
// container so every promoted prefix remains structurally final.
if (activeFence == null && newline >= 0 && line.isEmpty()) {
val candidate = content.substring(blockStart, offset).trimEnd()
if (candidate.isNotBlank() && !isConservativeStableBlock(candidate)) break
lastStableBoundary = newline + 1
blockStart = lastStableBoundary
}
if (newline < 0) break
offset = newline + 1
}
return lastStableBoundary
}
private fun isConservativeStableBlock(block: String): Boolean = block
.lineSequence()
.filter { it.isNotEmpty() }
.none { line ->
line.firstOrNull()?.isWhitespace() == true ||
AMBIGUOUS_STREAMING_BLOCK.matches(line)
}
private fun parseActiveStreamingTail(content: String): List<StreamingMarkdownBlock> {
val blocks = mutableListOf<StreamingMarkdownBlock>()
val paragraph = StringBuilder()
val code = StringBuilder()
var inFence = false
var activeFence = ""
var activeFence: StreamingFence? = null
var language = ""
fun flushParagraph() {
val text = paragraph.toString().trimEnd()
val text = paragraph.toString().trim('\n').trimEnd()
if (text.isNotBlank()) {
blocks += StreamingMarkdownBlock.Text(text)
}
@@ -298,35 +524,30 @@ private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdow
code.clear()
}
val lines = content
.replace("\r\n", "\n")
.replace('\r', '\n')
.split('\n')
val lines = content.split('\n')
lines.forEachIndexed { index, line ->
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
if (!inFence) {
val fence = streamingFenceMarker(line)
if (activeFence == null) {
val fence = streamingFence(line)
if (fence != null) {
flushParagraph()
inFence = true
activeFence = fence
language = streamingFenceLanguage(line, fence)
} else {
paragraph.append(lineWithBreak)
}
} else if (streamingFenceMarker(line) == activeFence) {
} else if (isClosingFence(line, activeFence)) {
flushCode()
inFence = false
activeFence = ""
activeFence = null
language = ""
} else {
code.append(lineWithBreak)
}
}
if (inFence) {
if (activeFence != null) {
flushCode()
} else {
flushParagraph()
@@ -335,18 +556,32 @@ private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdow
return blocks
}
private fun streamingFenceMarker(line: String): String? {
private data class StreamingFence(
val marker: Char,
val length: Int,
)
private fun streamingFence(line: String): StreamingFence? {
val trimmed = line.trimStart()
return when {
trimmed.startsWith("```") -> "```"
trimmed.startsWith("~~~") -> "~~~"
else -> null
}
val marker = trimmed.firstOrNull()?.takeIf { it == '`' || it == '~' } ?: return null
val length = trimmed.takeWhile { it == marker }.length
return length.takeIf { it >= 3 }?.let { StreamingFence(marker, it) }
}
private fun streamingFenceLanguage(line: String, marker: String): String {
val tail = line.trimStart().removePrefix(marker).trim()
private fun isClosingFence(line: String, activeFence: StreamingFence): Boolean {
val trimmed = line.trimStart()
if (trimmed.firstOrNull() != activeFence.marker) return false
val markerLength = trimmed.takeWhile { it == activeFence.marker }.length
return markerLength >= activeFence.length && trimmed.drop(markerLength).isBlank()
}
private fun streamingFenceLanguage(line: String, fence: StreamingFence): String {
val tail = line.trimStart().drop(fence.length).trim()
return tail
.takeWhile { !it.isWhitespace() && it != '`' && it != '~' }
.takeWhile { !it.isWhitespace() && it != fence.marker }
.take(32)
}
private val AMBIGUOUS_STREAMING_BLOCK = Regex(
"""^(?:[-+*]\s|\d{1,9}[.)]\s|>|```|~~~|<|\||(?:-{3,}|={3,})\s*$).*""",
)
@@ -44,8 +44,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessage
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
@@ -197,7 +199,7 @@ private fun MessageBannerContent(
)
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Show recent messages",
contentDescription = stringResource(R.string.cd_show_recent),
modifier = Modifier.size(18.dp),
)
}
@@ -229,7 +231,7 @@ private fun MessageBannerContent(
if (index == 0) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "Collapse",
contentDescription = stringResource(R.string.cd_collapse),
modifier = Modifier.size(18.dp),
)
}
@@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -330,7 +331,7 @@ fun MessageBubble(
onDismissRequest = { showMessageActions = false },
) {
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.msg_bubble_copy)) },
onClick = {
showMessageActions = false
onCopyMessage(message.content)
@@ -338,7 +339,7 @@ fun MessageBubble(
)
if (onQuoteMessage != null) {
DropdownMenuItem(
text = { Text("Quote in reply") },
text = { Text(stringResource(R.string.msg_bubble_quote)) },
onClick = {
showMessageActions = false
onQuoteMessage(message.content)
@@ -347,7 +348,7 @@ fun MessageBubble(
}
if (showEditAction) {
DropdownMenuItem(
text = { Text("Edit & resend") },
text = { Text(stringResource(R.string.msg_bubble_edit)) },
onClick = {
showMessageActions = false
onEditMessage(message)
@@ -395,21 +396,16 @@ fun MessageBubble(
color = textColor
)
} else {
// Use a stable lightweight renderer while streaming.
// The full parser/highlighter can rebuild block shapes
// on every partial fence/token and visibly flicker.
// Settled blocks keep the real Markdown renderer while
// only the structurally incomplete tail stays raw. The
// same composable settles the final tail so retained
// parser state survives the streaming -> final handoff.
if (markdownBody.isNotEmpty()) {
if (message.isStreaming) {
StreamingMarkdownContent(
content = markdownBody,
textColor = textColor
)
} else {
MarkdownContent(
content = markdownBody,
textColor = textColor
)
}
StreamingMarkdownContent(
content = markdownBody,
textColor = textColor,
isStreaming = message.isStreaming,
)
}
}
}
@@ -453,22 +449,34 @@ fun MessageBubble(
}
}
// Attachments — dispatched through the unified InboundAttachmentCard
// so outbound and inbound attachments share the same render pipeline.
// Outbound attachments (user-authored) always have state=LOADED so
// they route straight to the LOADED branch; inbound attachments (via
// MEDIA markers) cycle through LOADING → LOADED / FAILED as the
// background fetch progresses.
// Attachments — two or more loaded images collapse into one
// grid + swipe-across gallery. Every other item stays on the
// unified InboundAttachmentCard path, and layout items retain
// their original ChatMessage.attachments indices so retry /
// manual-fetch callbacks cannot drift after grouping.
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
message.attachments.forEachIndexed { index, attachment ->
InboundAttachmentCard(
attachment = attachment,
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp)
)
val attachmentItems = remember(message.attachments) {
attachmentLayoutItems(message.attachments)
}
attachmentItems.forEach { item ->
when (item) {
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
attachments = item.attachmentIndices.map(message.attachments::get),
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
is AttachmentLayoutItem.Single -> {
val index = item.attachmentIndex
InboundAttachmentCard(
attachment = message.attachments[index],
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
}
@@ -514,9 +522,9 @@ fun MessageBubble(
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (recoveringAnswer) {
"Reconnecting to your answer…"
stringResource(R.string.msg_bubble_reconnecting)
} else {
"Still working…"
stringResource(R.string.msg_bubble_still_working)
},
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.6f),
@@ -543,10 +551,17 @@ fun MessageBubble(
// message routed over the relay proactive channel). Null on every
// ordinary chat message, which render nothing here.
message.deliveryStatus?.takeIf { isUser }?.let { status ->
val (label, alpha) = when (status) {
MessageDeliveryStatus.SENDING -> "Sending…" to 0.5f
MessageDeliveryStatus.DELIVERED -> "Delivered" to 0.5f
MessageDeliveryStatus.FAILED -> "Not sent" to 0.7f
val label = stringResource(
when (status) {
MessageDeliveryStatus.SENDING -> R.string.msg_bubble_sending
MessageDeliveryStatus.DELIVERED -> R.string.msg_bubble_delivered
MessageDeliveryStatus.FAILED -> R.string.msg_bubble_not_sent
}
)
val alpha = when (status) {
MessageDeliveryStatus.SENDING -> 0.5f
MessageDeliveryStatus.DELIVERED -> 0.5f
MessageDeliveryStatus.FAILED -> 0.7f
}
Spacer(modifier = Modifier.height(2.dp))
Text(
@@ -37,8 +37,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* Searchable model picker as a bottom sheet — a cleaner surface than the inline
@@ -142,7 +144,7 @@ fun ModelPickerSheet(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Search models or providers…") },
placeholder = { Text(stringResource(R.string.model_picker_search)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -155,7 +157,7 @@ fun ModelPickerSheet(
IconButton(onClick = { query = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
contentDescription = stringResource(R.string.model_picker_cd_clear),
modifier = Modifier.size(20.dp),
)
}
@@ -202,7 +204,7 @@ fun ModelPickerSheet(
contentAlignment = Alignment.Center,
) {
Text(
text = "No models match your search",
text = stringResource(R.string.model_picker_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -252,7 +254,7 @@ private fun ModelPickerRow(
if (option.selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.cd_selected),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
@@ -26,38 +26,38 @@ import androidx.compose.material3.TopAppBarDefaults
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 com.hermesandroid.relay.R
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
enum class PowerFeatureGateStatus(
val label: String,
val actionLabel: String,
val explanation: String,
val labelRes: Int,
val actionLabelRes: Int,
val explanationRes: Int,
) {
RequiresPairing(
label = "Requires pairing",
actionLabel = "Pair to unlock",
explanation = "This feature requires the Relay plugin. Make sure it is installed " +
"and running on your Hermes server, then pair this device to unlock it.",
labelRes = R.string.power_feature_requires_pairing_label,
actionLabelRes = R.string.power_feature_requires_pairing_action,
explanationRes = R.string.power_feature_requires_pairing_explain,
),
PairingExpired(
label = "Pairing expired",
actionLabel = "Pair again",
explanation = "Your relay session is no longer accepted. Pair again to get a fresh grant.",
labelRes = R.string.power_feature_pairing_expired_label,
actionLabelRes = R.string.power_feature_pairing_expired_action,
explanationRes = R.string.power_feature_pairing_expired_explain,
),
Unavailable(
label = "Unavailable on this server",
actionLabel = "View connection",
explanation = "This Hermes server isn't exposing the Relay plugin (or it's an older " +
"version). Install or update the Relay plugin on the server to use this feature.",
labelRes = R.string.power_feature_unavailable_label,
actionLabelRes = R.string.power_feature_unavailable_action,
explanationRes = R.string.power_feature_unavailable_explain,
),
DashboardSignInRequired(
label = "Dashboard sign-in required",
actionLabel = "Open sign-in",
explanation = "This standard dashboard feature needs a dashboard session before it can load.",
labelRes = R.string.power_feature_dashboard_signin_label,
actionLabelRes = R.string.power_feature_dashboard_signin_action,
explanationRes = R.string.power_feature_dashboard_signin_explain,
);
companion object {
@@ -90,6 +90,7 @@ fun PowerFeatureGateScreen(
Scaffold(
modifier = modifier,
topBar = {
val backDesc = stringResource(R.string.power_feature_back_desc)
TopAppBar(
title = { Text(title) },
navigationIcon = {
@@ -97,7 +98,7 @@ fun PowerFeatureGateScreen(
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = backDesc,
)
}
}
@@ -134,6 +135,9 @@ fun PowerFeatureGateCard(
onPrimaryAction: () -> Unit,
modifier: Modifier = Modifier,
) {
val resolvedLabel = stringResource(status.labelRes)
val resolvedExplanation = stringResource(status.explanationRes)
val resolvedActionLabel = stringResource(status.actionLabelRes)
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -161,7 +165,7 @@ fun PowerFeatureGateCard(
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = status.label,
text = resolvedLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
@@ -178,7 +182,7 @@ fun PowerFeatureGateCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = status.explanation,
text = resolvedExplanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -187,7 +191,7 @@ fun PowerFeatureGateCard(
onClick = onPrimaryAction,
modifier = Modifier.fillMaxWidth(),
) {
Text(status.actionLabel)
Text(resolvedActionLabel)
}
}
}
@@ -22,7 +22,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.theme.gradientBorder
@@ -87,14 +89,14 @@ fun ProfileInspectorCard(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = "Inspect Agent",
text = stringResource(R.string.profile_inspector_card_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = if (enabled) {
"View config, SOUL, memory, skills"
stringResource(R.string.profile_inspector_card_subtitle)
} else {
"No active agent"
stringResource(R.string.profile_inspector_card_no_agent)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -66,6 +66,8 @@ import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -534,6 +536,9 @@ fun QrPairingScanner(
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Pre-resolve strings used inside non-Composable contexts (CameraX
// listeners, exception handlers, etc. don't have a Composable scope).
val cameraErrorMsg = stringResource(R.string.qr_scanner_camera_error)
// AtomicBoolean for thread-safe detection flag (accessed from camera executor thread)
val hasDetected = remember { AtomicBoolean(false) }
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
@@ -618,12 +623,12 @@ fun QrPairingScanner(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close scanner",
contentDescription = stringResource(R.string.qr_scanner_cd_close),
tint = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = "Scan Hermes QR",
text = stringResource(R.string.qr_scanner_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center)
@@ -670,7 +675,7 @@ fun QrPairingScanner(
cameraProviderFuture.get()
} catch (t: Throwable) {
Log.e("QrPairingScanner", "Camera provider init failed", t)
cameraError = "Couldn't start the camera on this device."
cameraError = cameraErrorMsg
return@addListener
}
cameraProviderRef.value = cameraProvider
@@ -758,7 +763,7 @@ fun QrPairingScanner(
)
} catch (e: Exception) {
Log.e("QrPairingScanner", "Camera bind failed", e)
cameraError = "Couldn't start the camera on this device."
cameraError = cameraErrorMsg
}
}, ContextCompat.getMainExecutor(ctx))
@@ -793,13 +798,13 @@ fun QrPairingScanner(
modifier = Modifier.size(32.dp)
)
Text(
text = "Scan a Hermes setup QR",
text = stringResource(R.string.qr_scanner_instruction),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Relay plugin.",
text = stringResource(R.string.qr_scanner_subtext),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -843,14 +848,14 @@ private fun CameraUnavailableCard(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "You can pair without the camera.",
text = stringResource(R.string.qr_scanner_fallback_message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onPairManually) {
Text("Pair manually")
Text(stringResource(R.string.qr_scanner_pair_manual))
}
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -55,9 +56,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
@@ -65,18 +69,18 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private enum class SessionDrawerFilter(val label: String) {
All("All"),
Threads("Threads"),
Pinned("Pinned"),
Archive("Archive"),
private enum class SessionDrawerFilter {
All,
Threads,
Pinned,
Archive,
}
@Composable
fun SessionDrawerContent(
sessions: List<ChatSession>,
currentSessionId: String?,
scopeTitle: String = "Sessions",
scopeTitle: String = "",
scopeSubtitle: String? = null,
isLoading: Boolean = false,
isOpen: Boolean = true,
@@ -204,7 +208,7 @@ fun SessionDrawerContent(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = scopeTitle,
text = scopeTitle.ifBlank { stringResource(R.string.drawer_filter_sessions) },
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
)
@@ -218,7 +222,7 @@ fun SessionDrawerContent(
) {
Icon(
Icons.Filled.FilterList,
contentDescription = "Filter by source",
contentDescription = stringResource(R.string.drawer_filter_by_source),
tint = if (presentSources.any { it in hiddenSources }) {
RelayRefresh.Relay
} else {
@@ -232,7 +236,7 @@ fun SessionDrawerContent(
onDismissRequest = { sourceFilterOpen = false },
) {
Text(
text = "Show sources",
text = stringResource(R.string.drawer_show_sources),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
@@ -299,7 +303,7 @@ fun SessionDrawerContent(
IconButton(onClick = refresh, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Filled.Refresh,
contentDescription = "Refresh sessions",
contentDescription = stringResource(R.string.drawer_refresh_sessions),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
@@ -326,7 +330,7 @@ fun SessionDrawerContent(
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("New Chat")
Text(stringResource(R.string.drawer_new_chat))
}
Spacer(modifier = Modifier.height(8.dp))
@@ -338,7 +342,7 @@ fun SessionDrawerContent(
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
placeholder = { Text("Search sessions or id...") },
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
)
Spacer(modifier = Modifier.height(8.dp))
Row(
@@ -357,11 +361,21 @@ fun SessionDrawerContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(text = item.label, style = relayMetadataStyle())
Text(text = when (item) {
SessionDrawerFilter.All -> stringResource(R.string.drawer_filter_all)
SessionDrawerFilter.Threads -> stringResource(R.string.drawer_filter_threads)
SessionDrawerFilter.Pinned -> stringResource(R.string.drawer_filter_pinned)
SessionDrawerFilter.Archive -> stringResource(R.string.drawer_filter_archive)
}, style = relayMetadataStyle())
BetaChip()
}
} else {
Text(text = item.label, style = relayMetadataStyle())
Text(text = when (item) {
SessionDrawerFilter.All -> stringResource(R.string.drawer_filter_all)
SessionDrawerFilter.Threads -> stringResource(R.string.drawer_filter_threads)
SessionDrawerFilter.Pinned -> stringResource(R.string.drawer_filter_pinned)
SessionDrawerFilter.Archive -> stringResource(R.string.drawer_filter_archive)
}, style = relayMetadataStyle())
}
},
)
@@ -380,7 +394,7 @@ fun SessionDrawerContent(
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text("New Thread")
Text(stringResource(R.string.drawer_new_thread))
}
}
if (!autoTitlesSupported) {
@@ -390,7 +404,7 @@ fun SessionDrawerContent(
// rather than broken — rename is one tap away via ⋮. (issue #133)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Chats aren't auto-named on this connection — use ⋮ → Rename.",
text = stringResource(R.string.drawer_chats_not_named),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -422,7 +436,7 @@ fun SessionDrawerContent(
strokeWidth = 2.dp,
)
Text(
text = "Loading sessions…",
text = stringResource(R.string.drawer_loading_sessions),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -435,12 +449,12 @@ fun SessionDrawerContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (sessions.isEmpty()) "No sessions yet" else "No matching sessions",
text = if (sessions.isEmpty()) stringResource(R.string.drawer_no_sessions) else stringResource(R.string.drawer_no_matching_sessions),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Start a conversation to see it here",
text = stringResource(R.string.drawer_start_conversation),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -482,12 +496,12 @@ fun SessionDrawerContent(
var threadName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { newThreadDialog = false },
title = { Text("New Thread") },
title = { Text(stringResource(R.string.drawer_new_thread)) },
text = {
OutlinedTextField(
value = threadName,
onValueChange = { threadName = it },
label = { Text("Thread name") },
label = { Text(stringResource(R.string.drawer_thread_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -500,12 +514,12 @@ fun SessionDrawerContent(
newThreadDialog = false
}
}) {
Text("Create")
Text(stringResource(R.string.drawer_create))
}
},
dismissButton = {
TextButton(onClick = { newThreadDialog = false }) {
Text("Cancel")
Text(stringResource(R.string.drawer_cancel))
}
},
)
@@ -516,12 +530,12 @@ fun SessionDrawerContent(
var newTitle by remember(session) { mutableStateOf(session.title ?: "") }
AlertDialog(
onDismissRequest = { renameDialogSession = null },
title = { Text("Rename Session") },
title = { Text(stringResource(R.string.drawer_rename_session)) },
text = {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("Title") },
label = { Text(stringResource(R.string.drawer_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@@ -533,12 +547,12 @@ fun SessionDrawerContent(
}
renameDialogSession = null
}) {
Text("Rename")
Text(stringResource(R.string.drawer_rename))
}
},
dismissButton = {
TextButton(onClick = { renameDialogSession = null }) {
Text("Cancel")
Text(stringResource(R.string.drawer_cancel))
}
}
)
@@ -548,21 +562,21 @@ fun SessionDrawerContent(
deleteDialogSession?.let { session ->
AlertDialog(
onDismissRequest = { deleteDialogSession = null },
title = { Text("Delete Session?") },
title = { Text(stringResource(R.string.drawer_delete_session_title)) },
text = {
Text("This will permanently delete \"${session.title ?: "Untitled"}\" and its message history.")
Text(stringResource(R.string.drawer_delete_session_prefix) + (session.title ?: stringResource(R.string.drawer_untitled)) + stringResource(R.string.drawer_delete_session_suffix))
},
confirmButton = {
TextButton(onClick = {
onDeleteSession(session.sessionId)
deleteDialogSession = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.drawer_delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteDialogSession = null }) {
Text("Cancel")
Text(stringResource(R.string.drawer_cancel))
}
}
)
@@ -583,6 +597,8 @@ private fun SessionItem(
) {
var menuOpen by remember { mutableStateOf(false) }
val locale = LocalLocale.current.platformLocale
val context = LocalContext.current
val untitledLabel = stringResource(R.string.drawer_untitled)
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
@@ -603,7 +619,7 @@ private fun SessionItem(
.padding(end = 8.dp),
) {
Text(
text = session.title ?: "Untitled",
text = session.title ?: untitledLabel,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -633,7 +649,7 @@ private fun SessionItem(
tint = RelayRefresh.Relay,
)
Text(
text = "Thread",
text = stringResource(R.string.drawer_thread),
style = relayMetadataStyle(),
color = RelayRefresh.Relay,
maxLines = 1,
@@ -645,7 +661,7 @@ private fun SessionItem(
sourceBadge(session.source)?.let { badge ->
SourceChip(badge)
}
sessionTimestampText(session, locale)?.let { timestamp ->
sessionTimestampText(session, locale, context)?.let { timestamp ->
Text(
text = timestamp,
style = MaterialTheme.typography.bodySmall,
@@ -657,7 +673,7 @@ private fun SessionItem(
}
if (session.messageCount > 0) {
Text(
text = "${session.messageCount} msgs",
text = "${session.messageCount}" + stringResource(R.string.drawer_msgs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -673,7 +689,7 @@ private fun SessionItem(
) {
Icon(
Icons.Filled.Star,
contentDescription = if (pinned) "Unpin session" else "Pin session",
contentDescription = if (pinned) stringResource(R.string.drawer_unpin_session) else stringResource(R.string.drawer_pin_session),
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
@@ -686,7 +702,7 @@ private fun SessionItem(
) {
Icon(
Icons.Filled.MoreVert,
contentDescription = "Session actions",
contentDescription = stringResource(R.string.drawer_session_actions),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
@@ -696,7 +712,7 @@ private fun SessionItem(
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = { Text("Rename") },
text = { Text(stringResource(R.string.drawer_rename)) },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
},
@@ -706,7 +722,7 @@ private fun SessionItem(
},
)
DropdownMenuItem(
text = { Text(if (archived) "Restore" else "Archive") },
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
Icon(
Icons.Filled.Archive,
@@ -726,7 +742,7 @@ private fun SessionItem(
DropdownMenuItem(
text = {
Text(
text = "Delete",
text = stringResource(R.string.drawer_delete),
color = MaterialTheme.colorScheme.error,
)
},
@@ -747,22 +763,22 @@ private fun SessionItem(
}
}
private fun sessionTimestampText(session: ChatSession, locale: Locale): String? {
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
val hasDistinctActivity =
session.lastActivityAt > 0L &&
session.startTimestamp > 0L &&
session.lastActivityAt != session.startTimestamp
val prefix = if (hasDistinctActivity) "Active" else "Started"
return "$prefix ${formatTimestamp(timestamp, locale)}"
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
}
private fun formatTimestamp(millis: Long, locale: Locale): String {
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
val now = System.currentTimeMillis()
val diff = now - millis
return when {
diff < 60_000 -> "Just now"
diff < 60_000 -> context.getString(R.string.drawer_just_now)
diff < 3_600_000 -> "${diff / 60_000}m ago"
diff < 86_400_000 -> SimpleDateFormat("h:mm a", locale).format(Date(millis))
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
@@ -39,6 +39,8 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
// ---------------------------------------------------------------------------
// Session-path summary — the glanceable "which transport/route am I on" view
@@ -68,8 +70,7 @@ import com.hermesandroid.relay.network.upstream.ServerCapabilities
* raw token NEVER reaches the user.
*/
internal data class SessionPathTransport(
val friendlyName: String,
val descriptor: String,
val type: String,
val icon: ImageVector?,
/** True only for the gateway transport — gates the "Live thinking" chip. */
val isGateway: Boolean,
@@ -78,37 +79,48 @@ internal data class SessionPathTransport(
internal fun sessionPathTransport(resolvedTransport: String): SessionPathTransport =
when (resolvedTransport) {
"gateway" -> SessionPathTransport(
friendlyName = "Gateway",
descriptor = "live thinking",
type = "gateway",
icon = Icons.Filled.Bolt,
isGateway = true,
)
"sessions" -> SessionPathTransport(
friendlyName = "Sessions API",
descriptor = "streaming",
type = "sessions",
icon = null,
isGateway = false,
)
"completions" -> SessionPathTransport(
friendlyName = "Chat Completions",
descriptor = "streaming",
type = "completions",
icon = null,
isGateway = false,
)
"runs" -> SessionPathTransport(
friendlyName = "Runs API",
descriptor = "structured streaming",
type = "runs",
icon = null,
isGateway = false,
)
else -> SessionPathTransport(
friendlyName = "Direct chat",
descriptor = "streaming",
type = "other",
icon = null,
isGateway = false,
)
}
@Composable
internal fun transportFriendlyName(type: String): String = when (type) {
"gateway" -> stringResource(R.string.session_path_gateway)
"sessions" -> stringResource(R.string.session_path_sessions_api)
"completions" -> stringResource(R.string.session_path_chat_completions)
"runs" -> stringResource(R.string.session_path_runs_api)
else -> stringResource(R.string.session_path_direct_chat)
}
@Composable
internal fun transportDescriptor(type: String): String = when (type) {
"gateway" -> stringResource(R.string.session_path_live_thinking)
"runs" -> stringResource(R.string.session_path_structured_streaming)
else -> stringResource(R.string.session_path_streaming)
}
/**
* One capability the session may or may not expose, paired with whether it is
* actually available right now. [available] is set strictly from a confirming
@@ -116,7 +128,7 @@ internal fun sessionPathTransport(resolvedTransport: String): SessionPathTranspo
* the detail list can show it muted instead of hiding it (never-hide principle).
*/
internal data class SessionCapability(
val label: String,
val type: String,
val available: Boolean,
val reason: String? = null,
)
@@ -134,6 +146,7 @@ internal data class SessionCapability(
* - Voice → `voiceReady` (standard dashboard voice OR relay voice — whichever
* the connection actually has).
*/
@Composable
internal fun sessionCapabilities(
transport: SessionPathTransport,
gatewayAvailability: GatewayAvailability,
@@ -144,52 +157,62 @@ internal fun sessionCapabilities(
): List<SessionCapability> {
val liveThinkingReason = when {
transport.isGateway -> null
gatewayAvailability == GatewayAvailability.Unknown -> "Checking gateway…"
gatewayAvailability == GatewayAvailability.Unknown -> stringResource(R.string.session_path_checking_gateway)
gatewayAvailability == GatewayAvailability.SignInRequired ->
"Sign in under Manage for the live-thinking gateway."
else -> "Available on the gateway transport — this session streams over the API server."
stringResource(R.string.session_path_signin_for_gateway)
else -> stringResource(R.string.session_path_gateway_api_server)
}
return listOf(
SessionCapability(
label = "Live thinking",
type = "live_thinking",
available = transport.isGateway,
reason = liveThinkingReason,
),
SessionCapability(
label = "Media",
type = "media",
available = relayConnected,
reason = when {
relayConnected -> null
relayConfigured -> "Relay paired but not connected."
else -> "Pair the relay to send and receive media."
relayConfigured -> stringResource(R.string.session_path_relay_not_connected)
else -> stringResource(R.string.session_path_pair_relay_media)
},
),
SessionCapability(
label = "Terminal",
type = "terminal",
available = relayConnected,
reason = when {
relayConnected -> null
relayConfigured -> "Relay paired but not connected."
else -> "Pair the relay for terminal access."
relayConfigured -> stringResource(R.string.session_path_relay_not_connected)
else -> stringResource(R.string.session_path_pair_relay_terminal)
},
),
SessionCapability(
label = "Voice",
type = "voice",
available = voiceReady,
reason = if (voiceReady) null else "Voice not ready on this connection.",
reason = if (voiceReady) null else stringResource(R.string.session_path_voice_not_ready),
),
SessionCapability(
label = "Threads",
type = "threads",
available = threadsActive,
reason = if (threadsActive) {
null
} else {
"Pair the relay and turn on “Let Hermes message me” so the agent can open Threads."
stringResource(R.string.session_path_threads_pair_relay)
},
),
)
}
@Composable
internal fun capabilityLabel(type: String): String = when (type) {
"live_thinking" -> stringResource(R.string.session_path_live_thinking)
"media" -> stringResource(R.string.session_path_media)
"terminal" -> stringResource(R.string.session_path_terminal)
"voice" -> stringResource(R.string.session_path_voice)
"threads" -> stringResource(R.string.session_path_threads)
else -> type
}
/**
* The always-visible session-path summary that anchors the Connection section:
* a friendly transport + route line, then a strip of chips for capabilities
@@ -226,13 +249,15 @@ internal fun SessionPathSummary(
)
}
val routeSuffix = routeLabel?.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
val friendlyName = transportFriendlyName(transport.type)
val descriptor = transportDescriptor(transport.type)
Text(
text = buildString {
append(transport.friendlyName)
append(friendlyName)
append(routeSuffix)
if (transport.descriptor.isNotBlank()) {
if (descriptor.isNotBlank()) {
append(" — ")
append(transport.descriptor)
append(descriptor)
}
},
style = MaterialTheme.typography.bodyMedium,
@@ -255,16 +280,17 @@ internal fun SessionPathSummary(
verticalAlignment = Alignment.CenterVertically,
) {
activeCaps.forEach { cap ->
if (cap.label == "Threads") {
val label = capabilityLabel(cap.type)
if (cap.type == "threads") {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
CapabilityChip(label = cap.label)
CapabilityChip(label = label)
BetaChip()
}
} else {
CapabilityChip(label = cap.label)
CapabilityChip(label = label)
}
}
}
@@ -307,6 +333,8 @@ internal fun SessionPathDetails(
serverCapabilities: ServerCapabilities,
modifier: Modifier = Modifier,
) {
val friendlyName = transportFriendlyName(transport.type)
var expanded by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
Row(
@@ -319,7 +347,7 @@ internal fun SessionPathDetails(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (expanded) "Hide session details" else "Session details",
text = if (expanded) stringResource(R.string.session_path_hide_details) else stringResource(R.string.session_path_details),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
@@ -329,16 +357,16 @@ internal fun SessionPathDetails(
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription = if (expanded) "Hide session details" else "Show session details",
contentDescription = if (expanded) stringResource(R.string.session_path_hide_details_cd) else stringResource(R.string.session_path_show_details_cd),
tint = MaterialTheme.colorScheme.primary,
)
}
if (expanded) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
DetailRow(label = "Transport", value = transport.friendlyName)
DetailRow(label = "Route", value = routeLabel?.takeIf { it.isNotBlank() } ?: "—")
DetailChipRow(label = "Relay state") {
DetailRow(label = stringResource(R.string.session_path_transport), value = friendlyName)
DetailRow(label = stringResource(R.string.session_path_route), value = routeLabel?.takeIf { it.isNotBlank() } ?: "—")
DetailChipRow(label = stringResource(R.string.session_path_relay_state)) {
SessionPathConnectionChip(relayConnectionState)
}
@@ -362,8 +390,8 @@ internal fun SessionPathDetails(
Spacer(modifier = Modifier.size(2.dp))
DetailRow(label = "API", value = apiServerUrl, monospace = true)
DetailRow(label = "Relay", value = relayUrl, monospace = true)
DetailRow(label = stringResource(R.string.session_path_api), value = apiServerUrl, monospace = true)
DetailRow(label = stringResource(R.string.session_path_relay), value = relayUrl, monospace = true)
}
}
}
@@ -387,20 +415,20 @@ internal fun TransportTierStepper(
val status = remember(streamingEndpoint, gatewayAvailability, serverCapabilities) {
resolveChatTransportStatus(streamingEndpoint, gatewayAvailability, serverCapabilities)
}
// basic → best, paired with whether THIS server exposes the tier.
// basic -> best, paired with whether THIS server exposes the tier.
val tiers = listOf(
Triple(ChatTransportTier.Completions, "Chat Completions", serverCapabilities.portable),
Triple(ChatTransportTier.Runs, "Runs API", serverCapabilities.runs),
Triple(ChatTransportTier.Sessions, "Sessions API", serverCapabilities.sessionsChatStream),
Triple(ChatTransportTier.Completions, stringResource(R.string.session_path_chat_completions), serverCapabilities.portable),
Triple(ChatTransportTier.Runs, stringResource(R.string.session_path_runs_api), serverCapabilities.runs),
Triple(ChatTransportTier.Sessions, stringResource(R.string.session_path_sessions_api), serverCapabilities.sessionsChatStream),
Triple(
ChatTransportTier.Gateway,
"Gateway · live thinking",
stringResource(R.string.session_path_gateway_live_thinking),
gatewayAvailability == GatewayAvailability.Ready,
),
)
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Transport path · basic → best",
text = stringResource(R.string.session_path_transport_path_ladder),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -470,9 +498,9 @@ private fun TransportTierRow(
)
Text(
text = when {
isActive -> "active"
available -> "available"
else -> "not exposed by this server"
isActive -> stringResource(R.string.session_path_active)
available -> stringResource(R.string.session_path_available)
else -> stringResource(R.string.session_path_not_exposed)
},
style = MaterialTheme.typography.labelSmall,
color = if (isActive) {
@@ -536,6 +564,7 @@ private fun DetailChipRow(label: String, chip: @Composable () -> Unit) {
@Composable
private fun CapabilityDetailRow(cap: SessionCapability) {
val label = capabilityLabel(cap.type)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -543,7 +572,7 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
) {
Icon(
imageVector = if (cap.available) Icons.Filled.Check else Icons.Filled.Remove,
contentDescription = if (cap.available) "Available" else "Unavailable",
contentDescription = if (cap.available) stringResource(R.string.session_path_available) else stringResource(R.string.session_path_unavailable),
tint = if (cap.available) {
MaterialTheme.colorScheme.primary
} else {
@@ -553,7 +582,7 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = cap.label,
text = label,
style = MaterialTheme.typography.bodyMedium,
color = if (cap.available) {
MaterialTheme.colorScheme.onSurface
@@ -578,22 +607,22 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
private fun SessionPathConnectionChip(state: ConnectionState) {
val (label, bg, fg) = when (state) {
ConnectionState.Connected -> Triple(
"Connected",
stringResource(R.string.session_path_connected),
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.onPrimaryContainer,
)
ConnectionState.Connecting -> Triple(
"Connecting…",
stringResource(R.string.session_path_connecting),
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.onTertiaryContainer,
)
ConnectionState.Reconnecting -> Triple(
"Reconnecting…",
stringResource(R.string.session_path_reconnecting),
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.onTertiaryContainer,
)
ConnectionState.Disconnected -> Triple(
"Disconnected",
stringResource(R.string.session_path_disconnected),
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -24,8 +24,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.dp
import androidx.annotation.StringRes
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.PairingPreferences
/**
@@ -83,7 +86,7 @@ fun SessionTtlPickerDialog(
},
title = {
Text(
text = "Keep this pairing for…",
text = stringResource(R.string.ttl_title),
style = MaterialTheme.typography.titleLarge
)
},
@@ -93,17 +96,16 @@ fun SessionTtlPickerDialog(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Your phone will reconnect automatically during " +
"this window. After it expires you'll need to re-pair.",
text = stringResource(R.string.ttl_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Transport hint / Tailscale helper line
val helperLine = when {
isTailscaleDetected -> "Transport: Tailscale detected"
transportHint.equals("wss", ignoreCase = true) -> "Transport: TLS (wss://)"
transportHint.equals("ws", ignoreCase = true) -> "Transport: plain ws://"
isTailscaleDetected -> stringResource(R.string.ttl_helper_tailscale)
transportHint.equals("wss", ignoreCase = true) -> stringResource(R.string.ttl_helper_tls)
transportHint.equals("ws", ignoreCase = true) -> stringResource(R.string.ttl_helper_plain)
else -> null
}
if (helperLine != null) {
@@ -134,7 +136,7 @@ fun SessionTtlPickerDialog(
onClick = { selectedIndex = index }
)
Text(
text = option.label,
text = stringResource(option.labelRes),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp)
)
@@ -160,10 +162,7 @@ fun SessionTtlPickerDialog(
.height(16.dp)
)
Text(
text = "This device will stay paired until you " +
"revoke it manually from Relay sessions. " +
"Only choose this if you control the network " +
"— LAN, Tailscale, VPN, or TLS.",
text = stringResource(R.string.ttl_never_expire_warning),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -175,12 +174,12 @@ fun SessionTtlPickerDialog(
TextButton(
onClick = { onConfirm(options[selectedIndex].seconds) }
) {
Text("Pair")
Text(stringResource(R.string.ttl_pair))
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text("Cancel")
Text(stringResource(R.string.ttl_cancel))
}
}
)
@@ -190,16 +189,16 @@ fun SessionTtlPickerDialog(
* A single entry in the TTL picker. `seconds == 0` means "never expire"
* (wire contract alignment — matches `ttl_seconds: 0` on the QR payload).
*/
data class TtlOption(val label: String, val seconds: Long)
data class TtlOption(@StringRes val labelRes: Int, val seconds: Long)
/** The canonical set of TTL options shown in the picker. */
fun ttlPickerOptions(): List<TtlOption> = listOf(
TtlOption("1 day", 24L * 60 * 60),
TtlOption("7 days", 7L * 24 * 60 * 60),
TtlOption("30 days", 30L * 24 * 60 * 60),
TtlOption("90 days", 90L * 24 * 60 * 60),
TtlOption("1 year", 365L * 24 * 60 * 60),
TtlOption("Never expire", PairingPreferences.TTL_NEVER),
TtlOption(R.string.ttl_option_1d, 24L * 60 * 60),
TtlOption(R.string.ttl_option_7d, 7L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_30d, 30L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_90d, 90L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_1y, 365L * 24 * 60 * 60),
TtlOption(R.string.ttl_option_never, PairingPreferences.TTL_NEVER),
)
/** Fallback default when no previous selection and no QR hint — 30 days. */
@@ -1,8 +1,11 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.BrandPalette
import com.hermesandroid.relay.ui.theme.BrandPalettes
@@ -36,18 +39,44 @@ data class SphereReactivity(
* renderer feeds it (the chat/voice [MorphingSphere] does not). */
val gaze: Boolean = false,
) {
/** Human-readable capability list for the picker ("Voice · Tools"). */
/** Ordered list of enabled capability flags (locale-independent so test
* assertions stay stable). The UI maps each flag to a localized label via
* [reactivityLabels]. */
fun flags(): List<ReactivityFlag> = buildList {
if (voice) add(ReactivityFlag.VOICE)
if (tools) add(ReactivityFlag.TOOLS)
if (intensity) add(ReactivityFlag.ACTIVITY)
if (gaze) add(ReactivityFlag.GAZE)
}
/** Legacy locale-independent summary for backward compat. Prefer [flags] +
* [reactivityLabels] in UI code. */
fun summary(): String {
val on = buildList {
if (voice) add("Voice")
if (tools) add("Tools")
if (intensity) add("Activity")
if (gaze) add("Gaze")
}
val on = flags().map { it.name.lowercase().replaceFirstChar { c -> c.uppercase() } }
return if (on.isEmpty()) "Static" else on.joinToString(" · ")
}
}
/** Individual capability flags exposed by a [SphereReactivity]. Used as
* locale-independent keys; the UI maps each to a localized label. */
enum class ReactivityFlag { VOICE, TOOLS, ACTIVITY, GAZE }
/** Pre-composed localized summary string from a list of [ReactivityFlag],
* joined with the middle-dot separator. */
@androidx.compose.runtime.Composable
fun reactivityLabels(flags: List<ReactivityFlag>): String {
if (flags.isEmpty()) return stringResource(R.string.sphere_reactivity_static)
val labels = flags.map { flag ->
when (flag) {
ReactivityFlag.VOICE -> stringResource(R.string.sphere_reactivity_voice)
ReactivityFlag.TOOLS -> stringResource(R.string.sphere_reactivity_tools)
ReactivityFlag.ACTIVITY -> stringResource(R.string.sphere_reactivity_activity)
ReactivityFlag.GAZE -> stringResource(R.string.sphere_reactivity_gaze)
}
}
return labels.joinToString(" · ")
}
enum class SphereSkinSource { ADAPTIVE, BUILT_IN, USER }
@Immutable
@@ -42,6 +42,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.data.AppAnalytics
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.AppStats
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.viewmodel.VoiceStats
@@ -79,22 +81,22 @@ fun StatsForNerds(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Overview",
text = stringResource(R.string.stats_overview),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Row {
if (expanded) {
TextButton(onClick = { showResetDialog = true }) {
Text("Reset", color = MaterialTheme.colorScheme.error,
Text(stringResource(R.string.stats_reset), color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall)
}
}
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Collapse" else "Expand")
Text(if (expanded) stringResource(R.string.stats_collapse) else stringResource(R.string.stats_expand))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) stringResource(R.string.stats_collapse_cd) else stringResource(R.string.stats_expand_cd),
modifier = Modifier.size(18.dp)
)
}
@@ -106,10 +108,13 @@ fun StatsForNerds(
val tokensPerMsg = if (appStats.totalMessagesSent > 0)
totalTokens / appStats.totalMessagesSent else 0L
Text(
text = "${appStats.totalMessagesSent} messages · " +
"${formatTokenCount(totalTokens)} tokens" +
(if (tokensPerMsg > 0) " (~${formatTokenCount(tokensPerMsg)}/msg)" else "") +
" · ${appStats.sessionCount} sessions",
text = stringResource(
R.string.stats_summary_format,
appStats.totalMessagesSent,
formatTokenCount(totalTokens),
if (tokensPerMsg > 0) stringResource(R.string.stats_tokens_per_msg, formatTokenCount(tokensPerMsg)) else "",
appStats.sessionCount
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -126,7 +131,7 @@ fun StatsForNerds(
// -- Response Time Chart (TTFT) --
if (appStats.recentResponseTimesMs.isNotEmpty()) {
ChartSection(
title = "Time to First Token",
title = stringResource(R.string.stats_time_to_first_token),
data = appStats.recentResponseTimesMs,
unit = "ms"
)
@@ -135,7 +140,7 @@ fun StatsForNerds(
// -- Completion Time Chart --
if (appStats.recentCompletionTimesMs.isNotEmpty()) {
ChartSection(
title = "Completion Time",
title = stringResource(R.string.stats_completion_time),
data = appStats.recentCompletionTimesMs,
unit = "ms"
)
@@ -174,19 +179,19 @@ fun StatsForNerds(
if (showResetDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset Analytics?") },
text = { Text("This will clear all recorded stats including token counts, response times, and stream history. This cannot be undone.") },
title = { Text(stringResource(R.string.stats_reset_analytics_title)) },
text = { Text(stringResource(R.string.stats_reset_analytics_body)) },
confirmButton = {
TextButton(onClick = {
AppAnalytics.resetAll()
showResetDialog = false
}) {
Text("Reset", color = MaterialTheme.colorScheme.error)
Text(stringResource(R.string.stats_reset_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("Cancel")
Text(stringResource(R.string.stats_cancel))
}
}
)
@@ -221,9 +226,9 @@ private fun ChartSection(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StatLabel("Min", formatMsWithSeconds(minVal))
StatLabel("Avg", formatMsWithSeconds(avgVal))
StatLabel("Max", formatMsWithSeconds(maxVal))
StatLabel(stringResource(R.string.stats_min), formatMsWithSeconds(minVal))
StatLabel(stringResource(R.string.stats_avg), formatMsWithSeconds(avgVal))
StatLabel(stringResource(R.string.stats_max), formatMsWithSeconds(maxVal))
}
Spacer(modifier = Modifier.height(8.dp))
@@ -326,7 +331,7 @@ private fun StatLabel(label: String, value: String) {
private fun TokenUsageSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Token Usage",
text = stringResource(R.string.stats_token_usage),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
@@ -334,7 +339,7 @@ private fun TokenUsageSection(stats: AppStats) {
// Current session
Text(
text = "Current Session",
text = stringResource(R.string.stats_current_session),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -342,14 +347,14 @@ private fun TokenUsageSection(stats: AppStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TokenStat("In", formatTokenCount(stats.currentSessionTokensIn))
TokenStat("Out", formatTokenCount(stats.currentSessionTokensOut))
TokenStat("Total", formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
TokenStat(stringResource(R.string.stats_in), formatTokenCount(stats.currentSessionTokensIn))
TokenStat(stringResource(R.string.stats_out), formatTokenCount(stats.currentSessionTokensOut))
TokenStat(stringResource(R.string.stats_total), formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
}
// Lifetime
Text(
text = "Lifetime",
text = stringResource(R.string.stats_lifetime),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -357,9 +362,9 @@ private fun TokenUsageSection(stats: AppStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TokenStat("In", formatTokenCount(stats.totalTokensIn))
TokenStat("Out", formatTokenCount(stats.totalTokensOut))
TokenStat("Total", formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
TokenStat(stringResource(R.string.stats_in), formatTokenCount(stats.totalTokensIn))
TokenStat(stringResource(R.string.stats_out), formatTokenCount(stats.totalTokensOut))
TokenStat(stringResource(R.string.stats_total), formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
}
}
}
@@ -388,7 +393,7 @@ private fun TokenStat(label: String, value: String) {
private fun ConnectionHealthSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Connection Health",
text = stringResource(R.string.stats_connection_health),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
@@ -396,7 +401,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
if (stats.healthChecksTotal == 0) {
Text(
text = "No health checks recorded yet",
text = stringResource(R.string.stats_no_health_checks),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -408,7 +413,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Success rate",
text = stringResource(R.string.stats_success_rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -437,7 +442,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg latency",
text = stringResource(R.string.stats_avg_latency),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -455,7 +460,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Total checks",
text = stringResource(R.string.stats_total_checks),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -478,7 +483,7 @@ private fun StreamStatsSection(stats: AppStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Stream Stats",
text = stringResource(R.string.stats_stream_stats),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
@@ -486,7 +491,7 @@ private fun StreamStatsSection(stats: AppStats) {
if (totalStreams == 0) {
Text(
text = "No streams recorded yet",
text = stringResource(R.string.stats_no_streams),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -498,7 +503,7 @@ private fun StreamStatsSection(stats: AppStats) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Success rate",
text = stringResource(R.string.stats_success_rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -528,9 +533,9 @@ private fun StreamStatsSection(stats: AppStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StreamCounter("Completed", stats.streamsCompleted, MaterialTheme.colorScheme.primary)
StreamCounter("Errored", stats.streamsErrored, MaterialTheme.colorScheme.error)
StreamCounter("Cancelled", stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
StreamCounter(stringResource(R.string.stats_completed), stats.streamsCompleted, MaterialTheme.colorScheme.primary)
StreamCounter(stringResource(R.string.stats_errored), stats.streamsErrored, MaterialTheme.colorScheme.error)
StreamCounter(stringResource(R.string.stats_cancelled), stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
}
// Avg times
@@ -541,7 +546,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg TTFT",
text = stringResource(R.string.stats_avg_ttft),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -558,7 +563,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Peak TTFT",
text = stringResource(R.string.stats_peak_ttft),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -578,7 +583,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Avg completion",
text = stringResource(R.string.stats_avg_completion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -595,7 +600,7 @@ private fun StreamStatsSection(stats: AppStats) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Slowest",
text = stringResource(R.string.stats_slowest),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -636,7 +641,7 @@ private fun StreamCounter(label: String, count: Int, color: Color) {
private fun VoiceSection(stats: VoiceStats) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Voice",
text = stringResource(R.string.stats_voice),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
@@ -644,85 +649,85 @@ private fun VoiceSection(stats: VoiceStats) {
// STT subsection
Text(
text = "STT",
text = stringResource(R.string.stats_stt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
KeyValueRow(
label = "Last transcript",
label = stringResource(R.string.stats_last_transcript),
value = truncate(stats.lastTranscript.ifBlank { "—" }, 80),
)
KeyValueRow(
label = "Latency",
label = stringResource(R.string.stats_latency),
value = if (stats.sttCallCount == 0) "—" else formatMsWithSeconds(stats.lastSttLatencyMs),
)
KeyValueRow(
label = "Uploaded",
label = stringResource(R.string.stats_uploaded),
value = formatByteCount(stats.sttBytesUploaded),
)
KeyValueRow(
label = "Calls",
label = stringResource(R.string.stats_calls),
value = "${stats.sttCallCount}",
)
// TTS subsection
Text(
text = "TTS",
text = stringResource(R.string.stats_tts),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
KeyValueRow(
label = "Last sentence",
label = stringResource(R.string.stats_last_sentence),
value = truncate(stats.lastSynthesizedSentence.ifBlank { "—" }, 80),
)
val avgTtsLatency = if (stats.recentTtsLatenciesMs.isNotEmpty()) {
stats.recentTtsLatenciesMs.sum() / stats.recentTtsLatenciesMs.size
} else 0L
KeyValueRow(
label = "Avg latency (last ${stats.recentTtsLatenciesMs.size.coerceAtLeast(0)})",
label = stringResource(R.string.stats_avg_latency_last_format, stats.recentTtsLatenciesMs.size.coerceAtLeast(0)),
value = if (stats.recentTtsLatenciesMs.isEmpty()) "—" else formatMsWithSeconds(avgTtsLatency),
)
KeyValueRow(
label = "Response chunks",
label = stringResource(R.string.stats_response_chunks),
value = "${stats.currentResponseTtsChunks}",
)
KeyValueRow(
label = "Last chunk gap",
label = stringResource(R.string.stats_last_chunk_gap),
value = if (stats.currentResponseTtsChunks <= 1) "—" else formatMsWithSeconds(stats.lastTtsChunkGapMs),
)
KeyValueRow(
label = "Received",
label = stringResource(R.string.stats_received),
value = formatByteCount(stats.ttsBytesReceived),
)
KeyValueRow(
label = "Calls",
label = stringResource(R.string.stats_calls),
value = "${stats.ttsCallCount}",
)
// Barge-in + playback subsection
Text(
text = "Barge-in / Playback",
text = stringResource(R.string.stats_barge_in_playback),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
KeyValueRow(
label = "Barge-in events",
label = stringResource(R.string.stats_barge_in_events),
value = "${stats.bargeInCount}",
)
KeyValueRow(
label = "VAD threshold",
label = stringResource(R.string.stats_vad_threshold),
value = if (stats.vadThresholdMs <= 0) "—" else "${stats.vadThresholdMs} ms",
)
KeyValueRow(
label = "Mode",
label = stringResource(R.string.stats_mode),
value = stats.interactionMode,
)
KeyValueRow(
label = "Queue depth",
label = stringResource(R.string.stats_queue_depth),
value = "${stats.ttsQueueDepth}",
)
KeyValueRow(
label = "Player",
label = stringResource(R.string.stats_player),
value = stats.playerState,
)
}
@@ -734,7 +739,7 @@ private fun VoiceSection(stats: VoiceStats) {
private fun ToolCallsSection(events: List<ToolCallEvent>) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Tool Calls",
text = stringResource(R.string.stats_tool_calls),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
@@ -754,9 +759,9 @@ private fun ToolCallRow(event: ToolCallEvent, nowMs: Long) {
else -> MaterialTheme.colorScheme.error
}
val statusLabel = when {
!event.isComplete -> "running"
event.success == true -> "ok"
else -> "failed"
!event.isComplete -> stringResource(R.string.stats_running)
event.success == true -> stringResource(R.string.stats_ok)
else -> stringResource(R.string.stats_failed)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
@@ -861,14 +866,15 @@ private fun truncate(text: String, limit: Int): String =
if (text.length <= limit) text else "${text.take(limit - 1)}…"
/** Relative timestamp like "just now" / "3s ago" / "2m ago" / "1h ago". */
@Composable
private fun relativeTime(eventMs: Long, nowMs: Long): String {
val deltaMs = (nowMs - eventMs).coerceAtLeast(0L)
val seconds = deltaMs / 1000
return when {
seconds < 2 -> "just now"
seconds < 60 -> "${seconds}s ago"
seconds < 3600 -> "${seconds / 60}m ago"
seconds < 86400 -> "${seconds / 3600}h ago"
else -> "${seconds / 86400}d ago"
seconds < 2 -> stringResource(R.string.stats_just_now)
seconds < 60 -> stringResource(R.string.stats_seconds_ago, seconds)
seconds < 3600 -> stringResource(R.string.stats_minutes_ago, seconds / 60)
seconds < 86400 -> stringResource(R.string.stats_hours_ago, seconds / 3600)
else -> stringResource(R.string.stats_days_ago, seconds / 86400)
}
}
@@ -36,10 +36,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
@@ -79,7 +81,7 @@ fun SubagentLane(
val laneLabel = calls.firstNotNullOfOrNull { call ->
call.taskLabel?.takeIf { it.isNotBlank() }
} ?: "Agent ${taskIndex + 1}"
} ?: stringResource(R.string.subagent_lane_label_fallback, taskIndex + 1)
// Lane duration: first child start → last child completion.
val duration = run {
@@ -90,12 +92,18 @@ fun SubagentLane(
} else null
}
val toolCountLabel = "${calls.size} tool${if (calls.size == 1) "" else "s"}"
val toolCountLabel = stringResource(R.string.subagent_lane_tool_count, calls.size)
val statusMeta = when {
anyRunning -> "$runningCount running"
anyRunning -> stringResource(R.string.subagent_lane_running_count, runningCount)
duration != null -> "$toolCountLabel · $duration"
else -> toolCountLabel
}
val laneA11y = stringResource(R.string.subagent_lane_a11y, laneLabel, statusMeta) +
if (anyFailed) ", " + stringResource(R.string.cd_subagent_failed) else ""
val completionLabel = stringResource(R.string.cd_subagent_completed)
val collapseLabel = stringResource(R.string.cd_subagent_collapse)
val expandLabel = stringResource(R.string.cd_subagent_expand)
val failureLabel = stringResource(R.string.cd_subagent_failed)
var expanded by remember { mutableStateOf(!allComplete) }
@@ -111,8 +119,7 @@ fun SubagentLane(
.padding(start = 8.dp)
.height(IntrinsicSize.Min)
.semantics {
contentDescription = "Subagent $laneLabel, $statusMeta" +
if (anyFailed) ", failed" else ""
contentDescription = laneA11y
},
) {
// Guide rail
@@ -165,7 +172,7 @@ fun SubagentLane(
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (anyFailed) Icons.Filled.Close else Icons.Filled.Check,
contentDescription = if (anyFailed) "Failed" else "Completed",
contentDescription = if (anyFailed) failureLabel else completionLabel,
modifier = Modifier.size(14.dp),
tint = if (anyFailed) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
@@ -176,7 +183,7 @@ fun SubagentLane(
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) collapseLabel else expandLabel,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -0,0 +1,146 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.HermesProcessNotification
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
/**
* Compact transcript treatment for the user-role process events that upstream
* Hermes injects when background work completes or matches a watch pattern.
*
* This component is intentionally quieter than a user bubble: the row is
* machine-authored process state, while the following assistant message is the
* conversational response. Command and output detail remain selectable behind
* progressive disclosure.
*/
@Composable
fun SyntheticProcessNotificationNotice(
notification: HermesProcessNotification,
modifier: Modifier = Modifier,
) {
val detail = notification.detail?.let(::sanitizeTerminalText)
val hasDetail = !detail.isNullOrBlank()
var expanded by rememberSaveable(notification.processId, notification.headline) {
mutableStateOf(false)
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier
.widthIn(max = 560.dp)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(
enabled = hasDetail,
onClickLabel = if (expanded) "Collapse process output" else "Expand process output",
) { expanded = !expanded }
.semantics {
contentDescription = buildString {
append("Background process notice. ")
append(notification.headline)
if (hasDetail) {
append(if (expanded) ". Output expanded" else ". Output collapsed")
}
}
if (hasDetail) {
stateDescription = if (expanded) "Output expanded" else "Output collapsed"
}
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.62f),
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = notification.headline,
style = relayMetadataStyle(),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
modifier = Modifier.weight(1f),
)
if (hasDetail) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "output",
style = relayMetadataStyle(),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.58f),
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.58f),
modifier = Modifier.size(16.dp),
)
}
}
AnimatedVisibility(visible = expanded && hasDetail) {
SelectionContainer {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 192.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.verticalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
text = detail.orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
)
}
}
}
}
}
}
@@ -26,10 +26,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.hermesandroid.relay.R
/**
* Inline search row that appears below the terminal top app bar.
@@ -85,7 +87,7 @@ fun TerminalSearchBar(
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
placeholder = { Text("Search scrollback") },
placeholder = { Text(stringResource(R.string.term_search_placeholder)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -106,7 +108,7 @@ fun TerminalSearchBar(
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "Previous match",
contentDescription = stringResource(R.string.term_search_cd_previous),
)
}
IconButton(
@@ -115,13 +117,13 @@ fun TerminalSearchBar(
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Next match",
contentDescription = stringResource(R.string.term_search_cd_next),
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close search",
contentDescription = stringResource(R.string.term_search_cd_close),
)
}
}
@@ -43,6 +43,8 @@ import com.hermesandroid.relay.auth.PairedSession
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import java.text.DateFormat
import java.util.Date
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* Bottom-sheet dialog showing full metadata for a single terminal tab plus
@@ -110,9 +112,9 @@ fun TerminalSessionInfoSheet(
Column {
Text(
text = if (tab.displayName.isNullOrBlank()) {
"Tab ${tab.tabId}"
stringResource(R.string.term_info_tab_format, tab.tabId)
} else {
"Tab ${tab.tabId} · ${tab.displayName}"
stringResource(R.string.term_info_tab_name_format, tab.tabId, tab.displayName)
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
@@ -141,27 +143,27 @@ fun TerminalSessionInfoSheet(
) {
StatusChip(
label = when {
tab.attached -> "Attached"
tab.attaching -> "Attaching…"
tab.error != null -> "Error"
!tab.userStarted -> "Not started"
else -> "Detached"
tab.attached -> stringResource(R.string.term_info_attached)
tab.attaching -> stringResource(R.string.term_info_attaching)
tab.error != null -> stringResource(R.string.term_info_error)
!tab.userStarted -> stringResource(R.string.term_info_not_started)
else -> stringResource(R.string.term_info_detached)
},
isPositive = tab.attached,
)
if (tab.tmuxAvailable) {
StatusChip(label = "tmux", isPositive = true)
StatusChip(label = stringResource(R.string.term_info_tmux), isPositive = true)
}
}
HorizontalDivider()
// Per-tab metadata.
InfoRow("PID", tab.pid?.toString() ?: "—")
InfoRow("Shell", tab.shell ?: "—", monospace = true)
InfoRow("Grid", "${tab.cols} × ${tab.rows}")
InfoRow(stringResource(R.string.term_info_pid), tab.pid?.toString() ?: "—")
InfoRow(stringResource(R.string.term_info_shell), tab.shell ?: "—", monospace = true)
InfoRow(stringResource(R.string.term_info_grid), stringResource(R.string.term_info_grid_format, tab.cols, tab.rows))
if (tab.error != null) {
InfoRow("Last error", tab.error, valueColor = MaterialTheme.colorScheme.error)
InfoRow(stringResource(R.string.term_info_last_error), tab.error, valueColor = MaterialTheme.colorScheme.error)
}
HorizontalDivider()
@@ -172,7 +174,7 @@ fun TerminalSessionInfoSheet(
// PairedDevicesScreen + ConnectionInfoSheet for visual parity.
if (pairedSession != null) {
Text(
text = "Relay session",
text = stringResource(R.string.term_info_relay_session),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -182,12 +184,12 @@ fun TerminalSessionInfoSheet(
size = TransportSecuritySize.Row,
)
InfoRow(
label = "Expires",
label = stringResource(R.string.term_info_expires),
value = formatExpiry(pairedSession.expiresAt),
)
if (pairedSession.grants.isNotEmpty()) {
Text(
text = "Channel grants",
text = stringResource(R.string.term_info_channel_grants),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -203,7 +205,7 @@ fun TerminalSessionInfoSheet(
}
} else {
Text(
text = "No paired session",
text = stringResource(R.string.term_info_no_paired_session),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -227,7 +229,7 @@ fun TerminalSessionInfoSheet(
},
modifier = Modifier.weight(1f),
) {
Text("Start session")
Text(stringResource(R.string.term_info_start_session))
}
} else {
OutlinedButton(
@@ -237,7 +239,7 @@ fun TerminalSessionInfoSheet(
},
modifier = Modifier.weight(1f),
) {
Text("Reattach")
Text(stringResource(R.string.term_info_reattach))
}
}
OutlinedButton(
@@ -248,7 +250,7 @@ fun TerminalSessionInfoSheet(
enabled = canCloseTab,
modifier = Modifier.weight(1f),
) {
Text("Close tab")
Text(stringResource(R.string.term_info_close_tab))
}
}
@@ -269,7 +271,7 @@ fun TerminalSessionInfoSheet(
),
modifier = Modifier.fillMaxWidth(),
) {
Text("Kill session")
Text(stringResource(R.string.term_info_kill_session))
}
}
@@ -278,7 +280,7 @@ fun TerminalSessionInfoSheet(
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text("Done")
Text(stringResource(R.string.term_info_done))
}
}
@@ -303,8 +305,8 @@ private fun RenameRow(
value = draft,
onValueChange = { draft = it.take(40) },
singleLine = true,
label = { Text("Name") },
placeholder = { Text("e.g. build, logs, claude") },
label = { Text(stringResource(R.string.term_info_name)) },
placeholder = { Text(stringResource(R.string.term_info_name_placeholder)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onCommit(draft) }),
modifier = Modifier.weight(1f),
@@ -313,7 +315,7 @@ private fun RenameRow(
onClick = { onCommit(draft) },
enabled = isDirty,
) {
Text(if (draft.isBlank() && currentName.isNotEmpty()) "Clear" else "Save")
Text(if (draft.isBlank() && currentName.isNotEmpty()) stringResource(R.string.term_info_clear) else stringResource(R.string.term_info_save))
}
}
}
@@ -368,6 +370,7 @@ private fun InfoRow(
@Composable
private fun GrantChipLocal(channel: String, expiresAt: Long?) {
val neverText = stringResource(R.string.term_info_never)
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
@@ -378,7 +381,7 @@ private fun GrantChipLocal(channel: String, expiresAt: Long?) {
text = buildString {
append(channel)
append(" · ")
append(if (expiresAt == null) "never" else formatExpiryShort(expiresAt))
append(if (expiresAt == null) neverText else formatExpiryShort(expiresAt))
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -386,8 +389,9 @@ private fun GrantChipLocal(channel: String, expiresAt: Long?) {
}
}
@Composable
private fun formatExpiry(epochSeconds: Long?): String {
if (epochSeconds == null) return "Never"
if (epochSeconds == null) return stringResource(R.string.term_info_never)
return try {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(epochSeconds * 1000L))
@@ -26,9 +26,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.TerminalViewModel
/**
@@ -88,7 +90,7 @@ fun TerminalTabBar(
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "New terminal tab",
contentDescription = stringResource(R.string.cd_new_terminal_tab),
tint = if (canAddMore) {
MaterialTheme.colorScheme.onSurface
} else {
@@ -188,7 +190,7 @@ private fun TerminalTabChip(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close tab ${tab.tabId}",
contentDescription = stringResource(R.string.cd_close_tab, tab.tabId),
tint = fg,
modifier = Modifier.size(12.dp),
)
@@ -38,10 +38,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.diagnostics.CheckStatus
import com.hermesandroid.relay.diagnostics.StatusCheck
@@ -95,7 +97,7 @@ fun TimelineView(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Timeline",
text = stringResource(R.string.timeline_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -110,7 +112,7 @@ fun TimelineView(
if (buckets.isEmpty()) {
Text(
text = "No activity yet",
text = stringResource(R.string.timeline_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -219,7 +221,7 @@ fun StatusCheckTimeline(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Status checks",
text = stringResource(R.string.timeline_status_checks),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -238,7 +240,7 @@ fun StatusCheckTimeline(
if (checks.isEmpty()) {
Text(
text = "No checks yet — connect to a server to populate diagnostics.",
text = stringResource(R.string.timeline_checks_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -361,7 +363,7 @@ private fun StatusCheckRow(
if (hasDetail) {
Text(
text = "Tap for log detail",
text = stringResource(R.string.timeline_tap_detail),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -49,11 +49,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
@Composable
@@ -115,10 +117,10 @@ fun ToolProgressCard(
val toolIcon = toolIcon(toolCall.name)
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
isPreparing -> "preparing"
else -> "running"
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
isPreparing -> stringResource(R.string.tool_preparing_a11y)
else -> stringResource(R.string.tool_progress_status_running)
}
// Slow alpha breathe on the tool icon while preparing — an indeterminate
@@ -137,12 +139,14 @@ fun ToolProgressCard(
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
String.format("%.1fs", seconds)
} else null
val durationDescription = duration?.let { stringResource(R.string.tool_duration_a11y, it) }.orEmpty()
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription)
Card(
modifier = modifier
.fillMaxWidth()
.semantics {
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
contentDescription = toolDescription
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
@@ -239,7 +243,7 @@ fun ToolProgressCard(
Column(modifier = Modifier.padding(top = 8.dp)) {
toolCall.runId?.takeIf { it.isNotBlank() }?.let { runId ->
Text(
text = "Run: $runId",
text = stringResource(R.string.tool_run_id, runId),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -259,7 +263,7 @@ fun ToolProgressCard(
toolCall.args?.let { args ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Arguments:",
text = stringResource(R.string.tool_arguments),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -275,7 +279,7 @@ fun ToolProgressCard(
val preview = compactToolDetail(error)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Error:",
text = stringResource(R.string.tool_error),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
@@ -292,7 +296,7 @@ fun ToolProgressCard(
val preview = compactToolDetail(result)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Result:",
text = stringResource(R.string.tool_result),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -23,6 +23,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurityKind
@@ -195,20 +197,21 @@ private fun RenderBadge(
* Label copy uses "Plain" rather than "Insecure" — amber, not red, and
* matches the UX pass where plaintext LAN is treated as informational.
*/
@Composable
fun insecureReasonLabel(reason: String?, activeRole: String? = null): String {
val roleLabel = when (activeRole?.lowercase()) {
"lan" -> "Plain (on LAN)"
"tailscale" -> "Plain (on Tailscale)"
"public" -> "Plain (on public URL)"
"lan" -> stringResource(R.string.transport_badge_insecure_lan)
"tailscale" -> stringResource(R.string.transport_badge_insecure_tailscale)
"public" -> stringResource(R.string.transport_badge_insecure_public)
null, "" -> null
else -> "Plain (on $activeRole)"
else -> stringResource(R.string.transport_badge_insecure_role_format, activeRole)
}
if (roleLabel != null) return roleLabel
return when (reason) {
"lan_only" -> "Plain (LAN only)"
"tailscale_vpn" -> "Plain (Tailscale)"
"local_dev" -> "Plain (dev only)"
else -> "Plain (no TLS)"
"lan_only" -> stringResource(R.string.transport_badge_insecure_lan_only)
"tailscale_vpn" -> stringResource(R.string.transport_badge_insecure_tailscale_vpn)
"local_dev" -> stringResource(R.string.transport_badge_insecure_dev)
else -> stringResource(R.string.transport_badge_insecure_no_tls)
}
}
@@ -220,7 +223,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
TransportSecurityState.AllSecure -> {
val green = Color(0xFF2E7D32)
BadgeAppearance(
label = "Secure \u2014 TLS",
label = stringResource(R.string.transport_badge_secure_tls),
bg = green.copy(alpha = 0.14f),
fg = green,
)
@@ -229,7 +232,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
// Amber (not red): secure fallback exists, so this is informational.
val amber = Color(0xFFF9A825)
BadgeAppearance(
label = "Mixed \u2014 secure fallback available",
label = stringResource(R.string.transport_badge_mixed),
bg = amber.copy(alpha = 0.16f),
fg = amber,
)
@@ -237,7 +240,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
TransportSecurityState.AllInsecure -> {
val red = MaterialTheme.colorScheme.error
BadgeAppearance(
label = "All routes plain \u2014 dev only",
label = stringResource(R.string.transport_badge_plain),
bg = red.copy(alpha = 0.16f),
fg = red,
)
@@ -255,7 +258,7 @@ private fun resolveAppearance(
if (isSecure) {
val green = Color(0xFF2E7D32)
return BadgeAppearance(
label = "Secure (TLS)",
label = stringResource(R.string.transport_badge_secure_tls_short),
bg = green.copy(alpha = 0.14f),
fg = green,
)
@@ -320,35 +323,35 @@ private fun connSecAppearance(security: ConnectionSecurity): ConnSecAppearance {
val red = MaterialTheme.colorScheme.error
return when (security.level) {
ConnectionSecurityLevel.Tls -> ConnSecAppearance(
label = "Encrypted · TLS",
label = stringResource(R.string.transport_badge_encrypted_tls),
icon = Icons.Filled.Lock,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Overlay -> ConnSecAppearance(
label = "Encrypted · ${security.mechanism}",
label = stringResource(R.string.transport_badge_encrypted_mech_format, security.mechanism),
icon = Icons.Filled.Shield,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Mixed -> ConnSecAppearance(
label = "Mixed routes",
label = stringResource(R.string.transport_badge_mixed_routes),
icon = Icons.Filled.Shield,
bg = amber.copy(alpha = 0.16f),
fg = amber,
)
ConnectionSecurityLevel.Plain -> ConnSecAppearance(
label = if (security.mechanism.isNotBlank() && security.mechanism != "Plain") {
"Not encrypted · ${security.mechanism}"
stringResource(R.string.transport_badge_not_encrypted_mech_format, security.mechanism)
} else {
"Not encrypted"
stringResource(R.string.transport_badge_not_encrypted)
},
icon = Icons.Filled.LockOpen,
bg = red.copy(alpha = 0.16f),
fg = red,
)
ConnectionSecurityLevel.Unknown -> ConnSecAppearance(
label = "Checking…",
label = stringResource(R.string.transport_badge_checking),
icon = Icons.Filled.Shield,
bg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
fg = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -405,11 +408,11 @@ fun SurfaceSecurityGlyph(
val green = Color(0xFF2E7D32)
val amber = Color(0xFFF9A825)
val (icon, tint, desc) = when (kind) {
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, "Encrypted (TLS)")
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, "Encrypted")
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, stringResource(R.string.transport_badge_glyph_tls))
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, stringResource(R.string.transport_badge_glyph_encrypted))
// Per-route plaintext is amber (informational), not red — a secure
// route may exist alongside it.
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, "Not encrypted")
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, stringResource(R.string.transport_badge_glyph_not_encrypted))
}
Icon(
imageVector = icon,
@@ -31,6 +31,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* v0.4.1 — sideload-only "unattended access" toggle row + one-time
@@ -94,19 +96,16 @@ fun UnattendedAccessRow(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Unattended Access",
text = stringResource(R.string.unattended_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = when {
!masterEnabled -> "Requires Agent Control — " +
"enable the master switch above first."
enabled -> "On — agent may wake the screen " +
"and act while you're away."
else -> "Off — bridge actions only land when " +
"the screen is already on."
!masterEnabled -> stringResource(R.string.unattended_requires_master)
enabled -> stringResource(R.string.unattended_on)
else -> stringResource(R.string.unattended_off)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -130,10 +129,7 @@ fun UnattendedAccessRow(
}
Text(
text = "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.",
text = stringResource(R.string.unattended_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -204,16 +200,12 @@ private fun KeyguardDetectedAlert() {
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Keyguard detected",
text = stringResource(R.string.unattended_keyguard_detected),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "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.",
text = stringResource(R.string.unattended_keyguard_body),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -246,81 +238,54 @@ private fun UnattendedScaryDialog(
tint = MaterialTheme.colorScheme.error,
)
},
title = { Text("Enable Unattended Access?") },
title = { Text(stringResource(R.string.unattended_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Unattended access lets your Hermes agent wake " +
"the screen and drive your phone while you're not " +
"watching it. This is a powerful capability with " +
"real risk — make sure you understand it before " +
"enabling.",
text = stringResource(R.string.unattended_dialog_intro),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "What it does:",
text = stringResource(R.string.unattended_dialog_what_it_does),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "• Each incoming bridge command holds a screen-" +
"bright wake lock so taps and gestures land on a " +
"lit display.\n" +
"• On lock screens with no credential set (None " +
"or Swipe), the system is asked to dismiss the " +
"keyguard so the agent can reach app UIs.\n" +
"• The 'Hermes has device control' notification — " +
"posted by the master Agent Control switch, not " +
"this toggle — stays visible so you can see the " +
"bridge is running.",
text = stringResource(R.string.unattended_dialog_what_it_does_body),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "Limitation: credential locks cannot be dismissed.",
text = stringResource(R.string.unattended_dialog_limitation),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.error,
)
Text(
text = if (credentialLockDetected) {
"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."
stringResource(R.string.unattended_dialog_limitation_lock_detected)
} else {
"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."
stringResource(R.string.unattended_dialog_limitation_no_lock)
},
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "How to disable:",
text = stringResource(R.string.unattended_dialog_how_to_disable),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "• 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.",
text = stringResource(R.string.unattended_dialog_how_to_disable_body),
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("I understand — enable")
Text(stringResource(R.string.unattended_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.unattended_dialog_cancel)) }
},
)
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
@@ -32,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
/**
* Global single-line banner rendered at the top of [RelayApp]'s scaffold
@@ -107,7 +109,7 @@ fun UnattendedGlobalBanner(
) {
PulsingStatusDot(color = amberFill)
Text(
text = "Unattended access ON — agent can wake and drive this device",
text = stringResource(R.string.unattended_banner_text),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = amberOn,
@@ -43,12 +43,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hermesandroid.relay.BuildConfig
import com.hermesandroid.relay.R
import com.hermesandroid.relay.update.UpdateAvailabilitySource
import com.hermesandroid.relay.update.UpdateDismissalPreferences
import com.hermesandroid.relay.update.UpdateStatus
@@ -318,21 +320,21 @@ fun UpdateAvailableBanner(
when (status) {
is UpdateStatus.Available -> {
title = "Update available"
subtitle = "${displayVersion(status.versionLabel)} is ready to install."
actionLabel = "Update"
title = stringResource(R.string.update_banner_available)
subtitle = stringResource(R.string.update_banner_subtitle_available, displayVersion(status.versionLabel))
actionLabel = stringResource(R.string.update_banner_update)
showDismiss = true
}
is UpdateStatus.Downloading -> {
title = "Downloading update…"
title = stringResource(R.string.update_banner_downloading)
subtitle = displayVersion(status.versionLabel)
actionLabel = null
showDismiss = true
}
is UpdateStatus.Downloaded -> {
title = "Update ready — restart"
subtitle = "${displayVersion(status.versionLabel)} downloaded. Restart to finish."
actionLabel = "Restart"
title = stringResource(R.string.update_banner_ready)
subtitle = stringResource(R.string.update_banner_subtitle_downloaded, displayVersion(status.versionLabel))
actionLabel = stringResource(R.string.update_banner_restart)
showDismiss = false
}
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> return
@@ -416,7 +418,7 @@ fun UpdateAvailableBanner(
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Dismiss",
contentDescription = stringResource(R.string.update_banner_cd_dismiss),
tint = contentColor,
)
}
@@ -28,6 +28,8 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.update.AvailableUpdate
/**
@@ -80,12 +82,12 @@ fun UpdateBanner(
modifier = Modifier.weight(1f),
) {
Text(
text = "Update available — v${update.latestVersion}",
text = stringResource(R.string.update_banner_available),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = "You're on ${update.currentVersion}. Tap Update to download.",
text = stringResource(R.string.update_banner_body_fmt, update.currentVersion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
@@ -104,12 +106,12 @@ fun UpdateBanner(
),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 4.dp),
) {
Text("Update")
Text(stringResource(R.string.update_banner_update))
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Dismiss",
contentDescription = stringResource(R.string.update_banner_cd_dismiss),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
@@ -93,6 +93,8 @@ import com.hermesandroid.relay.viewmodel.VoiceUiState
import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.SharedFlow
import java.io.File
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
@@ -509,7 +511,7 @@ fun VoiceModeOverlay(
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.size(4.dp))
Text("Retry")
Text(stringResource(R.string.voice_overlay_retry))
}
}
}
@@ -662,7 +664,7 @@ private fun VoiceMicButton(
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = "Voice mic",
contentDescription = stringResource(R.string.voice_overlay_mic_cd),
tint = Color.White,
modifier = Modifier.size(iconSize.dp),
)
@@ -678,12 +680,13 @@ private fun voiceStateToSphereState(state: VoiceState): SphereState = when (stat
VoiceState.Idle -> SphereState.Idle
}
@Composable
private fun stateHint(state: VoiceState): String = when (state) {
VoiceState.Idle -> "Tap the mic to speak"
VoiceState.Listening -> "Listening..."
VoiceState.Transcribing -> "Transcribing..."
VoiceState.Thinking -> "Thinking..."
VoiceState.Speaking -> "Speaking..."
VoiceState.Idle -> stringResource(R.string.voice_overlay_tap_mic)
VoiceState.Listening -> stringResource(R.string.voice_overlay_listening)
VoiceState.Transcribing -> stringResource(R.string.voice_overlay_transcribing)
VoiceState.Thinking -> stringResource(R.string.voice_overlay_thinking)
VoiceState.Speaking -> stringResource(R.string.voice_overlay_speaking)
VoiceState.Error -> ""
}
@@ -713,10 +716,11 @@ internal fun pendingVoiceTranscriptText(
return if (alreadyRendered) null else transcribed
}
@Composable
private fun InteractionMode.label(): String = when (this) {
InteractionMode.TapToTalk -> "Tap to talk"
InteractionMode.HoldToTalk -> "Hold to talk"
InteractionMode.Continuous -> "Continuous"
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk)
InteractionMode.HoldToTalk -> stringResource(R.string.voice_overlay_hold_to_talk)
InteractionMode.Continuous -> stringResource(R.string.voice_overlay_continuous)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -747,17 +751,17 @@ private fun VoiceSessionPill(
) {
val engineText = voiceEngineLabel(engineMode)
val providerText = voiceProviderLabel(provider, model, voice, outputEnabled)
val profileText = profileName?.takeIf { it.isNotBlank() } ?: "default profile"
val profileText = profileName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_default_profile)
val scopeText = when (configScope) {
"profile" -> "profile voice"
"relay" -> "relay voice"
"global" -> "global voice"
"profile" -> stringResource(R.string.voice_overlay_profile_voice)
"relay" -> stringResource(R.string.voice_overlay_relay_voice)
"global" -> stringResource(R.string.voice_overlay_global_voice)
else -> null
}
val headlineText = if (focusMode) {
"$engineText / $profileText / $providerText"
} else {
"${stateHint(uiState.state).ifBlank { "Voice ready" }} / $engineText / $providerText"
"${stateHint(uiState.state).ifBlank { stringResource(R.string.voice_overlay_voice_ready) }} / $engineText / $providerText"
}
Surface(
modifier = modifier,
@@ -801,7 +805,7 @@ private fun VoiceSessionPill(
)
}
Text(
text = "Voice",
text = stringResource(R.string.voice_overlay_voice),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -819,7 +823,7 @@ private fun VoiceSessionPill(
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse voice controls" else "Expand voice controls",
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
@@ -829,7 +833,7 @@ private fun VoiceSessionPill(
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Exit voice mode",
contentDescription = stringResource(R.string.voice_overlay_exit_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
@@ -911,7 +915,7 @@ private fun VoiceSessionPill(
modifier = Modifier.weight(1f),
) {
Text(
if (focusMode) "Compact" else "Focus",
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -923,13 +927,13 @@ private fun VoiceSessionPill(
},
modifier = Modifier.weight(1f),
) {
Text("Overlay", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(stringResource(R.string.voice_overlay_overlay), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
TextButton(
onClick = onExit,
modifier = Modifier.weight(1f),
) {
Text("Exit", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(stringResource(R.string.voice_overlay_exit), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
// Settings link (4c): exit voice mode before navigating
// so the overlay isn't left floating over the Voice
@@ -942,7 +946,7 @@ private fun VoiceSessionPill(
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Voice settings",
contentDescription = stringResource(R.string.voice_overlay_settings_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -1168,32 +1172,35 @@ private fun VoiceHandoffStrip(
}
}
@Composable
private fun voiceProviderLabel(
provider: String?,
model: String?,
voice: String?,
outputEnabled: Boolean?,
): String {
if (outputEnabled == false) return "output off"
val providerPart = provider?.takeIf { it.isNotBlank() } ?: "provider ..."
if (outputEnabled == false) return stringResource(R.string.voice_overlay_provider_output_off)
val providerPart = provider?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_provider_placeholder)
val modelPart = model?.takeIf { it.isNotBlank() }
val voicePart = voice?.takeIf { it.isNotBlank() }
return listOfNotNull(providerPart, modelPart, voicePart).joinToString(" / ")
}
@Composable
private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
"realtime_agent" -> "Realtime Agent"
"hermes_voice_output" -> "Hermes voice"
null, "" -> "Voice engine ..."
"realtime_agent" -> stringResource(R.string.voice_overlay_engine_realtime)
"hermes_voice_output" -> stringResource(R.string.voice_overlay_engine_hermes)
null, "" -> stringResource(R.string.voice_overlay_engine_placeholder)
else -> engineMode
.replace('_', ' ')
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
@Composable
private fun InteractionMode.shortLabel(): String = when (this) {
InteractionMode.TapToTalk -> "Tap"
InteractionMode.HoldToTalk -> "Hold"
InteractionMode.Continuous -> "Auto"
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap)
InteractionMode.HoldToTalk -> stringResource(R.string.voice_overlay_hold)
InteractionMode.Continuous -> stringResource(R.string.voice_overlay_auto)
}
/**
@@ -1232,9 +1239,9 @@ private fun CompactTranscriptRow(
val isVoiceActionBubble = message.role == MessageRole.ASSISTANT &&
message.id.startsWith("voice-intent-")
val caption = when {
isVoiceActionBubble -> "ACTION"
message.role == MessageRole.USER -> "YOU"
else -> "AGENT"
isVoiceActionBubble -> stringResource(R.string.voice_overlay_caption_action)
message.role == MessageRole.USER -> stringResource(R.string.voice_overlay_caption_you)
else -> stringResource(R.string.voice_overlay_caption_agent)
}
val captionColor = when {
isVoiceActionBubble -> MaterialTheme.colorScheme.tertiary
@@ -1301,12 +1308,20 @@ private fun CompactTranscriptRow(
@Composable
private fun VoiceToolStatusRow(toolCall: ToolCall) {
val status = when {
// Internal enum-style status drives both color and the localized label
// shown to the user. Localized text comes from stringResource() in the
// Composable so VoiceModeOverlay can switch locales mid-session.
val statusKey = when {
toolCall.isComplete && toolCall.success == true -> "done"
toolCall.isComplete && toolCall.success == false -> "failed"
else -> "running"
}
val statusColor = when (status) {
val statusLabel = when (statusKey) {
"done" -> stringResource(R.string.voice_overlay_tool_status_done)
"failed" -> stringResource(R.string.voice_overlay_tool_status_failed)
else -> stringResource(R.string.voice_overlay_tool_status_running)
}
val statusColor = when (statusKey) {
"done" -> MaterialTheme.colorScheme.primary
"failed" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.tertiary
@@ -1348,7 +1363,7 @@ private fun VoiceToolStatusRow(toolCall: ToolCall) {
modifier = Modifier.weight(1f),
)
Text(
text = duration?.let { "$status $it" } ?: status,
text = duration?.let { "$statusLabel $it" } ?: statusLabel,
style = MaterialTheme.typography.labelSmall,
color = statusColor,
)
@@ -1407,9 +1422,9 @@ private fun HermesConfirmationCard(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = { onAnswer("allow") }) { Text("Allow") }
TextButton(onClick = { onAnswer("deny") }) { Text("Deny") }
TextButton(onClick = { onAnswer("cancel") }) { Text("Cancel") }
TextButton(onClick = { onAnswer("allow") }) { Text(stringResource(R.string.voice_overlay_allow)) }
TextButton(onClick = { onAnswer("deny") }) { Text(stringResource(R.string.voice_overlay_deny)) }
TextButton(onClick = { onAnswer("cancel") }) { Text(stringResource(R.string.voice_overlay_cancel)) }
}
}
}
@@ -1472,9 +1487,9 @@ private fun DestructiveCountdownRow(
) {
Text(
text = if (label.isNotBlank()) {
"$label in ${durationSec}s — say cancel to stop"
stringResource(R.string.voice_overlay_confirming_format, label, durationSec)
} else {
"Confirming…"
stringResource(R.string.voice_overlay_confirming)
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
@@ -1548,7 +1563,7 @@ private fun BackgroundRunChip(
// no pulse, no live ticker.
val dotAlpha = if (done) 1f else pulse
val title = when (display.phase) {
BackgroundRunPhase.RECONNECTING -> "Reconnecting — your task is still running"
BackgroundRunPhase.RECONNECTING -> stringResource(R.string.voice_overlay_reconnecting_task)
BackgroundRunPhase.DELIVERING -> display.message
BackgroundRunPhase.RUNNING -> display.message
BackgroundRunPhase.DONE -> display.message
@@ -1559,8 +1574,11 @@ private fun BackgroundRunChip(
?.let { add(it.trimEnd('.', '…')) }
if (display.completedToolCount > 0) {
add(
"${display.completedToolCount} step" +
if (display.completedToolCount == 1) "" else "s"
stringResource(
if (display.completedToolCount == 1) R.string.voice_overlay_steps_one
else R.string.voice_overlay_steps_many,
display.completedToolCount,
)
)
}
if (display.queuedCount > 0) {
@@ -1677,7 +1695,7 @@ private fun PermissionDeniedChip(
)
}
Text(
text = "OPEN",
text = stringResource(R.string.voice_overlay_open),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
@@ -17,6 +17,8 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -56,7 +58,7 @@ fun WhatsNewDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text("What's New")
Text(stringResource(R.string.whats_new_title))
notes.version?.let { version ->
Text(
text = version,
@@ -76,7 +78,7 @@ fun WhatsNewDialog(
) {
if (notes.groups.isEmpty()) {
Text(
text = notes.fallback ?: "No release notes available.",
text = notes.fallback ?: stringResource(R.string.whats_new_no_notes),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -85,7 +87,7 @@ fun WhatsNewDialog(
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Got it")
Text(stringResource(R.string.whats_new_got_it))
}
}
)
@@ -314,6 +316,6 @@ private fun loadWhatsNew(context: Context): String {
return try {
context.assets.open("whats_new.txt").bufferedReader().readText()
} catch (_: Exception) {
"No release notes available."
context.getString(R.string.whats_new_no_notes)
}
}
@@ -50,6 +50,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
@@ -134,25 +135,21 @@ fun OnboardingScreen(
if (showSkipConfirm) {
AlertDialog(
onDismissRequest = { showSkipConfirm = false },
title = { Text("Skip setup?") },
title = { Text(stringResource(R.string.onboarding_skip_setup_title)) },
text = {
Text(
"No problem — you can explore the demo to see how Hermes-Relay works, " +
"and connect your own Hermes server anytime from Settings → Connections. " +
"Relay pairing for power tools can be added later too."
)
Text(stringResource(R.string.onboarding_skip_setup_text))
},
confirmButton = {
TextButton(onClick = {
showSkipConfirm = false
onComplete()
}) {
Text("Skip for now")
Text(stringResource(R.string.onboarding_skip_for_now))
}
},
dismissButton = {
TextButton(onClick = { showSkipConfirm = false }) {
Text("Go back")
Text(stringResource(R.string.onboarding_go_back))
}
}
)
@@ -171,7 +168,7 @@ fun OnboardingScreen(
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
TextButton(onClick = { showSkipConfirm = true }) {
Text(
text = "Skip",
text = stringResource(R.string.onboarding_skip),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -244,7 +241,7 @@ fun OnboardingScreen(
}
}
) {
Text(text = "Back")
Text(text = stringResource(R.string.onboarding_back))
}
}
if (pagerState.currentPage == 0) {
@@ -260,7 +257,13 @@ fun OnboardingScreen(
}
}
) {
Text(text = if (pagerState.currentPage == lastPage - 1) "Connect" else "Next")
Text(
text = if (pagerState.currentPage == lastPage - 1) {
stringResource(R.string.onboarding_connect)
} else {
stringResource(R.string.onboarding_next)
}
)
}
}
}
@@ -274,8 +277,8 @@ private fun WelcomePage() {
val context = LocalContext.current
OnboardingPage(
icon = Icons.Outlined.RocketLaunch,
title = "Hermes-Relay for Android",
description = "Chat with Hermes and manage your dashboard from your phone.",
title = stringResource(R.string.onboarding_welcome_title),
description = stringResource(R.string.onboarding_welcome_description),
transparentHero = true,
heroContent = {
Box(modifier = Modifier.fillMaxSize()) {
@@ -298,7 +301,7 @@ private fun WelcomePage() {
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = "Welcome",
text = stringResource(R.string.onboarding_welcome_badge),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -316,7 +319,7 @@ private fun WelcomePage() {
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "Hermes logo",
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.size(30.dp)
)
Text(
@@ -334,17 +337,17 @@ private fun WelcomePage() {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Chat & Manage",
description = "Connect to your running Hermes dashboard and API. No Relay install or pairing required.",
label = stringResource(R.string.onboarding_chat_manage_label),
description = stringResource(R.string.onboarding_chat_manage_description),
)
SetupPathSummary(
label = "Power tools",
description = "Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.",
label = stringResource(R.string.onboarding_power_tools_label),
description = stringResource(R.string.onboarding_power_tools_description),
)
}
Text(
text = "The setup guide has copy/paste commands when you need to start Hermes on a computer or server.",
text = stringResource(R.string.onboarding_setup_guide_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -367,7 +370,7 @@ private fun WelcomePage() {
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("Setup Guide")
Text(stringResource(R.string.onboarding_setup_guide))
}
OutlinedButton(
@@ -384,12 +387,12 @@ private fun WelcomePage() {
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("Hermes Docs")
Text(stringResource(R.string.onboarding_hermes_docs))
}
}
Text(
text = "Hermes API server docs",
text = stringResource(R.string.onboarding_api_server_docs),
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline
),
@@ -437,24 +440,24 @@ private fun SetupPathSummary(
private fun ChatPage() {
OnboardingPage(
icon = Icons.Outlined.Forum,
title = "Chat",
description = "Your Hermes agent, streaming in real time.",
title = stringResource(R.string.onboarding_chat_title),
description = stringResource(R.string.onboarding_chat_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Streaming",
description = "Live responses with tool progress, markdown, and rich cards as the agent works.",
label = stringResource(R.string.onboarding_streaming_label),
description = stringResource(R.string.onboarding_streaming_description),
)
SetupPathSummary(
label = "Profiles",
description = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
label = stringResource(R.string.onboarding_profiles_label),
description = stringResource(R.string.onboarding_profiles_description),
)
SetupPathSummary(
label = "Voice",
description = "Tap the mic to talk. Speech runs through your Hermes server — no extra install.",
label = stringResource(R.string.onboarding_voice_label),
description = stringResource(R.string.onboarding_voice_description),
)
}
}
@@ -464,24 +467,24 @@ private fun ChatPage() {
private fun ManagePage() {
OnboardingPage(
icon = Icons.Filled.Settings,
title = "Manage",
description = "Your Hermes dashboard, pocket-sized.",
title = stringResource(R.string.onboarding_manage_title),
description = stringResource(R.string.onboarding_manage_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Control",
description = "Profiles, skills, automations, MCP servers, models, and provider keys.",
label = stringResource(R.string.onboarding_control_label),
description = stringResource(R.string.onboarding_control_description),
)
SetupPathSummary(
label = "Skills hub",
description = "Search the hub, read a skill before installing, and install from your phone.",
label = stringResource(R.string.onboarding_skills_hub_label),
description = stringResource(R.string.onboarding_skills_hub_description),
)
SetupPathSummary(
label = "One sign-in",
description = "Signing into the dashboard once also unlocks voice for this connection.",
label = stringResource(R.string.onboarding_one_sign_in_label),
description = stringResource(R.string.onboarding_one_sign_in_description),
)
}
}
@@ -493,24 +496,24 @@ private fun PowerToolsPage(
) {
OnboardingPage(
icon = Icons.Outlined.Terminal,
title = "Power tools",
description = "Pair the optional Relay when you want more than standard.",
title = stringResource(R.string.onboarding_power_title),
description = stringResource(R.string.onboarding_power_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Terminal",
description = "A real tmux session on your server, in your pocket.",
label = stringResource(R.string.onboarding_terminal_label),
description = stringResource(R.string.onboarding_terminal_description),
)
SetupPathSummary(
label = "Bridge",
description = "Let the agent operate this phone — with safety rails (sideload builds).",
label = stringResource(R.string.onboarding_bridge_label),
description = stringResource(R.string.onboarding_bridge_description),
)
SetupPathSummary(
label = "Realtime",
description = "Provider-native realtime voice agent and profile-aware voice providers.",
label = stringResource(R.string.onboarding_realtime_label),
description = stringResource(R.string.onboarding_realtime_description),
)
OutlinedButton(
onClick = onOpenPermissions,
@@ -522,7 +525,7 @@ private fun PowerToolsPage(
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text("Review permissions")
Text(stringResource(R.string.onboarding_review_permissions))
}
}
}
@@ -1,3 +1,5 @@
@file:Suppress("LocalContextGetResourceValueCall")
package com.hermesandroid.relay.ui.screens
import android.content.Intent
@@ -52,6 +54,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -82,6 +85,11 @@ fun AboutScreen(
val scope = rememberCoroutineScope()
val isDarkTheme = LocalBrand.current.isDark
// Pre-resolved Toast messages (Toast is not a composable scope)
val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked)
val updateCopiedMsg = stringResource(R.string.about_update_copied)
val emDash = stringResource(R.string.about_em_dash)
// Tap-7x unlock state (mirrors the old SettingsScreen locals)
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context).collectAsState(initial = FeatureFlags.isDevBuild)
var versionTapCount by remember { mutableStateOf(0) }
@@ -92,12 +100,12 @@ fun AboutScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("About") },
title = { Text(stringResource(R.string.about_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.about_back),
)
}
},
@@ -117,7 +125,7 @@ fun AboutScreen(
) {
// About section
Text(
text = "About",
text = stringResource(R.string.about_section_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -148,18 +156,18 @@ fun AboutScreen(
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "Hermes-Relay",
contentDescription = stringResource(R.string.about_app_name),
modifier = Modifier.size(80.dp)
)
}
Text(
text = "Hermes-Relay",
text = stringResource(R.string.about_app_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
Text(
text = "Native Android client for Hermes Agent",
text = stringResource(R.string.about_app_tagline),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -170,14 +178,14 @@ fun AboutScreen(
// Version info (dynamic)
val versionName = remember {
try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "—"
} catch (_: Exception) { "—" }
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: emDash
} catch (_: Exception) { emDash }
}
val versionCode = remember {
try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode.toString()
} catch (_: Exception) { "—" }
} catch (_: Exception) { emDash }
}
Row(
@@ -196,19 +204,20 @@ fun AboutScreen(
when {
remaining <= 0 -> {
scope.launch { FeatureFlags.unlockDevOptions(context) }
Toast.makeText(context, "Developer options unlocked", Toast.LENGTH_SHORT).show()
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
versionTapCount = 0
onUnlockDeveloperOptions()
}
remaining <= 3 -> {
Toast.makeText(context, "$remaining taps to unlock developer options", Toast.LENGTH_SHORT).show()
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
Toast.makeText(context, tapsMsg, Toast.LENGTH_SHORT).show()
}
}
},
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Version",
text = stringResource(R.string.about_version),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -229,7 +238,7 @@ fun AboutScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Track",
text = stringResource(R.string.about_track),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -259,15 +268,15 @@ fun AboutScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Relay",
text = stringResource(R.string.about_relay),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val subtitle = when {
ru.updateAvailable && !ru.latest.isNullOrBlank() ->
"Update available — v${ru.current} → v${ru.latest}"
ru.current.isNotBlank() -> "On v${ru.current} — up to date"
else -> "Connected"
stringResource(R.string.about_update_available, ru.current, ru.latest)
ru.current.isNotBlank() -> stringResource(R.string.about_up_to_date, ru.current)
else -> stringResource(R.string.about_connected)
}
Text(
text = subtitle,
@@ -284,17 +293,17 @@ fun AboutScreen(
clipboard.setText(AnnotatedString(ru.updateCommand))
Toast.makeText(
context,
"Update command copied",
updateCopiedMsg,
Toast.LENGTH_SHORT,
).show()
}) {
Text("Copy fix")
Text(stringResource(R.string.about_copy_fix))
}
}
}
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
Text(
text = "Run on your Hermes host, then restart the gateway:\n${ru.updateCommand}",
text = stringResource(R.string.about_update_command_body, ru.updateCommand),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
@@ -317,16 +326,16 @@ fun AboutScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Updates",
text = stringResource(R.string.about_updates),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val subtitle = when (val s = state) {
UpdateCheckResult.Idle -> "Tap to check GitHub for a new release"
UpdateCheckResult.Checking -> "Checking…"
UpdateCheckResult.UpToDate -> "You're on the latest release"
is UpdateCheckResult.Available -> "Update available — v${s.update.latestVersion}"
is UpdateCheckResult.Error -> "Check failed: ${s.message}"
UpdateCheckResult.Idle -> stringResource(R.string.about_updates_tap)
UpdateCheckResult.Checking -> stringResource(R.string.about_updates_checking)
UpdateCheckResult.UpToDate -> stringResource(R.string.about_updates_latest)
is UpdateCheckResult.Available -> stringResource(R.string.about_updates_available, s.update.latestVersion)
is UpdateCheckResult.Error -> stringResource(R.string.about_updates_failed, s.message)
}
Text(
text = subtitle,
@@ -349,9 +358,9 @@ fun AboutScreen(
) {
Text(
text = when (state) {
is UpdateCheckResult.Available -> "Download"
is UpdateCheckResult.Available -> stringResource(R.string.about_download)
UpdateCheckResult.Checking -> "…"
else -> "Check"
else -> stringResource(R.string.about_check)
}
)
}
@@ -378,7 +387,7 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("GitHub")
Text(stringResource(R.string.about_github))
}
OutlinedButton(
onClick = {
@@ -393,7 +402,7 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("App Docs")
Text(stringResource(R.string.about_app_docs))
}
}
Row(
@@ -413,7 +422,7 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("Hermes Docs")
Text(stringResource(R.string.about_hermes_docs))
}
}
@@ -431,17 +440,17 @@ fun AboutScreen(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(6.dp))
Text("Privacy Policy")
Text(stringResource(R.string.about_privacy_policy))
}
// What's New
TextButton(onClick = { showWhatsNew = true }) {
Text("What's New in This Version")
Text(stringResource(R.string.about_whats_new))
}
// Credits
Text(
text = "Axiom Labs \u2764\uFE0F Hermes Agent · Nous Research",
text = stringResource(R.string.about_credits),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
@@ -20,7 +20,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.components.StatsForNerds
import com.hermesandroid.relay.ui.components.TimelineView
@@ -50,12 +52,12 @@ fun AnalyticsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Analytics") },
title = { Text(stringResource(R.string.analytics_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.analytics_back),
)
}
},
@@ -78,13 +80,13 @@ fun AnalyticsScreen(
// and each card's own header below).
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = "Stats for Nerds",
text = stringResource(R.string.analytics_section_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Local, on-device performance and usage metrics.",
text = stringResource(R.string.analytics_section_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -64,13 +64,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.LocalAvailableSphereSkins
import com.hermesandroid.relay.ui.components.SphereRegistry
import com.hermesandroid.relay.ui.components.SphereSkin
import com.hermesandroid.relay.ui.components.SphereSkinSource
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.reactivityLabels
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.AvatarSource
@@ -119,16 +122,16 @@ fun AppearanceSettingsScreen(
pendingDelete?.let { target ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Remove pet?") },
text = { Text("Remove “${target.label}”? You can re-import it later.") },
title = { Text(stringResource(R.string.appearance_remove_pet_title)) },
text = { Text(stringResource(R.string.appearance_remove_pet_body, target.label)) },
confirmButton = {
TextButton(onClick = {
connectionViewModel.deleteUserAvatar(target.id, target.label)
pendingDelete = null
}) { Text("Remove") }
}) { Text(stringResource(R.string.appearance_remove)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.appearance_cancel)) }
},
)
}
@@ -136,12 +139,12 @@ fun AppearanceSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Appearance") },
title = { Text(stringResource(R.string.appearance_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.appearance_back),
)
}
},
@@ -162,7 +165,7 @@ fun AppearanceSettingsScreen(
) {
// Theme gallery section
Text(
text = "Theme",
text = stringResource(R.string.appearance_theme),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -183,8 +186,7 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Pick a look. The brand chrome, accents, and chat " +
"background all follow your choice.",
text = stringResource(R.string.appearance_theme_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -207,7 +209,7 @@ fun AppearanceSettingsScreen(
// Appearance (mode + font) section
Text(
text = "Appearance",
text = stringResource(R.string.appearance_appearance),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -228,13 +230,13 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Light / Dark",
text = stringResource(R.string.appearance_light_dark),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val themeOptions = listOf("auto", "light", "dark")
val themeLabels = listOf("Auto", "Light", "Dark")
val themeLabels = listOf(stringResource(R.string.appearance_theme_auto), stringResource(R.string.appearance_theme_light), stringResource(R.string.appearance_theme_dark))
val selectedIndex = themeOptions.indexOf(theme).coerceAtLeast(0)
// The mode toggle only applies to themes that ship both a
// light and dark palette (Hermes Relay). Fixed-mode themes
@@ -261,10 +263,9 @@ fun AppearanceSettingsScreen(
Text(
text = when (selectedTheme.mode) {
ThemeMode.LIGHT_ONLY ->
"${selectedTheme.label} is a fixed light theme."
stringResource(R.string.appearance_fixed_light, selectedTheme.label)
else ->
"${selectedTheme.label} is a fixed dark theme. " +
"Switch to Hermes Relay for light/dark control."
stringResource(R.string.appearance_fixed_dark, selectedTheme.label)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -278,7 +279,7 @@ fun AppearanceSettingsScreen(
// window.setFontSize through TerminalWebView.
val fontScale by connectionViewModel.fontScale.collectAsState()
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.3f)
val fontScaleLabels = listOf("Small", "Normal", "Large", "Larger")
val fontScaleLabels = listOf(stringResource(R.string.appearance_font_small), stringResource(R.string.appearance_font_normal), stringResource(R.string.appearance_font_large), stringResource(R.string.appearance_font_larger))
// Match the closest stop — float equality is fragile.
val selectedFontScaleIndex = fontScaleOptions
.withIndex()
@@ -287,7 +288,7 @@ fun AppearanceSettingsScreen(
?: 1
Text(
text = "Font size",
text = stringResource(R.string.appearance_font_size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -314,7 +315,7 @@ fun AppearanceSettingsScreen(
// LocalDensity once they tap a stop.
val previewBase = MaterialTheme.typography.bodyMedium
Text(
text = "Aa — sample text",
text = stringResource(R.string.appearance_font_preview),
style = previewBase.copy(
fontSize = previewBase.fontSize * fontScaleOptions[selectedFontScaleIndex]
),
@@ -327,7 +328,7 @@ fun AppearanceSettingsScreen(
// its own label + sample line IN that font so the choice is legible
// before tapping; the selection re-themes every screen live.
Text(
text = "Font",
text = stringResource(R.string.appearance_font),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -351,8 +352,7 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Sets the typeface across the whole app. Code and " +
"timestamps stay monospaced.",
text = stringResource(R.string.appearance_font_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -369,7 +369,7 @@ fun AppearanceSettingsScreen(
// Animation section
Text(
text = "Animation",
text = stringResource(R.string.appearance_animation),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -400,11 +400,11 @@ fun AppearanceSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "ASCII sphere",
text = stringResource(R.string.appearance_ascii_sphere),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Show animated sphere on empty chat screen and ambient mode",
text = stringResource(R.string.appearance_ascii_sphere_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -427,11 +427,11 @@ fun AppearanceSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Behind messages",
text = stringResource(R.string.appearance_behind_messages),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Show subtle sphere animation behind chat messages",
text = stringResource(R.string.appearance_behind_messages_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -448,8 +448,7 @@ fun AppearanceSettingsScreen(
// (including for screen-reader users browsing settings).
if (animEnabled) {
Text(
text = "Tip: long-press the chat background for a fullscreen " +
"ambient sphere; tap anywhere to return.",
text = stringResource(R.string.appearance_ambient_tip),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -461,7 +460,7 @@ fun AppearanceSettingsScreen(
// plus any imported "pets"; add/remove pets in-app below). When the
// sphere avatar is selected its skin chips nest below: avatar → skin.
Text(
text = "Agent avatar",
text = stringResource(R.string.appearance_agent_avatar),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -493,8 +492,7 @@ fun AppearanceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Pick the agent's avatar. Each shows which live " +
"signals it reacts to.",
text = stringResource(R.string.appearance_agent_avatar_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -539,19 +537,17 @@ fun AppearanceSettingsScreen(
modifier = Modifier.size(18.dp),
)
Text(
text = "Add a pet",
text = stringResource(R.string.appearance_add_pet),
modifier = Modifier.padding(start = 6.dp),
)
}
TextButton(onClick = { connectionViewModel.refreshAgentAvatars() }) {
Text("Rescan")
Text(stringResource(R.string.appearance_rescan))
}
}
Text(
text = "Import a pet pack (.zip), or a single image to use as a " +
"static avatar. Generate one with AI or hand-author it — " +
"see docs/pet-spec.md.",
text = stringResource(R.string.appearance_add_pet_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -559,7 +555,7 @@ fun AppearanceSettingsScreen(
// Installed-pet management: a labeled list with per-pet remove.
if (userAvatars.isNotEmpty()) {
Text(
text = "Installed pets",
text = stringResource(R.string.appearance_installed_pets),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -577,7 +573,7 @@ fun AppearanceSettingsScreen(
IconButton(onClick = { pendingDelete = pet }) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Remove ${pet.label}",
contentDescription = stringResource(R.string.appearance_remove_pet_cd, pet.label),
tint = MaterialTheme.colorScheme.error,
)
}
@@ -592,7 +588,7 @@ fun AppearanceSettingsScreen(
val petSpeed by connectionViewModel.petSpeed.collectAsState()
Text(
text = "Playback speed — ${"%.1f".format(java.util.Locale.US, petSpeed)}×",
text = stringResource(R.string.appearance_playback_speed, "%.1f".format(java.util.Locale.US, petSpeed)),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -603,8 +599,7 @@ fun AppearanceSettingsScreen(
steps = 9,
)
Text(
text = "Scales the selected pet's animation speed. " +
"1.0× plays each clip at its authored rate.",
text = stringResource(R.string.appearance_playback_speed_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -616,12 +611,11 @@ fun AppearanceSettingsScreen(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Stabilize frames",
text = stringResource(R.string.appearance_stabilize),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Auto-centers each frame so the pet doesn't drift or jump " +
"between frames (fixes wobbly AI sheets).",
text = stringResource(R.string.appearance_stabilize_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -637,22 +631,20 @@ fun AppearanceSettingsScreen(
// verify look/speed/stabilization without running the agent.
HorizontalDivider()
Text(
text = "Preview",
text = stringResource(R.string.appearance_preview),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val previewStates = remember {
listOf(
Triple("Idle", SphereState.Idle, 0f),
Triple("Thinking", SphereState.Thinking, 0f),
Triple("Working", SphereState.Thinking, 1f),
Triple("Writing", SphereState.Streaming, 0f),
Triple("Speaking", SphereState.Speaking, 0f),
Triple("Listening", SphereState.Listening, 0f),
Triple("Error", SphereState.Error, 0f),
)
}
val previewStates = listOf(
Triple(stringResource(R.string.appearance_state_idle), SphereState.Idle, 0f),
Triple(stringResource(R.string.appearance_state_thinking), SphereState.Thinking, 0f),
Triple(stringResource(R.string.appearance_state_working), SphereState.Thinking, 1f),
Triple(stringResource(R.string.appearance_state_writing), SphereState.Streaming, 0f),
Triple(stringResource(R.string.appearance_state_speaking), SphereState.Speaking, 0f),
Triple(stringResource(R.string.appearance_state_listening), SphereState.Listening, 0f),
Triple(stringResource(R.string.appearance_state_error), SphereState.Error, 0f),
)
var previewIdx by remember { mutableIntStateOf(0) }
var greetKey by remember { mutableIntStateOf(0) }
var overrideState by remember { mutableStateOf<SphereState?>(null) }
@@ -703,7 +695,7 @@ fun AppearanceSettingsScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(onClick = { greetKey++ }) { Text("Greet") }
OutlinedButton(onClick = { greetKey++ }) { Text(stringResource(R.string.appearance_greet)) }
OutlinedButton(onClick = {
// Replay celebrate by driving Speaking → Idle on the
// live instance (the transition fires the one-shot).
@@ -714,12 +706,11 @@ fun AppearanceSettingsScreen(
delay(2500)
overrideState = null
}
}) { Text("Done") }
}) { Text(stringResource(R.string.appearance_done)) }
}
Text(
text = "Tap a state to preview it; Greet/Done replay the one-shot " +
"reactions. Reflects the speed and stabilize settings above.",
text = stringResource(R.string.appearance_preview_tip),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -731,7 +722,7 @@ fun AppearanceSettingsScreen(
HorizontalDivider()
Text(
text = "Sphere skin — choose the orb's look.",
text = stringResource(R.string.appearance_sphere_skin),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
@@ -754,9 +745,7 @@ fun AppearanceSettingsScreen(
HorizontalDivider()
Text(
text = "Add your own: drop a sphere JSON spec into the app's " +
"private spheres/ folder, then reopen this screen. See " +
"docs/sphere-spec.md for the format.",
text = stringResource(R.string.appearance_sphere_custom_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -930,8 +919,8 @@ private fun SphereSkinChip(
val poleA = swatch.getOrElse(0) { MaterialTheme.colorScheme.primary }
val poleB = swatch.getOrElse(1) { poleA }
val capability = buildString {
append(skin.reactivity.summary())
if (skin.source == SphereSkinSource.USER) append(" · Custom")
append(reactivityLabels(skin.reactivity.flags()))
if (skin.source == SphereSkinSource.USER) append(stringResource(R.string.appearance_custom_suffix))
}
Column(
modifier = Modifier
@@ -1074,7 +1063,7 @@ private fun AgentAvatarChip(
maxLines = 1,
)
Text(
text = avatar.reactivity.summary(),
text = reactivityLabels(avatar.reactivity.flags()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -41,8 +41,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayHeroPanel
import com.hermesandroid.relay.ui.components.RelayNavTile
@@ -77,7 +79,7 @@ fun BridgeCoreScreen(
onNavigateToSettings: () -> Unit = {},
returnTitle: String? = null,
returnSubtitle: String = "",
returnLabel: String = "Back",
returnLabel: String = "",
onReturn: (() -> Unit)? = null,
) {
val relayState by connectionViewModel.relayUiState.collectAsState()
@@ -86,24 +88,24 @@ fun BridgeCoreScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bridge") },
title = { Text(stringResource(R.string.bridge_core_title)) },
navigationIcon = {
RelayChromeIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to chat",
contentDescription = stringResource(R.string.bridge_core_back_to_chat),
onClick = onNavigateToChat,
)
},
actions = {
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = "Terminal",
contentDescription = stringResource(R.string.bridge_core_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = "Settings",
contentDescription = stringResource(R.string.bridge_core_settings),
onClick = onNavigateToSettings,
modifier = Modifier.padding(end = 4.dp),
)
@@ -129,25 +131,25 @@ fun BridgeCoreScreen(
icon = Icons.AutoMirrored.Filled.ArrowBack,
title = returnTitle,
subtitle = returnSubtitle,
label = returnLabel,
label = returnLabel.ifBlank { stringResource(R.string.bridge_core_back) },
onClick = onReturn,
)
}
RelayHeroPanel(
title = if (relayConnected) "Phone bridge is paired" else "Bridge Core is waiting",
title = if (relayConnected) stringResource(R.string.bridge_core_paired_title) else stringResource(R.string.bridge_core_waiting_title),
subtitle = if (relayConnected) {
"Terminal, voice, notification, media, and relay-session controls share this grant."
stringResource(R.string.bridge_core_paired_subtitle)
} else {
"Pair Relay to use Terminal and phone bridge tools. Chat and Manage continue over standard Hermes API."
stringResource(R.string.bridge_core_waiting_subtitle)
},
action = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RelayStatusPill(
text = relayState.statusText("connected").lowercase(),
text = relayState.statusText(stringResource(R.string.bridge_core_connected_label)).lowercase(),
active = relayConnected,
)
RelayStatusPill(
text = "safety on",
text = stringResource(R.string.bridge_core_safety_on),
active = true,
)
}
@@ -165,14 +167,13 @@ fun BridgeCoreScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Hermes Bridge Core",
text = stringResource(R.string.bridge_core_hermes_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Relay features for your self-hosted Hermes server: chat, " +
"voice, terminal, notifications, media, and session grants.",
text = stringResource(R.string.bridge_core_hermes_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -191,9 +192,7 @@ fun BridgeCoreScreen(
modifier = Modifier.size(20.dp),
)
Text(
text = "This build does not include AccessibilityService, " +
"screen reading, taps, typing, screenshots, SMS, calls, " +
"or unattended phone control.",
text = stringResource(R.string.bridge_core_no_access_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -214,19 +213,18 @@ fun BridgeCoreScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Relay",
text = stringResource(R.string.bridge_core_relay_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = relayState.statusText("Connected"),
text = relayState.statusText(stringResource(R.string.bridge_core_connected_label2)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Bridge Core uses the same paired relay session as " +
"terminal, notification, media, and voice features.",
text = stringResource(R.string.bridge_core_relay_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -234,44 +232,44 @@ fun BridgeCoreScreen(
}
RelaySectionCaption(
title = "Bridge Surface",
meta = "not hidden in settings",
title = stringResource(R.string.bridge_core_surface_title),
meta = stringResource(R.string.bridge_core_surface_meta),
)
RelayNavTile(
icon = Icons.Filled.Link,
title = "Connections",
subtitle = "Pair, switch, and verify relay routes",
title = stringResource(R.string.bridge_core_connections),
subtitle = stringResource(R.string.bridge_core_connections_sub),
onClick = onNavigateToConnections,
)
RelayNavTile(
icon = Icons.Filled.Code,
title = "Terminal",
subtitle = "Attach to your Hermes relay terminal",
title = stringResource(R.string.bridge_core_terminal_tile),
subtitle = stringResource(R.string.bridge_core_terminal_sub),
onClick = onNavigateToTerminal,
selected = relayConnected,
)
RelayNavTile(
icon = Icons.Filled.GraphicEq,
title = "Voice",
subtitle = "Provider, model, output voice",
title = stringResource(R.string.bridge_core_voice),
subtitle = stringResource(R.string.bridge_core_voice_sub),
onClick = onNavigateToVoiceSettings,
)
RelayNavTile(
icon = Icons.Filled.Notifications,
title = "Notifications",
subtitle = "Shared app notifications",
title = stringResource(R.string.bridge_core_notifications),
subtitle = stringResource(R.string.bridge_core_notifications_sub),
onClick = onNavigateToNotificationCompanion,
)
RelayNavTile(
icon = Icons.Filled.Image,
title = "Media",
subtitle = "Inbound attachments and cache behavior",
title = stringResource(R.string.bridge_core_media),
subtitle = stringResource(R.string.bridge_core_media_sub),
onClick = onNavigateToMediaSettings,
)
RelayNavTile(
icon = Icons.Filled.Devices,
title = "Relay sessions",
subtitle = "Review active grants for this server",
title = stringResource(R.string.bridge_core_relay_sessions),
subtitle = stringResource(R.string.bridge_core_relay_sessions_sub),
onClick = onNavigateToRelaySessions,
)

Some files were not shown because too many files have changed in this diff Show More