Compare commits
94
Commits
@@ -5,13 +5,13 @@ on:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-relay-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-relay-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
permissions:
|
||||
@@ -49,8 +49,8 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify relay-owned version metadata
|
||||
run: python scripts/check-relay-version-sync.py
|
||||
- name: Verify server-owned version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
- name: Install dashboard API test deps
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# branch protection on `main` has a check name it can rely on, regardless
|
||||
# of which paths the PR touches.
|
||||
#
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-relay.yml`,
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-server.yml`,
|
||||
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
|
||||
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
|
||||
# "required status checks" treat a check that doesn't run as failing — so
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Hermes-Relay — Python Relay CI Pipeline
|
||||
# Hermes-Relay — Python Server CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# Python-affecting paths so Android-only changes don't spin up the
|
||||
# server-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check -> focused relay tests
|
||||
# Pipeline: syntax-check -> focused server tests
|
||||
|
||||
name: CI — Relay
|
||||
name: CI — Server
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -26,9 +26,9 @@ on:
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-relay-version-sync.py"
|
||||
- "scripts/bump-relay-version.sh"
|
||||
- ".github/workflows/ci-relay.yml"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
@@ -46,18 +46,18 @@ on:
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-relay-version-sync.py"
|
||||
- "scripts/bump-relay-version.sh"
|
||||
- ".github/workflows/ci-relay.yml"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-relay-${{ github.ref }}
|
||||
group: ci-server-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — py_compile syntax sanity
|
||||
# Python Server — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax check (Python)
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (plugin.relay — canonical location)
|
||||
- name: Syntax check (server/plugin.relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
@@ -87,18 +87,18 @@ jobs:
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Validate Relay version metadata
|
||||
run: python scripts/check-relay-version-sync.py
|
||||
- name: Validate Server version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — focused route/auth/session tests
|
||||
# Python Server — focused route/auth/session tests
|
||||
#
|
||||
# Tests are ADVISORY on dev (push or PR) so WIP commits don't block the
|
||||
# merge queue. Strict on main — the dev → main release-merge PR surfaces
|
||||
# any real failures before release.
|
||||
# ──────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Focused Relay tests (Python)
|
||||
name: Focused Server tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Relay tests
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
@@ -1,16 +1,16 @@
|
||||
# Hermes-Relay — Android App Release Pipeline
|
||||
# Hermes-Relay-Android — Release Pipeline
|
||||
#
|
||||
# Triggered when a version tag (v*) is pushed.
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Relay server/Python package releases use relay-v* tags.
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
|
||||
name: Release
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
@@ -135,7 +135,8 @@ jobs:
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: v${{ needs.validate.outputs.version }}
|
||||
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
@@ -154,7 +155,7 @@ jobs:
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "## Release v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release desktop
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -171,6 +171,10 @@ jobs:
|
||||
- build-cli-binaries
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
- name: Extract desktop version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
@@ -187,13 +191,13 @@ jobs:
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
|
||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay Desktop - ${{ github.ref_name }}
|
||||
# Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
|
||||
**Experimental phase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
name: Release Relay server
|
||||
name: Release Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "relay-v*"
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Relay release
|
||||
name: Validate Server release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -20,15 +20,15 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/relay-v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Relay version sync
|
||||
run: python scripts/check-relay-version-sync.py --expect "$TAG_VERSION"
|
||||
- name: Verify Server version sync
|
||||
run: python scripts/check-server-version-sync.py --expect "$TAG_VERSION"
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
test:
|
||||
name: Test Relay package
|
||||
name: Test Server package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
python -m py_compile plugin/tools/desktop_tool.py
|
||||
python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Run focused Relay tests
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
plugin/tests/test_session_grants.py
|
||||
|
||||
package:
|
||||
name: Build and publish Relay package
|
||||
name: Build and publish Server package
|
||||
needs: [validate, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -89,16 +89,17 @@ jobs:
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: relay-v${{ needs.validate.outputs.version }}
|
||||
tag_name: relay-v${{ needs.validate.outputs.version }}
|
||||
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
tag_name: server-v${{ needs.validate.outputs.version }}
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay server/Python package - relay-v${{ needs.validate.outputs.version }}
|
||||
# Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
|
||||
This release contains the Relay server and Python plugin package.
|
||||
Android app releases use `v*` tags. Desktop CLI releases use
|
||||
`desktop-v*` tags.
|
||||
This release contains the server/Python plugin package.
|
||||
Android releases use `android-v*` tags. Desktop releases use
|
||||
`desktop-v*` tags. Historical server releases before this lane
|
||||
rename used `relay-v*` tags.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
-11
@@ -6,15 +6,153 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Persistent Realtime Agent conversation.** Realtime Agent voice now keeps one provider session/socket open across turns instead of creating a fresh session per utterance, so the provider retains the live conversation (follow-up references work) and turns skip session-setup latency. The relay needed no change — it already supported multiple turns on one socket. A **Voice Settings → Realtime Agent → Persistent session** toggle (default on) falls back to the legacy per-utterance path. See `docs/plans/2026-05-24-realtime-persistent-session.md`.
|
||||
|
||||
- **Background Hermes runs in Realtime Agent voice (ADR 33).** Long Hermes tasks no longer freeze the realtime conversation. A run that exceeds a grace window is promoted to a tracked background task: the provider speaks a short handoff ("I'm on it"), the conversation stays responsive, and the answer is spoken once the run finishes. `hermes_run_task(mode="background")` starts a durable run immediately. New relay events `hermes.run.promoted` and `hermes.run.background_completed`, plus `tier`/`floor` fields on `hermes.run.progress`.
|
||||
|
||||
- **Relay audio floor owner.** A single-owner audio floor (provider / relay-TTS / Android-filler) makes explicit the serialization that the old blocking design provided implicitly, so a completed background result never barges in and two voices never overlap.
|
||||
|
||||
- **Voice Settings → Realtime Agent → Background tasks.** New controls to enable/disable promotion, toggle the spoken handoff, and choose result delivery (speak when idle / notify / show only). A persistent "working on it" chip appears in the voice overlay while a background task runs.
|
||||
|
||||
- **Provider idle-tolerance probe.** `scripts/realtime-provider-idle-probe.py` records a per-provider verdict (hold-floor-ok / needs-keepalive / must-reopen) for holding a realtime socket quiescent during a background run; see `docs/realtime-voice-poc.md`.
|
||||
|
||||
- **Per-route reachability verdicts in the Routes card.** Every route row now shows the result of its last health probe — "Reachable", or "Unreachable" with the actual reason ("TLS failed — server may be http://, not https://", "Connection refused", "No answer (timed out)", "HTTP 404 from /health") — and "Re-check" shows a live checking state instead of doing invisible background work. Verdicts persist between probes so you can see what the network last said.
|
||||
|
||||
- **Manage parity with the hermes-desktop dashboard.** The Manage tab can now do what the desktop dashboard can: **Models** — change the main model from the full provider/model catalog (`/api/model/options` → `/api/model/set`), including the expensive-model confirmation round-trip; new **Keys** tab — view, set (write-only, masked), reveal (server rate-limited), and clear provider keys / env secrets; **Profiles** — create profiles (clone-from-default), edit descriptions, set per-profile models, and **edit SOUL.md** in a full-file editor; **Skills** — browse the multi-source skills hub with search, SKILL.md preview-before-install, install/uninstall (async server-side), and update-all.
|
||||
|
||||
- **Manage data survives app restarts.** The Manage payload cache now mirrors to a plain-JSON file in the app's private cache directory and hydrates at startup, so a cold app launch renders the last-seen dashboard data instantly while fresh data loads quietly behind it. Signing in or out wipes the disk mirror along with the in-memory cache. (Deliberately a flat file rather than encrypted prefs — the payload carries no credentials, and every encrypted-prefs build costs seconds under the Keystore's process-global lock.)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Standard (no-plugin) voice now rides the Hermes dashboard surface.** STT/TTS for the standard route uses the dashboard's `/api/audio/transcribe` + `/api/audio/speak` (the hermes-desktop voice contract) with the same cookie session Manage signs in with — a vanilla hermes-agent install needs no Relay plugin for voice. Previously the client targeted the API server, which has no audio routes, so standard-only voice always failed.
|
||||
|
||||
- **Auto STT/TTS route prefers Relay when paired.** Paired Relay voice is profile-aware and needs no dashboard sign-in; the standard dashboard route is the zero-plugin fallback. Voice Settings now shows live per-route status (ready / sign-in required / unreachable / unsupported build) with a "Sign in via Manage" shortcut, and the Realtime Agent engine is clearly marked as requiring a paired Relay.
|
||||
|
||||
- **Softened the active connection card.** The full-card Electric blue fill on the active connection was overpowering against body text; it now uses a muted indigo wash while small accents keep the vivid brand blue.
|
||||
|
||||
- **Connection wizard capability card now includes Voice.** Finishing setup shows Chat / Manage / Voice / Relay readiness in one card — voice availability (ready / unlocks with dashboard sign-in / build too old) is probed in the same pass, so the result is accurate the moment you connect.
|
||||
|
||||
- **No more relay warnings on standard-only connections.** Voice Settings no longer fetches Relay voice configs (and no longer shows "unavailable" rows or error snackbars) when no Relay is configured — relay-backed sections are replaced by a quiet note that speech uses the server's configured TTS/STT, with Relay pairing called out as the way to pick providers from the phone.
|
||||
|
||||
- **Skills hub opens with featured content.** The browse dialog lists the configured hub sources and the index's featured skills before the first search instead of starting blank.
|
||||
|
||||
- **Onboarding feature pages got real content.** Chat / Manage / Power tools pages now show three concrete feature rows each (streaming + profiles + voice; control + skills hub + one sign-in; terminal + bridge + realtime) instead of a single sentence.
|
||||
|
||||
- **Floating status pill.** The bottom status strip is now an inset rounded capsule floating above the gesture area instead of an edge-to-edge bordered bar that clashed with rounded display corners.
|
||||
|
||||
- **Ambient mode is now a gesture.** The top-bar sphere toggle is gone; long-press the conversation background to enter the fullscreen sphere, tap anywhere to return (a transient "tap to return to chat" pill teaches the exit on entry). Message long-press (copy) is unaffected.
|
||||
|
||||
- **Media settings labeled Relay-only.** The Media screen now states that its inbound-attachment controls apply to Relay-delivered files only, not to standard connections or images you attach in chat.
|
||||
|
||||
- **Quote in reply.** Long-pressing a message now offers Copy and "Quote in reply" — quoting drops the message into the input as a Markdown blockquote.
|
||||
|
||||
- **Share conversation.** A share icon in the chat top bar exports the visible conversation as Markdown through the system share sheet.
|
||||
|
||||
- **Manage cards declutter.** Cards with five or more actions (profiles) keep the three most-used buttons inline and fold the rest behind "More".
|
||||
|
||||
- **Ambient gesture is documented in Appearance.** Settings → Appearance now explains the long-press-to-enter / tap-to-return gesture, keeping it discoverable (including for screen-reader users) without a visible control.
|
||||
|
||||
- **User docs: Quick Start.** New two-minute Quick Start page leads the guide; the dashboard page documents the full phone Manage surface (skills hub, models, keys, profile + SOUL editing); voice docs lead with the standard no-Relay route.
|
||||
|
||||
- **Routes are now editable in Settings → Connections.** The Routes card gains "Add route" plus per-route Edit/Remove (the primary route mirrors the connection's API URL and stays protected) — the standard path's manual equivalent of the Relay QR's multi-endpoint provisioning. Add your server's Tailscale or public URL after the fact and the phone roams to it automatically; the wizard's optional Tailscale field remains the setup-time shortcut.
|
||||
|
||||
- **URL fields accept bare hosts and explain their ports.** Typing `100.71.8.56` (or any bare host/IP) into the API URL, wizard Tailscale, or route-editor fields now saves `http://100.71.8.56:8642` — scheme and API port defaulted, and the route editor previews exactly what will be saved ("Will save: http://100.71.8.56:8642") before you commit. Field copy now states which port is which (API `8642`, dashboard `9119`) and that `https://` should only be used when the server actually has TLS. Route rows display the full URL including the scheme, since an invisible `https` was the classic cause of a route that never won a probe.
|
||||
|
||||
- **Manage remembers its data and pre-warms it.** Dashboard payloads now live in a process-lifetime cache instead of screen state, so leaving and re-entering Manage shows the last data instantly (entries older than 30 s refresh quietly in the background — content stays put, only a thin progress bar shows). When a connection's saved dashboard status says it was reachable and signed in, the app pre-warms all Manage sections at startup (and again after a LAN↔Tailscale route handoff), so even the first open lands on real data. Signing in or out still clears the cache.
|
||||
|
||||
- **Manage's full load dropped from ~40 round trips to ~12.** Every section fetch used to re-run the dashboard auth preamble (status → providers → session → ws-ticket) before its payload — eight sections, strictly one after another, which over a Tailscale link read as 5–10 seconds of "still loading". The preamble is now fetched once per sweep and shared, and the section payloads download concurrently, so a full load costs roughly one preamble plus one payload's worth of latency.
|
||||
|
||||
- **Cold start no longer waits 15 seconds to learn there's no API key.** On devices with StrongBox secure hardware (recent Samsungs), every keystore operation takes ~half a second and they all run one at a time — a measured cold start spent 15 seconds decrypting the credential store before the app could even build its HTTP client, only to find the connection had no API key (the normal local setup). A plain non-sensitive "has API key?" hint now lets key-less connections build the client immediately — chat, health, and the conversation restore start within a couple of seconds — while keyed connections still wait for the real decrypt (a stale hint can only ever make startup slower, never strip auth). The startup checks also now count the route prober's successful health probe as "hermes online" instead of waiting for the client-based probe to repeat the same check.
|
||||
|
||||
- **The startup sphere is now the actual loading screen.** Cold starts used to flash a slideshow of half-ready states — the disconnected "connect" prompt, then the connected state, then the conversation, each revealing separately — because the splash gate released on the first health verdict (often a probe against the old route, moments before the resolver switched) and force-hid itself after 5.5 s no matter what. The sphere now holds until the app is presentable — server answering AND the last conversation restored — or until an unreachable verdict survives a settle window (then the normal UI takes over with its offline status), with a 12 s backstop. While it holds, terminal-style check lines narrate progress at the bottom (state restored · route · hermes online · conversation), so a longer wait reads as work instead of a hang.
|
||||
|
||||
- **Terminal and Settings headers gained back buttons.** Both are pushed destinations (reached from the Chat/Manage header chrome), but neither offered a way back except the system gesture; they now carry the same header back arrow as every other pushed screen. The footer status pill also hugs the bottom edge slightly tighter.
|
||||
|
||||
- **"Use now" no longer silently becomes a preference.** The Routes card's "Use now" is now a true one-time switch: it moves traffic immediately and holds only until the next disconnect, without touching the saved route preference. Making a route sticky is the explicit "Prefer this route" action in the row's ⋮ menu (now a toggle, with "Stop preferring" when set). The Current line says which mode picked the route — automatic, preferred, or "manual (until disconnect)" — and dedicated "Cancel manual switch" / "Stop preferring" actions undo each layer separately. Tailscale is intentionally not auto-preferred: automatic resolution already promotes it the moment the LAN route stops answering, and keeps the faster LAN path when you're home.
|
||||
|
||||
- **Manage loading and overview polish.** The cold-load skeleton is now one progress bar plus quiet content-shaped ghost cards — previously four stacked progress bars with fake narrative labels ("Checking dashboard session"…) that read like three different failures. The cryptic KPI glyphs (`ok / … / !`) are replaced by three cards: section count, a tone-colored dashboard state word (ready / sign-in / offline / error), and the server version (handy for confirming which host answered after a route handoff). The dashboard status banner is now two lines — state + identity with Sign out, then URL · route · checked time — so nothing truncates, and its duplicate "Connection" button is gone (the Connections tile sits directly below).
|
||||
|
||||
- **Manage names its dashboard target and explains per-route sign-in.** The Manage tab now shows exactly which dashboard URL it's talking to ("Dashboard: http://… · Tailscale route") above the content, and "Dashboard unavailable" errors name the URL that failed — the dashboard (`:9119`) is a separate server from the API (`:8642`), so "chat works" never proved Manage's target was reachable. When the resolver has moved Manage onto a different host (e.g. roamed to Tailscale), the sign-in card now explains that dashboard sign-ins are per host and a one-time sign-in on this route keeps both sessions — the same hint voice already had.
|
||||
|
||||
- **Remote access is discoverable, not an easter egg.** The standard setup form now shows a "Remote access — Tailscale URL (optional)" field in the main flow (previously buried under Advanced), with a hint when Tailscale is detected on the phone; the setup result card gains a "Remote" readiness line that calls out LAN-only connections; the "Hermes API unreachable" status now diagnoses the likely cause ("Away from the server's network? Add a Tailscale or public route") instead of just reporting; and the Connections card offers an "Add Tailscale route" shortcut when the phone is on Tailscale but the connection has no Tailscale route.
|
||||
|
||||
- **README + Play listing refresh.** Both rewritten around the standard-first story. The README quick start now mirrors the app's capability card (Chat / Manage / Voice / Remote / Relay), voice is no longer described as relay-only, Manage and remote access become headline features, the desktop CLI section is trimmed and clearly marked alpha (with its planned refocus into a remote "hands" connector), and the stale CI badge, broken in-page anchors, and version-pinned "What's new in v0.6.0" section are gone. The Play listing (`docs/play-store-listing.md`) gets an end-user-first short description, a quick-start beat, Manage/remote-access feature blocks, a corrected no-plugin voice story, and v0.8.1 release notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **App-start UI freeze (frozen sphere) from Keystore lock contention.** Cold starts could freeze the UI for many seconds (logcat: `Skipped 1386 frames`, `Davey! duration=11596ms`): every `EncryptedDashboardCookieStore` eagerly built its Keystore-backed prefs in its constructor — a 1–4 s operation on StrongBox devices that serializes through a process-global Tink lock — and several code paths (Manage section loads, connection validation, the Manage pre-warm) each constructed their own instance, stacking multi-second lock holds that main-thread keystore users then queued behind. The store now builds lazily on first cookie access (always an I/O thread), all dashboard-surface consumers share one cached instance per connection, and the pre-warm uses a single client plus the shared store for its whole sweep instead of one of each per section.
|
||||
|
||||
- **"Re-check" / "Use now" no longer fail silently.** When every saved route failed its probe, the user-triggered re-probe early-returned without publishing anything: the Routes card sat on "Current: Resolving" forever (showing the internal relay URL underneath, which read as "stuck on the internal route") with zero feedback. The probe now always publishes its outcome, the card states "No route reachable — using saved URL …" explicitly, and per-route rows show why each candidate failed. The old 100 ms post-probe delay — always shorter than a real resolve, leaving the follow-up health checks pointed at the stale route — is replaced by actually awaiting the resolve.
|
||||
|
||||
- **Standard (no-Relay) connections now follow LAN ↔ Tailscale network changes.** The ADR 24 network-aware route switching only activated when a Relay socket was open: the connectivity callback registered inside `connect()` and bailed without a socket URL, so a standard connection that left home Wi-Fi kept probing the dead LAN route until the app was backgrounded and reopened. The callback now registers at construction and re-resolves routes (debounced) even with no socket — chat, Manage, and standard voice follow the resolved endpoint automatically.
|
||||
|
||||
- **Standard voice follows the resolved route.** The standard voice client and its availability probe targeted the connection's persisted dashboard URL instead of the resolver's active route, so voice stayed pinned to the LAN host (and gated off) while away from home even after chat had switched to Tailscale. Both now ride `effectiveDashboardUrl`.
|
||||
|
||||
- **Stale probe cache can't pin a dead route.** App-resume and network-change revalidation now clear the endpoint resolver's probe cache, so a route that died moments ago can't win re-resolution for the remainder of its 60-second positive cache window. The periodic health check also escalates two consecutive unreachable probes into a full cache-cleared re-resolve — the safety net for handoffs Android never surfaces as connectivity changes (always-on VPN keeps "internet available" true throughout).
|
||||
|
||||
- **Editing URLs no longer wipes fallback routes.** Saving an API or Relay URL rebuilt the connection's route-candidate list from just the edited URL, silently dropping the setup wizard's Tailscale route (or extra endpoints from a pairing payload). Edits now merge: the touched route is rebuilt, stored extras are preserved verbatim.
|
||||
|
||||
- **Per-route sign-in is explained.** Dashboard sessions are cookie-based and per-host, so a Manage sign-in at home doesn't carry to the Tailscale host. When voice is gated on sign-in because the route moved, Voice Settings and the chat mic toast now say so ("sign in once in Manage on this route") instead of showing a bare sign-in nag that looks broken.
|
||||
|
||||
- **A network change can no longer resurrect a deliberately disconnected relay socket.** The route-switch path force-reconnected whenever the resolved winner differed from the last URL, even after an explicit Disconnect; socket actions are now gated on reconnect intent while route publication for HTTP surfaces continues.
|
||||
|
||||
## [0.8.1] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice mode crash with barge-in on legacy TTS playback.** When barge-in was enabled and the relay served audio over the legacy `/voice/synthesize` (Media3) path, the first agent sentence played for ~2 syllables and then the app crashed with `IllegalStateException: Player is accessed on the wrong thread`. The barge-in listener's `Dispatchers.IO` reader was reading `ExoPlayer.getAudioSessionId()` (a thread-confined accessor) to attach the echo canceller. `VoicePlayer.audioSessionId` now serves a `@Volatile` cache populated from main-thread Media3 callbacks, so it is safe to read from any thread.
|
||||
|
||||
## [0.8.0] - 2026-05-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Provider-native Realtime Agent voice.** Android can opt into a Realtime Agent voice engine where Android streams mic PCM to the relay, xAI or OpenAI owns realtime speech recognition and speech generation, and Hermes remains the governed authority for tools, memory, profiles, confirmations, current-data checks, side effects, and durable transcript context.
|
||||
|
||||
- **Hermes-brokered realtime tool timeline.** Realtime Agent turns now mirror transcript, assistant speech, Hermes task state, concise tool-status rows, confirmation state, path badges, and compact result provenance into chat/voice UI without dumping raw tool output aloud.
|
||||
|
||||
- **Connection diagnostics and activity logs.** Settings now includes a Diagnostics surface with sanitized recent API, relay, session, endpoint, and voice activity. API / Relay / Session detail drawers also tail the relevant recent activity so hung or unreachable relays are visible without ADB first.
|
||||
|
||||
- **Realtime and Voice Settings active-engine layout.** Voice Settings now separates **Voice Engine** from global voice controls, shows only the selected engine's provider card, keeps fallback TTS visible as a global safety-net card, and provides **Test Current Engine**: stable voice plays the saved Voice Output sample, while Realtime Agent opens a provider-native `/voice/realtime-agent/*` test session and plays streamed realtime audio.
|
||||
|
||||
- **Voice Lab text and mic demos.** The realtime voice test screen now offers two clearly separated demos: a **Text demo** that plays raw provider TTS, and a **Mic demo** that exercises the full agent path — real speech recognition, Hermes brokering, and a spoken reply — with tap-to-record / tap-to-stop capture. A `scripts/realtime-voice-lab-smoke.ps1` smoke script accompanies the lab.
|
||||
|
||||
- **Realtime playback diagnostics.** Playback now records a time-to-first-audio metric, logs requested-vs-actual AudioTrack buffer sizes, runs a first-frame watchdog, and cross-checks playback drain drift so cold-start and underrun regressions surface in the Diagnostics log instead of as silent dead air.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Google Play Bridge Core split.** The Google Play Android track keeps relay pairing, chat, profiles, voice, terminal/TUI, media, notification companion, relay sessions, diagnostics, and status while removing AccessibilityService-backed Device Control declarations and permissions. Sideload remains the track for screen reading, gestures, screenshots, SMS/calls, contacts/location, overlays, wake locks, and unattended control.
|
||||
|
||||
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, server/Python releases use `server-v*`, and desktop continues on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Server vX.Y.Z`, and `Hermes-Relay-Desktop vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
|
||||
|
||||
- **Realtime voice instructions are provider-neutral.** Realtime providers receive active interface context, local date/time, provider/model/voice/profile metadata, and guidance to ask Hermes for current facts, research, device/desktop state, project context, precise/versioned data, and any requested checks instead of guessing from model knowledge.
|
||||
|
||||
- **Play/user docs now match the actual artifact.** Release-track docs, feature matrix, getting-started copy, privacy/security references, and Play listing copy now say Google Play has no AccessibilityService, screen reading, gestures, screenshots, or phone-control utility permissions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Silent / choppy first-turn realtime voice playback.** The AudioTrack deep-buffer cold-start was parking the playback head at zero so the first turn dropped or stuttered. The streaming buffer was shrunk from 4000ms to 700ms, the low-latency prebuffer threshold retuned, and a preroll force-start removed, giving reliable low-latency playback from the first frame. Confirmed on-device.
|
||||
|
||||
- **Voice Lab waveform now tracks the playback cursor.** The waveform is driven by `RealtimePcmPlayer.playbackAmplitude()` at the playback position instead of socket-arrival time, so the visual matches what is actually being heard.
|
||||
|
||||
- **Realtime Hermes calls no longer depend on the phone's saved Hermes API key.** Provider-native Hermes tool calls are brokered by the relay with its server-side Hermes credential, so a phone can be paired for realtime voice without exposing or misusing its saved API bearer.
|
||||
|
||||
- **Hung relay voice turns fail visibly.** Voice turns run relay health preflight and shorter realtime/session timeouts so Settings and Voice mode surface unreachable relay state instead of sitting indefinitely on Thinking.
|
||||
|
||||
- **OpenAI realtime is no longer treated as render-after-Hermes fallback.** `openai_realtime` is registered as a native Realtime Agent provider path alongside xAI, with provider-native audio events normalized through the same broker contract.
|
||||
|
||||
- **Local release signing no longer falls back to debug when `local.properties` uses a repo-root relative keystore path.** The Android Gradle signing config now resolves relative keystore paths from the repo root, matching the documented `release.keystore` setup.
|
||||
|
||||
## [0.7.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Profile-aware Hermes sessions and voice settings.** Android now treats Hermes profiles as first-class connection state: profile selection resolves against the active server, profile-specific chat sessions are persisted separately, default/Victor display is normalized, and per-profile voice provider/model/voice settings can be read and saved through relay-owned endpoints without depending on Hermes config mutations.
|
||||
- **Profile-aware Hermes sessions and voice settings.** Android now treats Hermes profiles as first-class connection state: profile selection resolves against the active server, profile-specific chat sessions are persisted separately, default/Victor display is normalized, and per-profile voice provider/model/voice settings can be read and saved through server-owned endpoints without depending on Hermes config mutations.
|
||||
|
||||
- **Realtime voice playground and provider lab.** The relay now includes standalone OpenAI/xAI/ElevenLabs-oriented voice lab tooling, provider adapters, provider option discovery routes, realtime playground routes, and generated WAV/JSONL artifact ignores for iterative voice quality testing outside production Hermes routes.
|
||||
|
||||
- **Streaming voice output routes.** Relay-owned `/voice/output/*`, realtime playground, profile voice config, and provider option endpoints support provider-neutral TTS rendering, dynamic voice/model option surfaces, and profile-scoped voice configuration for Android.
|
||||
- **Streaming voice output routes.** server-owned `/voice/output/*`, realtime playground, profile voice config, and provider option endpoints support provider-neutral TTS rendering, dynamic voice/model option surfaces, and profile-scoped voice configuration for Android.
|
||||
|
||||
- **Experimental Android realtime voice overlay.** Android adds a richer voice overlay with tap-to-talk, continuous mode controls, optional system overlay mode, compact mode, realtime waveform visualization, playback controls, and an experimental badge around barge-in instead of treating all voice as experimental.
|
||||
|
||||
@@ -34,7 +172,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Dashboard plugin CI.** `.github/workflows/ci-dashboard.yml` builds the dashboard plugin, runs the dashboard API tests, and verifies the plugin-owned QR modal CSS markers are present in the built bundle.
|
||||
|
||||
- **Upstream integration sync reference.** `docs/upstream-integration-sync.md` now tracks which Hermes-Relay surfaces use upstream-supported extension points, which pieces are relay-owned compatibility layers, and what has to be checked before changing relay, Android, desktop, dashboard, bootstrap, or user-doc surfaces.
|
||||
- **Upstream integration sync reference.** `docs/upstream-integration-sync.md` now tracks which Hermes-Relay surfaces use upstream-supported extension points, which pieces are server-owned compatibility layers, and what has to be checked before changing relay, Android, desktop, dashboard, bootstrap, or user-doc surfaces.
|
||||
|
||||
- **Relay version sync verifier.** `scripts/check-relay-version-sync.py` validates the relay package version against plugin metadata and dashboard metadata so release and dashboard surfaces cannot silently drift.
|
||||
|
||||
@@ -119,7 +257,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Added
|
||||
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
|
||||
@@ -170,7 +308,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
|
||||
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-relay.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
|
||||
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
|
||||
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
|
||||
@@ -277,7 +415,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Hermes-agent dashboard plugin** at `plugin/dashboard/` — surfaces
|
||||
relay state in the gateway's web UI via four tabs. **Relay
|
||||
Management** lists paired devices + health + relay version;
|
||||
Management** lists paired devices + health + Server version;
|
||||
**Bridge Activity** renders the in-memory ring buffer of recent
|
||||
bridge commands (method / path / decision, with safety-rail
|
||||
`executed` / `blocked` / `confirmed` / `timeout` / `error`
|
||||
@@ -1051,7 +1189,7 @@ picker.
|
||||
- **Stats for Nerds enhancements** — reset button, tokens per message average, peak TTFT, slowest completion, seconds subtext on all ms values
|
||||
- **Feature gating** — `FeatureFlags` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides
|
||||
- **Developer Options** — hidden settings section, tap version 7 times to unlock (same pattern as Android system Developer Options)
|
||||
- **Relay feature toggle** — relay server settings and pairing sections gated behind developer options in release builds
|
||||
- **Relay feature toggle** — Server settings and pairing sections gated behind developer options in release builds
|
||||
- **Dynamic onboarding** — terminal, bridge, and relay pages excluded from onboarding when relay feature disabled
|
||||
- **Parse tool annotations** — experimental annotation parsing for Sessions mode (marked with badge, disabled for Runs mode)
|
||||
- **Privacy policy link** — accessible from Settings → About
|
||||
@@ -1085,7 +1223,7 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
#### Core Chat
|
||||
- **Direct API chat** — connects to Hermes API Server via `/api/sessions/{id}/chat/stream` with SSE streaming
|
||||
- **HermesApiClient** — full session CRUD + SSE streaming, health checks, cancel support
|
||||
- **Dual connection model** — API Server (HTTP) for chat, Relay Server (WSS) for bridge/terminal
|
||||
- **Dual connection model** — API Server (HTTP) for chat, Server (WSS) for bridge/terminal
|
||||
- **API key auth** — optional Bearer token stored in EncryptedSharedPreferences
|
||||
- **Cancel streaming** — stop button to cancel in-flight chat responses
|
||||
- **Error retry** — retry button in error banner re-sends last failed message
|
||||
@@ -1115,21 +1253,22 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Auth flow** — 6-character pairing code with session token persistence
|
||||
- **Material 3 + Material You** — dynamic theming with light/dark/auto
|
||||
- **Onboarding** — multi-page pager with feature overview and connection setup
|
||||
- **Settings** — API Server + Relay Server config, theme, reasoning toggle, data export/import/reset
|
||||
- **Settings** — API Server + Server config, theme, reasoning toggle, data export/import/reset
|
||||
- **Offline detection** — banner shown when network connectivity is lost
|
||||
- **What's New dialog** — shown automatically when app version changes
|
||||
- **Splash screen** — branded splash via core-splashscreen API
|
||||
- **Network security** — cleartext restricted to localhost only
|
||||
|
||||
#### Infrastructure
|
||||
- **Relay server** — Python aiohttp WSS server for bridge/terminal channels
|
||||
- **Server** — Python aiohttp WSS server for bridge/terminal channels
|
||||
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
|
||||
- **Claude Code automation** — issue triage, PR fix, chat, and code review workflows
|
||||
- **Dependabot** — weekly Gradle + GitHub Actions dependency updates with auto-merge
|
||||
- **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/v0.7.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...HEAD
|
||||
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
|
||||
[0.7.0]: https://github.com/Codename-11/hermes-relay/compare/v0.6.1...v0.7.0
|
||||
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
|
||||
[0.1.0-beta]: https://github.com/Codename-11/hermes-relay/releases/tag/v0.1.0-beta
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
|
||||
|
||||
**Current state:** v0.7.x (unreleased on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode, bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative) and `sideload` (full-capability).
|
||||
**Current state:** v0.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -29,45 +29,53 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
|
||||
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
|
||||
1. **Codename-11 fork** (`feat/session-api` branch, deployed on the `axiom` branch) — adds them natively. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) *"feat(api-server): add session management API for frontend clients"* — scope is broader than the title: sessions CRUD + session chat/stream + memory + skills + config + available-models.
|
||||
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file. Does NOT inject `/api/sessions/{id}/chat/stream` — use `/v1/runs` for chat.
|
||||
3. **Upstream-merged** (post PR #8556) — bootstrap auto-detects and no-ops.
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap) |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/skills`, `/{name}` | Skill discovery (list + detail) | Fork OR bootstrap OR upstream-merged |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — loopback-only):**
|
||||
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`; Android Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
|
||||
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Remove `hermes_relay_bootstrap/` in one PR once PR #8556 merges. It's no-op-compatible, so leaving it in place during rollout is harmless.
|
||||
- **Bootstrap maintenance:** Retire `hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -107,7 +115,7 @@ hermes-android/
|
||||
│ ├── tools/ # android_navigate.py, android_notifications.py
|
||||
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
|
||||
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
|
||||
├── hermes_relay_bootstrap/ ← Runtime patch for vanilla upstream; removable after PR #8556
|
||||
├── hermes_relay_bootstrap/ ← Runtime compatibility patch; retire per surface as upstream replaces it
|
||||
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
|
||||
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
|
||||
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
|
||||
@@ -147,14 +155,14 @@ hermes-android/
|
||||
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
|
||||
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Use `bash scripts/bump-version.sh <new-version>` to bump all three sources atomically (`gradle/libs.versions.toml`, `pyproject.toml`, `plugin/relay/__init__.py`). The `release: vX.Y.Z` commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the tag is cut from `main`.
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
|
||||
### Testing
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-relay.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-server.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -197,7 +205,7 @@ hermes-android/
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | MediaPlayer + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
@@ -230,7 +238,7 @@ hermes-android/
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch for vanilla upstream; no-op on fork/upstream-merged; remove after PR #8556 |
|
||||
| `hermes_relay_bootstrap/` | Runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
@@ -374,10 +382,10 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
|---------|----------|-------|
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; preferred |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | No live tool events; reloads history on stream complete |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Non-standard; bootstrap or fork |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
@@ -390,7 +398,7 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
|
||||
+2
-2
@@ -94,14 +94,14 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
|
||||
|
||||
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
|
||||
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a `release: vX.Y.Z` PR merges `dev` → `main` with `--no-ff`. The tag is cut from `main` after the merge. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
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.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into two path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes) and `.github/workflows/ci-relay.yml` (syntax check + unittest discover on plugin/Python changes). Both run on pushes to `main` and `dev` and on PRs targeting either.
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -1,5 +1,210 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-06-11 — Standard-route network auto-switch (LAN ↔ Tailscale roaming without Relay)
|
||||
|
||||
**Context.** Bailey reported that away from home, a configured + signed-in standard connection never switched over to its Tailscale route — chat stayed dead and voice stayed gated, while the old Relay-paired path used to hand off fine (chat catch-up, voice, realtime). Audit traced it to the rechrome: every downstream piece (route candidates stored by the wizard, `effectiveApiServerUrl`/`effectiveDashboardUrl` flows, client-rebuild collectors, chat session refresh on client swap) was sound; nothing upstream ever *triggered* re-resolution on the standard path, and standard voice ignored the resolved route entirely.
|
||||
|
||||
**Root causes (4).** (1) `ConnectionManager`'s ADR 24 NetworkCallback registered only inside `connect()` — never on socketless standard connections — and its handlers early-returned without a socket URL. (2) The fallback trigger (`networkStatus` StateFlow → `revalidate()`) is value-deduped over 3 coarse states; with Tailscale's always-on VPN keeping "internet available" true through any handoff, the value never leaves `Available`, so the collector never fires — the exact user this feature targets is the one it can't see. (3) `activeDashboardUrl()` (standard voice client + availability probe) read the **persisted** dashboard URL, not `effectiveDashboardUrl`. (4) The resolver's 60s positive probe cache wasn't cleared on resume/network-change re-resolution, so a just-died LAN route kept winning.
|
||||
|
||||
**What changed (branch `fix/standard-route-network-switchover`, merged --no-ff; follow-ups direct on `feature/standard-voice-dashboard-surface`).**
|
||||
|
||||
- **ConnectionManager** — NetworkCallback registers at construction (no-op without context); `onAvailable`/`onLost` unify into a debounced (300ms) `scheduleNetworkReResolve()` that publishes `activeEndpoint` even with no socket, preserving the socket-swap/reconnect semantics when one exists. `refreshActiveEndpoint(clearProbeCache)` clears the resolver cache; `revalidate()` passes true.
|
||||
- **Standard voice** — `activeDashboardUrl()` now delegates to `effectiveDashboardUrl`, so the voice client and `probeStandardVoice()` follow the resolved route; `rebuildApiClient()` → `probeStandardVoice()` re-gates the mic automatically after a route swap.
|
||||
- **Escalation safety net** — the 30s API health loop turns two consecutive Unreachable probes into a cache-cleared re-resolve, covering handoffs Android never surfaces as connectivity events. Client rebuild stays reactive via the `effectiveApiServerUrl` collector (single rebuild path for all triggers).
|
||||
- **Route-candidate preservation** — new `Connection.mergeRouteCandidates(rebuilt, existing)`: URL edits (`updateApiServerUrl`, `updateRelayUrl`, `connectRelay`, `testRelayReachable`, `saveApiAndProbeVoice`, `saveStandardApiConnection` fallback) rebuild only the touched route and keep stored priority>0 extras verbatim — previously any URL save collapsed the list to one candidate and silently killed roaming. The wizard doesn't pre-fill its Tailscale field, so blank-on-rerun means "unchanged", not "remove".
|
||||
- **Per-route sign-in UX** — dashboard cookies are host-scoped, so a LAN sign-in doesn't authenticate the Tailscale host (the store holds both; it's a one-time sign-in per host). New `standardVoiceSignInRouteHint` flow + route-aware copy in Voice Settings' SignInRequired block + a Diagnostics entry from the probe.
|
||||
- **Cleanup** — removed the duplicate `networkStatus → revalidate()` collector left by the rechrome.
|
||||
|
||||
**Tests.** New `ConnectionManagerRouteTest` (Robolectric + MockWebServer): callback registration/unregistration at construction, socketless `onAvailable` publishing `activeEndpoint`, stale-cache vs `clearProbeCache` resolve. New `ConnectionRouteCandidateMergeTest`: extras preserved, payload relay URLs verbatim, host:port collision defers to rebuilt, primary replacement, no-extras passthrough. `:app:lint` + targeted unit suites green locally.
|
||||
|
||||
**On-device verification needed (Bailey, via Studio):** standard connection with a Tailscale route → leave Wi-Fi → chat should re-route within ~30–60s worst case (network callback usually immediate); Voice Settings should show the per-route sign-in hint until Manage sign-in on the Tailscale host; return home → routes flip back to LAN (priority 0). Also worth re-checking the Relay-paired handoff path for regressions since onAvailable/onLost were unified.
|
||||
|
||||
**Pre-release polish (same day).** Review pass before handing to Studio: (1) gated network-change socket actions on `shouldReconnect` — the swap path force-set it true, so a network event could resurrect a socket the user explicitly disconnected (pre-existing hole the refactor preserved; routes still publish for HTTP surfaces); (2) `refreshActiveEndpoint` keeps the live route on a transient probe miss while the WSS is Connected (mirrors the callback's guard — a resume-time probe blip no longer downgrades every HTTP surface to the saved LAN URL); (3) sign-in route hint upgraded to `displayLabel()` ("Tailscale") and the chat mic toast made route-aware; (4) escalation counter resets while no API client exists.
|
||||
|
||||
**Remote-access discoverability (same day, Bailey's ask).** UX audit of the onboarding → remote journey found the mechanics worked but nothing *led* users to them: the wizard's happy path (scan LAN → connect) produced a LAN-only connection silently (the Tailscale field hid inside the collapsed Advanced expander), the setup result card had no remote line, "Hermes API unreachable" didn't distinguish "server down" from "you're remote with no fallback route", and `TailscaleDetector` powered only an informational chip. Shipped four nudges, each at a moment of real user attention: (1) the Tailscale field lifted into the main setup form as "Remote access — Tailscale URL (optional)" with a detected-on-this-phone hint; (2) a "Remote" readiness line on the setup result card (`StandardApiSetupResult.remoteRouteConfigured`); (3) the unreachable status pill diagnoses by route count — single-route gets "add a Tailscale or public route" (sharpened when the phone is on Tailscale), multi-route gets "none of the N routes responded, fallbacks retried automatically"; (4) an "Add Tailscale route" shortcut on the Connections card when the phone is on Tailscale but the connection lacks a tailscale route (route-editor state hoisted out of the expander so it opens with the list collapsed). Deliberately skipped: auto-deriving the server's MagicDNS URL (needs server-side support vanilla hermes-agent doesn't have) and QR-for-standard (no upstream payload generator). user-docs `remote-access.md` gains an "Add or Edit Routes on the Phone" section + per-route sign-in tip.
|
||||
|
||||
**Routes editor (same day, Bailey's ask).** Two gaps confirmed: the standard path's only multi-route provisioning was the wizard's buried optional Tailscale field (single route, setup-time only, not pre-filled on re-runs — and no QR equivalent exists because vanilla hermes-agent has no payload generator; the QR comes from the Relay plugin), and the Routes card was read-only. Closed both: `EndpointsCard` gains **Add route** + per-row **Edit/Remove** (fallback rows only — the priority-0 primary mirrors the connection's API URL and is edited there; remove confirms first), backed by a `RouteEditorDialog` (Tailscale/Public/Custom role chips, URL field, inline validation errors from the save callback). `ConnectionViewModel.saveExtraRoute`/`removeExtraRoute` persist to `Connection.routeCandidates`, seeding from the same fallback chain `observeDeviceEndpoints` displays (per-device PairingPreferences → synthesized primary) so an edit never hides QR-provisioned routes; host:port collisions are rejected with a pointed message; removing a route clears a preferred-route override that pointed at it; both finish with a cache-cleared `refreshActiveEndpoint` so the change takes effect immediately. Relay URLs for manual routes are derived (`:8767` convention) — QR remains the path for custom relay URLs. Wizard helper text now points at Settings → Connections → Routes.
|
||||
|
||||
**Route probe visibility + URL forgiveness (same day, Bailey remote-debugging).** Field report from the road: Tailscale route added (`100.71.8.56`, port auto-appended), phone on the tailnet, but "Probe now"/"Use now" left the card on "Current: Resolving" showing the internal URL, with no probing indicator anywhere. Diagnosis found one real bug plus a UX black hole:
|
||||
|
||||
- **The bug** — `probeAndReconnect()` early-returned (`resolved?.relay?.url ?: current ?: return@launch`) when every probe failed on the standard (no-socket) path: nothing was published, nothing was shown, and the only record went to DiagnosticsLog. Replaced by `probeAndReconnectNow(): EndpointCandidate?` (awaitable; `probeAndReconnect()` is now a launch wrapper) which **always publishes the outcome** — with the Connected-socket transient-miss guard preserved. `probeNow()`'s 100ms-delay-then-health-check hack (a real resolve takes 4s+ when LAN must time out) now awaits the resolve, rebuilds the API client on route change, then health-probes.
|
||||
- **The black hole** — no probe feedback at any layer. New `RouteProbeOutcome` map on `EndpointResolver` (per-candidate verdict + human reason; survives `clearCache()` — caching ≠ verdict history) with the TLS case spelled out ("TLS failed — server may be http://, not https://"), `RouteProbeStatus` (Idle/Probing/Done(winner)) on ConnectionViewModel, and UI: Re-check buttons show "Checking…", route rows show Reachable/Unreachable-with-reason, the Current line states "No route reachable — using saved URL <api url>" in error color instead of eternal "Resolving" over the **relay** URL fallback (the "internal url" Bailey was seeing — ConnectionsSettingsScreen printed `connection.relayUrl` under the resolving label).
|
||||
- **Likely root cause of the field failure** — the route row only showed `host:port`; the scheme (the `tls` flag) was invisible, and the editor's placeholder even suggested `https://`. An https route against the plain-HTTP API server TLS-fails every probe. Rows now show the **full URL including scheme**; the editor previews "Will save: http://100.71.8.56:8642" live.
|
||||
- **URL forgiveness** — new `Connection.normalizeApiUrlInput(raw, defaultPort=8642)`: bare hosts/IPs get `http://` + the surface's default port (dashboard field uses 9119); explicitly-schemed URLs pass **verbatim** (an `https://host` may be a reverse proxy on 443 — never force-append 8642). Applied in `saveExtraRoute`, `saveStandardApiConnection` (API + Tailscale + dashboard fields), and `updateApiServerUrl` (save-time only — its single call site is `applyManualPair`, not per-keystroke). Wizard validators soften to accept bare hosts; field copy now names the ports (API 8642 vs dashboard 9119, relay 8767 derived). `saveExtraRoute`/`removeExtraRoute` end in a full `probeNow()` so a just-saved route shows its verdict immediately.
|
||||
|
||||
Tests: +4 resolver outcome tests, +2 ConnectionManager `probeAndReconnectNow` publish tests (winner published socketless; null published when all routes die — the old early-return regression case), +10 `normalizeApiUrlInput` cases incl. the bare-Tailscale-IP end-to-end journey. All green; lint green. user-docs `remote-access.md` gains "Which URL Do I Enter?" (raw `100.x` IP → `http://` + API server must listen beyond loopback; `*.ts.net` behind `tailscale serve` → `https://`, cert is name-only). **On-device verification (Bailey):** with the Tailscale route's scheme visible, check whether it was saved as https — edit to http if so; Re-check should now show per-route verdicts either way.
|
||||
|
||||
**README + Play listing audit (same day, Bailey's ask).** Audit found both documents lagging the standard-first pivot by two release cycles: README said "What's new in v0.6.0" (app at 0.8.1), the CI badge pointed at deleted `ci.yml`, two in-page anchors were broken (renamed headings), voice was described as relay-only in three places (surfaces table, features bullet, How It Works diagram) despite the dashboard-surface change, and neither document mentioned Manage parity or remote access at all. Rewrote both: README restructured around the setup card's Chat/Manage/Voice/Remote/Relay framing (one Quick Start instead of three overlapping sections — Quick Start / What It Does / Getting Started), operational detail (sideload steps, update-banner mechanics, paste-workflow demo, uninstall flags) compressed to one-liners with docs-site links, desktop section trimmed to install + 4 commands behind an explicit alpha banner stating the planned refocus into a remote "hands" connector now that hermes-desktop owns desktop chat/management (Bailey's direction). Play listing rewritten end-user-first: new short description ("Your self-hosted Hermes AI agent, in your pocket — chat, voice, and control.", 76/80 chars), a 3-step QUICK START block, Manage + Works Away From Home feature sections, voice corrected to the no-plugin story, "TUI" jargon dropped, v0.8.1 release notes drafted (464/500 chars). Compliance-sensitive GOOGLE PLAY BUILD / SECURITY & PRIVACY sections kept verbatim.
|
||||
|
||||
**Field follow-up: http fixed chat; Manage stayed dark over Tailscale (same day).** Confirmed on-device that flipping the route to `http://` made chat roam. Manage not working over the same route has three candidate causes, all by-design rather than app bugs: (1) the dashboard (`:9119`, `hermes_cli/web_server.py`) is a separate server from the API (`:8642`) — tailnet reachability of one proves nothing about the other (bind/port-mapping/ACL; note `hermes-relay-tailscale enable` fronts only 8767 + 8642, never 9119); (2) dashboard sessions are host-scoped cookies, so the home sign-in doesn't authenticate `100.x.y.z:9119` — one sign-in per route, the app keeps both; (3) an explicit dashboard URL override pins Manage to that host (only auto-managed URLs roam — deliberate, since overrides usually mean a reverse proxy; the wizard's LAN scan can store one silently when detected ≠ derived). Audit confirmed the app already targets `effectiveDashboardUrl` everywhere (client factory, sign-in dialog, payload cache keys) — what was missing was *visibility*. Manage now: shows a persistent "Dashboard: <url> · <route> route" target line under the mode strip; names the failing URL in the "Dashboard unavailable" card; and explains per-host sign-in in the sign-in card when the route has moved. Plumbing: new `ConnectionViewModel.dashboardRouteMovedHint` (route label when effective ≠ persisted dashboard URL); `standardVoiceSignInRouteHint` refactored to reuse it (semantics unchanged).
|
||||
|
||||
**user-docs cockpit rechrome + content refresh (same day, Bailey's ask).** The docs site still wore the pre-refresh "Nothing-inspired" chrome (OLED `#000`, neutral grays, `#7C3AED` purple) while the app shipped the relay cockpit palette two days earlier. Rechromed `user-docs/.vitepress` to mirror `RelayRefresh.kt`: dark mode is now navy-black `#08090D` with navy panels (`#121426`/`#191B31`), warm-white ink `#F7F6F0`, alpha-based warm-white hairlines (the `Line`/`LineStrong` trick), Relay periwinkle `#AEBFFF` for links/active text vs ElectricMuted `#4F5BD5` for fills (same glare lesson as the app), status colors from the app's Green/Amber/Danger, a 42px-grid + 10px Relay-dot lattice on the home surface mirroring `relayGridTexture()`, and light mode moved to warm paper `#F7F3EA`. Hardcoded old-palette colors swept from HermesFlow/HermesFlowNode (edges, nodes — diagrams stay dark in both modes like instrument panels), HeroDemo (navy bezel + periwinkle glow), ExperimentalBadge (app Amber), FeatureMatrix (sideload tint). Content pass from a full staleness audit: four complete pages were unreachable from the sidebar (`features/voice`, `features/voice-intents`, `features/phone-control-tools`, `reference/relay-server`) plus `architecture/flavor-differences` linked from nowhere — all five added to `config.mts`; `guide/index.md` version heading bumped 0.8.0→0.8.1; `desktop/installation.md` example pins bumped alpha.14→alpha.18. Build verified: compiled CSS/JS contain the new palette and zero old-palette hex values. Deferred per Bailey: demo video + the 5 dashboard screenshot TODOs in `features/dashboard.md` (chat_demo.mp4 and the poster also predate the cockpit refresh and should be re-captured). Feedback round (live design review on the dev server): dark brand accent shifted from Relay periwinkle `#AEBFFF` → electric indigo `#6E7CFF` ("too light, not our app blue" — periwinkle survives only in the dot texture); SphereMark gained a radial occlusion halo so the home dot-grid fades behind/around the sphere, plus an `isConnected` guard on the cached install-section anchor (a detached node's rect is all zeros → `scrollVy` locked at 1 and the eye stared down forever after HMR/route swaps — the reported "tracking breaks after scrolling"); install-extras cards un-crunched from 2-col to stacked full-width with one-liners wrapping (`pre-wrap`) instead of horizontal-scrolling. Verified live via Orca browser screenshots: gaze tracks cursor left/right post-scroll, halo clean, no horizontal scroll.
|
||||
|
||||
**Server-side root cause + fix (same evening, via SSH per Bailey).** `ss -tlnp` on docker-server showed the real story: API (`:8642`) and relay (`:8767`) on `0.0.0.0`, but `hermes-dashboard.service` ran with `--host 172.16.24.250` — LAN interface only, so `100.71.8.56:9119` was connection-refused (not 401, hence no sign-in card; the "existing login" Bailey saw was last-known persisted state). Rebound to `--host 0.0.0.0` + restart (authorized via prompt), verified `/api/status` on both IPs, updated the server's `~/SYSTEM.md` services table. Phone (adb) confirmed end-to-end: Manage's new target line showed `100.71.8.56:9119 · Tailscale route`, banner flipped to "sign-in required", sign-in card rendered with the route strip. Two learnings recorded: the app's `DashboardCookieJar` is per-connection, NOT host-scoped (sends the stored session cookie to whichever host the route resolves to — sessions normally roam; the restart wiping in-memory dashboard sessions is what forced re-sign-in), and the sign-in strip's "per host" wording could be tightened later.
|
||||
|
||||
**Manage loading/overview pass (same day, Bailey's ask).** Three complaints: the cold-load skeleton stacked four progress bars with fake narrative labels; every re-entry to Manage was a cold load; the KPI glyphs (`ok/…/!`) and the one-line status banner (truncated by two trailing buttons, one a duplicate "Connection" link) were weak. Shipped: (1) **process-lifetime payload cache** — `DashboardPayloadCache` singleton replaces the `remember{}` maps, keyed `connection|dashboardUrl|section` so connection switches and route handoffs stay partitioned; `Loaded.fetchedAtMillis` drives a 30s stale-while-revalidate window (fresh → no fetch; stale → cached content + thin refresh bar); sign-in/out clears as before. (2) **App-start pre-warm** — section fetch core extracted to `fetchDashboardSectionState()`; `prewarmDashboardManage()` (internal, same file) fills cold keys only, aborts the sweep on first unreachable/auth failure, never marks Loading so it can't fight the open screen; RelayApp fires it (1.5s debounce) when the persisted snapshot says reachable + signed-in/auth-free, re-firing on route handoff. (3) **Skeleton** — one LinearProgressIndicator + three pulsing content-shaped ghost cards. (4) **KPI strip** — count / tone-colored dashboard state word (ready/sign-in/offline/error) / server version (`RelayMetricCard` gains optional `valueColor`). (5) **Status banner** — two-line layout (state+identity+Sign out / URL·route·checked), duplicate "Connection" button removed (Connections tile is directly below).
|
||||
|
||||
**user-docs marketing reposition: "the brain stays home, the hands go everywhere" (same day, Bailey's ask).** Bailey's direction: desktop is becoming a remote-device access point — "a hand inside other devices you install to" (remote control, filesystem, CLI); chat/management on desktop belongs to hermes-desktop. Docs site rewritten to lead with that. Homepage: hero is now "The brain stays home. The hands go everywhere."; CTAs flipped (brand → "Get the Android app", alt → "Give it hands — desktop CLI"); 8 feature cards rewritten benefit-first ("Chat that streams, not spins", "Voice with a face", "Manage from your pocket", "Your phone, on the agent's toolbelt", "Hands on any computer", "One pair, every network", "Notifications and media in the loop", "Private by architecture"); surface cards re-roled as "The companion · Android" / "The hand · Desktop CLI" with CTA "Put a hand on this machine →"; new `HowItWorks.vue` 3-step strip (01 Install · 02 Pair · 03 Reach) slotted between SphereMark and InstallSection in `theme/index.ts` (markdown body renders after VPFeatures, so a body-level strip landed at page bottom — component slot fixes the order). `desktop/index.md` re-led with the hands story: tool routing promoted to "The point — the agent works on *your* machine" directly under the intro, modes table reordered (Tools/Daemon above Shell/Chat), chat marked "maintained for scripting; not a growth surface", new info box stating the track direction, killer demo reframed as the escape-hatch bonus. Nav/meta refreshed (og/twitter titles → "give your self-hosted Hermes agent hands"; sidebar/nav say "remote hands"). Old-positioning sweep cleared `guide/index.md` tip, InstallSection Step 2, features/index "Coming Soon" row. Verified via Orca screenshots + clean build. **og-image.png still renders the old tagline — re-render alongside the deferred demo/screenshot batch.** Headline rounds 2–3 (Bailey): "hands" demoted from hero to feature level; "server" rejected too (with official Hermes Desktop the brain may be a PC/laptop) — final hero is **"Runs on your machine. Lives on your devices."**; "self-hosted Hermes agent" dropped globally in favor of plain "Hermes agent" (self-hosting implied; kept only as a technical term: self-hosted OIDC/VPN); **"Desktop CLI" renamed to plain "CLI" across all user-docs copy** (the binary runs on any host — desktops, laptops, headless boxes; `/desktop/` URLs and `desktop-v*` track names stay until the code refactor); hero gained a mono platform-note subtext under the action buttons: "CLI: Windows today · macOS / Linux coming soon" (`home-hero-actions-after` slot + `.hero-platform-note`). Decisions recorded in auto-memory `hermes-relay-branding-voice`. Round 4 — two-path funnel (audit first, Bailey approved): the homepage install funnel was the power path presented as the only path (HowItWorks step 01 told standard users to curl-install the relay plugin; InstallSection led with plugin + `hermes-pair` + "Python 3.11+"). Restructured: HowItWorks recast as the quick-path journey (01 Run Hermes on your machine — API serves chat, dashboard serves Manage/voice, nothing extra to install · 02 Install the app · 03 Connect — LAN scan / setup QR / URL, since standard onboarding accepts a QR) with a power-path footer pointer; InstallSection rebuilt as stacked path cards — "Quick path · No server install — Just connect" (app links + the brief chat=API / Manage+voice=dashboard / pure-chat-needs-only-API nit) above "Power path · Relay plugin — Give it hands" (plugin curl, hermes-pair, CLI one-liners pwsh-first with the Windows-today note, AI-agents block rescoped to the power path); homepage surface card + "Works at home and away" feature card de-relay-flavored (standard connections roam too). `guide/getting-started.md` needed no change — already standard-first. Round 5 (Bailey): power card now shows ONLY the Windows CLI one-liner (the sh one-liner invited users to install binaries that don't exist yet) with a muted "lands with those builds" note; surface cards moved up out of the page-bottom orphan slot into a new `SurfaceCards.vue` slotted between How-it-works and Get-started ("meet the two surfaces before the install commands") — index.md body is now intentionally empty; "Voice with a face" renamed "Hands-free voice" and "voice with the morphing sphere" copy dropped (Bailey plans user-swappable voice animations — don't marry voice marketing to one visual); Windows-only status propagated to the remaining availability claims: desktop/index experimental box, installation.md intro, and a "Coming soon" warning on its macOS/Linux section (capability mentions like clipboard tool paths left intact — that code exists).
|
||||
|
||||
**Frozen-sphere incident: Keystore/Tink global-lock contention (same day, found via adb after Bailey reported ~3s startup freeze).** Cold-start logcat showed `Skipped 45 frames` at first draw (pre-existing VM-init cost), then `Long monitor contention … AndroidKeysetManager$Builder.build() … owner DefaultDispatcher-worker-5 … for 4.095s` **on the main thread**, ending in `Skipped 1386 frames` / `Davey! duration=11596ms`. Mechanics: `EncryptedDashboardCookieStore` built its Keystore-backed prefs **eagerly in its constructor** — 1–4s per build on Samsung StrongBox, serialized through Tink's process-global `AndroidKeysetManager.Builder.build()` lock — and the new Manage pre-warm constructed one per section (8×, worker-5 = the lock owner in every contention event), while other paths (connection validation probe, Manage's per-fetch client factory, session clear) constructed yet more instances, one of them on the main thread. The pre-warm didn't create the main-thread keystore work, but it multiplied the stall by keeping the lock hot. Fix (three layers): (1) `EncryptedDashboardCookieStore.store` is now `by lazy` — construction free on any thread, the build lands on first cookie access, which is always an OkHttp/IO thread; (2) new `ConnectionViewModel.dashboardCookieStoreFor(connectionId)` — ONE cached instance per connection, now used by Manage's client factory, the validation probe, session clear, standard voice, and the pre-warm (previously each had private instances = N keyset builds for the same prefs file); (3) `prewarmDashboardManage` takes the shared store + builds ONE `DashboardApiClient` for the whole sweep (`fetchDashboardSectionStateWith(client, …)` core extracted; per-section client/store construction removed; `NonCancellable` shutdown in finally). `DashboardOAuthSignInDialog.cookieStoreFactory` widened to the `DashboardCookieStore` interface. Net keyset builds at cold start: was ~10+ serialized seconds-long holds; now ≤3 (two AuthManagers + one cookie store), all off-main. Honest correction recorded: my earlier "pre-warm can't delay the UI" claim missed the keystore-lock dimension — network was off-main, but lock contention is transitive.
|
||||
|
||||
**Use-now/Prefer split + Manage waterfall fix + disk cache (same day, Bailey's asks).** Branch `feature/route-override-split-manage-cache`. Three items:
|
||||
|
||||
- **Routes: "Use now" vs "Prefer" separated.** Bailey noticed "Use now" silently flipped the connection to "Preferred: tailscale" — both affordances routed through `setPreferredEndpointRole`, which persists `preferredRouteRole`. Split per the act-now-vs-policy principle: "Use now" → new `ConnectionViewModel.useRouteNow(role)` (transient `setManualRoleOverride` + `probeNow`; dies on disconnect; `useRouteNow(null)` restores the persisted preference); "Prefer this route" (⋮ menu, now a toggle with "Stop preferring" + explanatory sublabels) remains the only writer of `preferredRouteRole`. `ConnectionManager.manualRoleOverride` became a `StateFlow` so the card can label Current as `automatic` / `preferred` / `manual (until disconnect)` — the trick: a preference is *implemented through* the override (restored into it on connection load), so "manual" is simply `override != preferredRole`. Card gains "Cancel manual switch" + "Stop preferring" rows; top-level "Auto" clears both layers. Decision recorded: do NOT auto-prefer Tailscale — strict priority + reachability already promotes it when LAN dies and prefers the faster LAN at home; silent preference changes were the exact complaint.
|
||||
- **Manage waterfall.** Bailey: "still 5–10s to fully load — didn't we fix this?" Root cause was arithmetic, not regression: `fetchDashboardSectionStateWith` ran the full auth preamble (status → providers → session → ws-ticket) before EVERY section payload — 8 sections × 5 sequential round trips ≈ 40, and both pre-warm paths ran them serially. Extracted `DashboardPreamble` + `fetchDashboardPreamble()`: fetched once per sweep, passed into the section fetch (which then costs one payload GET); `prewarmDashboardManage` aborts the whole sweep on an unreachable/unauthenticated preamble and fans the section GETs out concurrently (one OkHttp client, dispatcher caps per-host); the in-screen sibling prewarm reuses the visible section's already-verified status/session as its preamble and launches sections in parallel. Net: ~40 sequential → ~4 + 8 concurrent. Foreground (visible-tab) loads keep the full preamble — the header needs fresh status/session.
|
||||
- **Disk cache ("yes add that now").** New `DashboardManageDiskCache` (`ui/screens/DashboardManageDiskCache.kt`): plain-JSON file `cacheDir/dashboard-manage-cache.json` (schema-versioned envelope, tmp+rename writes, mutex-serialized, corrupt/foreign-version decodes to empty) — deliberately NOT EncryptedSharedPrefs per the Tink-lock lesson; payload carries no credentials (cookies stay in their encrypted store) and `cacheDir` is app-private. `DashboardSummaryItem`/`DashboardItemAction`/`DashboardActionKind` moved there (private→internal, `@Serializable`); `DashboardStatus`/`DashboardAuthProvider`/`DashboardAuthSession` annotated `@Serializable` in place. Hydration (`hydrateDashboardManageCache`, RelayApp `LaunchedEffect(Unit)`, once per process, never overwrites populated keys) preserves `fetchedAtMillis`, so hydrated entries render instantly AND count as stale — the existing SWR window + the pre-warm (cold filter widened to include stale-Loaded so disk-hydrated entries refresh) handle the rest with zero new states. Write-through after every Loaded fetch + end of pre-warm sweep (whole-file rewrite, a few KB); all three sign-in/out clear sites also wipe the file. New `DashboardManageDiskCacheTest` (8 cases: field-complete round-trip, corrupt/future-version/unknown-keys, filesystem round-trip, missing-file, clear, atomic replace).
|
||||
|
||||
Verified: `:app:lint` green, full `testGooglePlayDebugUnitTest` green. Answer to "was it just caching saved info before?": no — pre-rechrome Manage had NO cache at all (remember{} per entry); "fully loaded" got slower because the overview/pre-warm multiplied the per-section preamble 8×. Cold-process data loss was real though (cache was process-lifetime only) — the disk mirror closes it. On-device: cold start should show Manage data instantly (after one prior visit) and the route card's Use-now should no longer flip the Preferred chip.
|
||||
|
||||
**Startup gate rework: sphere = loading screen with narration (same day, Bailey's report).** Bailey watched a ~15s cold start play out as a slideshow: "Connect to Hermes" CTA → connected (button gone) → last session + agent name. Two gate bugs made the intermediate states visible: (1) `startupConnectionResolved` listed `apiHealth == Unreachable` as a release condition — the FIRST health verdict at cold start often runs against the persisted URL moments before the route resolver lands, so the gate dropped users into disconnected chat while they were connected-just-waiting; (2) `showStartupSphere` had `!startupGateTimedOut` — at 5.5s the sphere force-hid regardless of progress, which is "shown the app screen before UI was ready" verbatim. And nothing ever gated on conversation restore. Branch `feature/startup-gate-narration`:
|
||||
|
||||
- **Readiness contract** — happy path: `startupApiUp && initialChatSettled` (new one-way latch in ChatViewModel, set on every conclusion path of `switchProfileContext` — loaded / nothing-to-restore / failed, with the history fetch now in try/finally so a throw can't strand `isLoadingHistory` or the gate). Error path: an Unreachable verdict must SURVIVE a 3s settle window (keyed LaunchedEffect restarts on every health flip) before it releases — the normal UI then owns offline presentation. Backstop: 12s timeout that RELEASES the gate instead of yanking the sphere (timeout removed from `showStartupSphere`).
|
||||
- **Narration** — terminal-style check lines at the sphere's bottom (`✓ state restored / ✓ route · Tailscale / › contacting hermes… / · conversation`), monospace labelSmall, all four rows always laid out (pending rows dimmed at 0.28 alpha, animateFloatAsState fade-up) so the column never reflows. Failed shows `✕ hermes unreachable` briefly during the fade-out. Rendered only when a connection is configured.
|
||||
- **Why ~15s is real** — the wall-clock chain is: Tink keyset hydration before the API client can exist (`getApiKey()` awaits crypto init, 1–4s on StrongBox) → route resolve (4s per dead candidate probe) → client rebuild → capabilities probe + health probe (repeated after a route swap if the first pass ran against the stale persisted URL) → sessions + last-session messages + personalities fetches. The gate fix makes the wait *legible*, not shorter; candidate future wins: resolve the preferred role before the first client rebuild, persist the capabilities snapshot, parallelize capabilities/health/personalities after rebuild.
|
||||
|
||||
**Header/footer polish (same day, Bailey's asks).** Terminal and Settings are pushed destinations (header chrome from Chat/Manage) but had no back affordance — both TopAppBars gain the standard `navigationIcon` back arrow (`popBackStack`), including Terminal's PowerFeatureGate variant; `RelayStatusStrip` pill margins tightened top 2→3dp, bottom 8→4dp per spec. Verified: lint + full unit suite green.
|
||||
|
||||
**On-device cold-start measurement + the keystore wall (same day, "test this yourself and track timings").** Bailey reported the new sphere pausing on "contacting hermes…" then dumping to disconnected chat. Reproduced over wireless adb (force-stop → launch → timestamped screencaps + logcat on the S25 Ultra, googlePlay build): **+0.5s** resolver wins `lan 172.16.24.250:8642` (HEAD /health 200 — server fine, network fine); **+2.4→+15.1s** a continuous wall of StrongBox keystore operations — keystore2's own watchdog fired every second (`createOperation … Pending: 500ms`), each op ~550ms, all serialized behind Tink's process-global lock; **+12.65s** the 12s gate backstop fired → disconnected chat revealed (exactly Bailey's report); **+15.1s** `AuthManager init: no stored session_token` — the store finally decrypted, and only then could `rebuildApiClient()`'s `getApiKey()` return. The API client — and health, capabilities, and chat behind it — sat 15 seconds behind crypto whose only finding was "there is no key" (keyless local setup). Two fixes (branch `fix/cold-start-keystore-fastpath`):
|
||||
|
||||
- **Key-less fast path** — new plain-SharedPreferences hint (`<tokenPrefs>_plain_hints` / `api_key_present`, boolean only, never key material) written by `setApiKey`/`clearApiKey` and converged in AuthManager init after the real decrypt. `ConnectionViewModel.apiKeyForClientBuild()` consults `apiKeyKnownAbsent()` and skips `getApiKey()` entirely when the connection is known key-less; used by the cold-start DataStore collector, `rebuildApiClient`, and `rebuildChatApiClient`. Defaults to "assume present ⇒ wait", so keyed connections never see an unauthenticated client — the worst case of a stale hint is the old slow behavior. First launch after this update still pays the marathon once (hint unwritten); every cold start after is fast.
|
||||
- **Resolver evidence counts as hermes-online** — the gate's `startupApiUp` now includes `activeEndpoint != null`: the resolver only publishes a winner after a successful HEAD /health on that route, which lands ~1s in — no reason for the narration to sit on "contacting hermes…" for 14 more seconds waiting for the client-based probe to repeat the same check.
|
||||
|
||||
Expected new keyless cold start: client ~+1–2s after first DataStore emission, checks tick through, gate releases on conversation-restored at ~+3–5s. Keyed connections on slow StrongBox still pay the decrypt (the key is genuinely needed) and may hit the 12s backstop — acceptable; the narration holds the screen that long. Verified: lint + full unit suite green; on-device re-measure pending next Studio install.
|
||||
|
||||
## 2026-06-10 — Standard voice retargeted at the dashboard surface + Manage parity (model/keys/profiles) + softened brand blue
|
||||
|
||||
**Context.** Release verification found the just-landed `StandardHermesVoiceClient` implemented the right upstream contract (`/api/audio/transcribe` + `/api/audio/speak`, base64 data-url — hermes-desktop's voice path) but aimed it at the **API server** (:8642) with a bearer header. Verified against upstream/main (tip `d1383a6b1`, fetched 2026-06-10 into `hermes-agent-pr-prep`): `api_server.py` has **no audio routes** (`/v1/capabilities` says `audio_api: false`; PR #8199 unmerged) — the routes live on the **dashboard web server** (`hermes_cli/web_server.py:1877/2012`) behind its cookie-session auth gate. Net effect: standard-only users got an enabled mic and a guaranteed 404 per turn; relay users silently paid a full base64 upload to a 404 before each fallback.
|
||||
|
||||
**What changed (branch `feature/standard-voice-dashboard-surface`).**
|
||||
|
||||
- **Voice retarget.** `StandardHermesVoiceClient` now takes a `dashboardUrlProvider` (`Connection.resolvedDashboardUrl`, :9119 derived) and an OkHttpClient carrying the **same per-connection encrypted cookie jar Manage signs in with** (new `DynamicDashboardCookieJar` resolves the store per-request so connection switches stay correct). Bearer header dropped — meaningless on this surface. 401/404 error copy now points at Manage sign-in / server update.
|
||||
- **Availability model.** New `StandardVoiceAvailability` (Unknown/Ready/SignInRequired/Unreachable/Unsupported) in ConnectionViewModel, fed by `probeStandardVoice()`: `GET /api/status` (public) → `GET /api/auth/me` when gated → HEAD existence check on the audio route (405 = present, 404 = old build). Replaces `HermesApiClient.probeAudioApi()` (deleted). Probe runs in the health cycle + `rebuildApiClient()`, refreshes the persisted dashboard snapshot only on material change, and re-runs immediately after Manage sign-in/sign-out (`refreshStandardVoice()`).
|
||||
- **Route preference.** `AutoVoiceAudioClient` Auto order is now **Relay first, then Standard**: paired Relay is profile-aware and needs no dashboard sign-in; Standard is the zero-plugin path for vanilla installs. Power users can force either in Voice Settings.
|
||||
- **Voice Settings UX.** Stable STT/TTS Route section shows live per-route status (Standard: Ready / sign-in required / unreachable / unsupported; Relay: ready / not configured; Auto: which route it would use) with a "Sign in via Manage" CTA (navigates to the Manage tab) and an "update hermes-agent or pair Relay" hint. Realtime Agent engine now states "Requires a paired Relay" and shows an inline error + guidance when selected without one. Chat mic toast is availability-aware.
|
||||
- **Manage parity with hermes-desktop.** New `DashboardApiClient` methods + UI: **Models** tab gets "Change main model" (`/api/model/options` picker → `POST /api/model/set`, with the upstream expensive-model `confirm_required` round-trip); new **Keys** tab (`GET /api/env` inventory → Set (write-only, password-masked) / Reveal (`POST /api/env/reveal`, server rate-limited) / Clear (`DELETE /api/env` with JSON body)); **Profiles** tab gets New profile (`POST /api/profiles`, clone-from-default), Describe (`PUT .../description`), and per-profile Model (`PUT .../model`, shared picker). Overview gains Models + Keys tiles. Channel-managed env vars stay visible (tagged `channel`) since the app has no Channels page to defer to.
|
||||
- **Theme.** `RelayRefresh.Electric` softened `#111DFF` → `#4F5BD5` (user feedback: connections card too saturated/"blue" vs text + palette). Drives `relaySelectedPanel`, dark `primaryContainer`, light `primary`.
|
||||
- **Docs.** CLAUDE.md dashboard-surface paragraph now lists the audio/model/env/profile routes and the standard-voice auth model.
|
||||
|
||||
**Verified.** `:app:compileSideloadDebugKotlin`, `:app:lint`, and `:app:testGooglePlayDebugUnitTest` all green locally. On-device verification needed: Manage sign-in → standard voice turn on an API-only connection; Auto fallback with relay paired; model picker payload shape against the live dashboard (`parseModelOptions` is tolerant but unverified against real `build_models_payload` output).
|
||||
|
||||
**Follow-up (same day).** Closed the deferred parity items + re-scoped the blue:
|
||||
|
||||
- **Skills hub** — "Browse hub" on the Skills tab: multi-source search (`GET /api/skills/hub/search`, results marked installed via the lock-file map), SKILL.md **preview before install** (`/api/skills/hub/preview` → detail dialog), install (`POST .../install {identifier}`) / uninstall (`POST .../uninstall {name}`) / "Update installed" (`POST .../update`). All three mutations are **async spawns server-side** (`{ok, pid}`) — UI messages say "started — refresh Skills shortly" and install rows stay disabled to prevent double-fires. Dashboard client read timeout raised 30s→45s so the server's 30s search fan-out can't die client-side at the edge.
|
||||
- **SOUL editor** — "Edit SOUL" profile action fetches the **full** file (`GET /api/profiles/{name}/soul` is untruncated upstream, unlike the relay Inspector's 200KB cap), opens a monospace full-file editor dialog, `PUT {content}` on save; creates the file when absent.
|
||||
- **Blue re-scoped** — Bailey liked the original Electric elsewhere; reverted `Electric` to `#111DFF` and added `ElectricMuted` (`#4F5BD5`), applied only to the **active connection card** (was a full-opacity `primaryContainer` fill — the actual complaint) as a 0.42-alpha wash. Cockpit selected panels, pills, and light-theme primary keep the vivid brand blue.
|
||||
- **CHANGELOG** — `[Unreleased]` entries added (missed in the first commit batch; the dev-branch convention expects per-PR appends).
|
||||
|
||||
**Follow-up 2 (same day) — capability card, quiet standard-path UX, hub featured, onboarding copy.**
|
||||
|
||||
- **Capability card** — discovered the wizard's `StandardSetupResultCard` already renders Chat/Manage/Relay readiness lines; completed it with a **Voice** line instead of inventing a new surface. `StandardApiSetupResult` gains `voiceAvailability`, settled in the same setup probe (dashboard status → auth → audio-route HEAD) so the card and the mic gate are correct the moment setup finishes. Wording: Ready → "Speech ready via your Hermes server"; SignInRequired → "Unlocks with dashboard sign-in" (the existing Manage CTA covers it); Unsupported → "update or pair Relay".
|
||||
- **No spurious relay warnings on the standard path** (audit per Bailey). Verified `runVoiceRelayPreflight` only fires on the Realtime engine (correctly relay-gated). The real offender was **Voice Settings**: it fetched three relay configs on open and snackbar'd every failure — a standard-only user got two "Relay unreachable" snackbars for a route they don't use. Now gated on `relayVoiceReady`: fetches skipped entirely, relay-backed sections (Fallback TTS / Voice Output / Realtime config) replaced by one quiet "Voice Providers" card ("speaks through your Hermes server's configured TTS/STT — pair Relay to pick providers from the phone"), STT section shows a quiet line instead of permanent "loading...", and Test Current Engine labels the route honestly.
|
||||
- **Hub featured view** — `GET /api/skills/hub/sources` on dialog open: "Sources: Official (Nous), skills.sh, ..." line + featured skills (from the centralized index) listed before the first search, marked installed via the same lock map. Best-effort: silent on failure.
|
||||
- **Onboarding** — the rechrome (55a4227) reworked Welcome (sphere hero + Standard/Advanced paths) and the Connect step (shared ConnectionWizard), but Chat/Manage/Power remained icon + one sentence. They now carry three concrete feature rows each (reusing the Welcome page's row style): Chat = streaming/profiles/voice-no-install; Manage = control/skills-hub/one-sign-in; Power = terminal/bridge/realtime. Copy emphasizes the standard-first story ("no extra install", "signing in once also unlocks voice").
|
||||
|
||||
**Follow-up 3 (same day) — release polish: floating status pill, gesture ambient mode, media-settings scoping, voice.md standard route.**
|
||||
|
||||
- `RelayStatusStrip` is now a floating capsule (insets → 14dp side / 8dp bottom margins → pill clip) instead of a zero-radius bordered bar — the full-width rectangle clashed with rounded display corners. Gate caught a real overload error: Compose `padding()` can't mix `horizontal` with `top`/`bottom` — use start/end/top/bottom.
|
||||
- Ambient (fullscreen sphere) lost its top-bar toggle: long-press the conversation background to enter (bubbles keep their copy long-press — they consume first), tap/long-press anywhere to exit, transient "tap to return to chat" pill on every entry. Note: inside a Box nested in a Column, bare `AnimatedVisibility` resolves to the ColumnScope extension and fails — fully qualify `androidx.compose.animation.AnimatedVisibility`.
|
||||
- Media settings confirmed **Relay-only** (they govern `MEDIA:hermes-relay://<token>` fetches via MediaSettingsRepository) and now say so on-screen.
|
||||
- `user-docs/features/voice.md` intro rewritten: standard (no-Relay) voice via the dashboard audio routes is now the lead story, with a two-route tip block (Auto prefers Relay when paired) and Realtime marked relay-required. Remaining docs debt logged below.
|
||||
|
||||
**Follow-up 4 (same day) — chat enhancements + release docs.** Audit found most of the chat wishlist already shipped (scroll-to-bottom FAB with `userScrolledAway` auto-follow, session drawer search/pin/archive, not-connected empty state with Connect CTA, stop-during-streaming, tappable suggestion chips). Added the genuinely missing pieces: **Quote in reply** (bubble long-press now opens Copy/Quote menu when a quote handler is wired; copy-only call sites keep direct copy), **Share conversation** (top-bar share → Markdown via ACTION_SEND), Manage card **"More" overflow** for 5+ action rows, and the ambient-gesture tip in Appearance (a11y-discoverable). user-docs: new `guide/quick-start.md` (2-minute standard path, capability-card table, power-tools in a collapsed details block) registered first in the sidebar; `features/dashboard.md` Android Manage section rewritten per-section including hub/Keys/SOUL-editing (and the stale "SOUL editing requires the paired inspector" claim fixed); `features/voice.md` Requirements split standard-route vs relay-route. Release coordination: PR #64 (docs baseline refs → dev) overlaps us on CLAUDE.md/DEVLOG/upstream-contributions/dashboard.md — land #64 first, resolve in our PR. Dependabot PRs target `main` directly (off-policy); add `target-branch: dev` to `.github/dependabot.yml` as a follow-up.
|
||||
|
||||
**Docs debt (user-docs) for the release:** `features/dashboard.md` lacks the new Manage surfaces (Keys tab, model picker, profile create/describe/SOUL editor, skills-hub browse/featured); `guide/getting-started.md` (17.5KB) should split into a short Quick Start page + separate Install-options/Advanced pages; `features/voice.md` body still describes relay-era requirements beyond the new intro.
|
||||
|
||||
**Next.** When upstream PR #8199 lands `/v1/audio/*` on the API server, add it as the preferred standard route (capabilities already advertise `audio_api`) and demote the dashboard path to fallback. Remaining deferred parity: MCP manual add-server form (catalog install covers onboarding), per-profile cron/skill scoping in Manage.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-05 — Prefer upstream `/v1/skills` with legacy fallback
|
||||
|
||||
**Context.** Upstream Hermes Agent now has baseline skill/session API surface area, while Axiom's fork still preserves richer Relay-specific `/api/*` compatibility routes. The Android client should begin consuming upstream-compatible skill listings when present without breaking older fork/bootstrap installs.
|
||||
|
||||
**What changed.** `HermesApiClient.getSkills()` now tries `/v1/skills` first, then falls back to `/api/skills`. Skill parsing accepts upstream OpenAI-style list envelopes (`{"object":"list","data":[...]}`), legacy fork envelopes (`{"skills":[...]}` / `{"items":[...]}`), and direct arrays.
|
||||
|
||||
**Verification.** Added pure Kotlin unit coverage for endpoint order and `/v1/skills` `data` parsing. Verified with `ANDROID_HOME=$HOME/Android/Sdk ./gradlew :app:testGooglePlayDebugUnitTest --tests 'com.hermesandroid.relay.network.HermesApiClientTest'` → BUILD SUCCESSFUL. `git diff --check` passes.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-26 — Fix voice-mode crash: ExoPlayer audio session id read off-main (barge-in + legacy TTS)
|
||||
|
||||
**Report.** Discord user, sideload latest: voice chat crashes the instant Hermes starts answering — "I hear just 2 letters and it crashes." Stack: `IllegalStateException: Player is accessed on the wrong thread. Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'` with the Media3 `player-accessed-on-wrong-thread` doc link and a `Suppressed: ... Dispatchers.IO`.
|
||||
|
||||
**Root cause.** `Dispatchers.IO` threads are named `DefaultDispatcher-worker-N` (IO and Default share one scheduler pool), so the crash is on an IO coroutine. `BargeInListener` runs its mic reader on `Dispatchers.IO` and, to attach `AcousticEchoCanceler`, polls an `audioSessionIdProvider` lambda. On the **legacy `/voice/synthesize` (Media3) playback path**, `VoiceViewModel` wires that provider to `{ player.audioSessionId }` → `exoPlayer.audioSessionId`. ExoPlayer is thread-confined; its `getAudioSessionId()` getter calls `verifyApplicationThread()` and throws when read off-main. The realtime PCM path is unaffected because it wires the provider to an `AudioTrack` session id (thread-safe), which is why the bug only hit legacy/fallback setups. Sequence: first sentence starts → `runPlayWorker.onFileReady` → `startBargeInListenerIfEnabled()` → IO reader → `awaitNonZeroSessionId()` → off-main getter → crash ~2 syllables in.
|
||||
|
||||
**Fix.** `VoicePlayer.audioSessionId` now serves a `@Volatile cachedAudioSessionId` instead of the raw thread-confined getter. The cache is populated from main-thread Media3 callbacks: an `AnalyticsListener.onAudioSessionIdChanged` hook (authoritative, fires when Media3 allocates/reallocates the AudioTrack) plus a belt-and-braces read inside the existing `onIsPlayingChanged`. Reads from any thread are now safe.
|
||||
|
||||
**Tests.** Added `VoicePlayerTest` coverage: getter reflects the analytics-listener-cached id, never re-invokes `exoPlayer.audioSessionId` (the off-main call), and defaults to 0 before allocation. Captured the `AnalyticsListener` in the MockK harness. Verified locally: `:app:lintGooglePlayDebug` + `:app:testGooglePlayDebugUnitTest --tests VoicePlayerTest` both green (BUILD SUCCESSFUL).
|
||||
|
||||
**Next.** Landed on `dev` via PR #60, then shipped as the focused patch release `android-v0.8.1` (cherry-picked off the `android-v0.8.0` tag, PR #62); `main` merged back to `dev`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24 — Background Hermes runs in Realtime Agent voice (ADR 33)
|
||||
|
||||
**Context.** Realtime Agent ran each Hermes turn synchronously *inside* the provider event pump (`_run_brokered_tool` did `return await task`), so a long research/multi-tool/desktop run froze the whole realtime session until it finished. ADR 33 + `docs/plans/2026-05-24-realtime-background-hermes-runs.md` define a three-tier model (foreground / promoted / durable) with the relay as an explicit audio-floor owner. Branch `feature/realtime-background-hermes-runs`.
|
||||
|
||||
**What shipped (phased, per the plan):**
|
||||
|
||||
- **Phase 0 — idle-tolerance probe + verdict.** `scripts/realtime-provider-idle-probe.py` + an "Idle tolerance" section in `docs/realtime-voice-poc.md`. **Ran live against OpenAI** (`VOICE_TOOLS_OPENAI_KEY` in `~/.hermes/.env`): the session survived 10s/20s/30s quiescent windows and returned clean audio on every post-idle turn → verdict **`hold-floor-ok`**. xAI has no creds on the dev box, so its verdict is recorded analytically as `hold-floor-ok` (same `turn_detection:None` multi-turn model; the implementation closes the pending call rather than holding an open response) — confirm on the relay host. Incidental finding logged: OpenAI now wants `session.audio.output.format.rate` at `session.update` (minor `_session_update` follow-up; session still worked).
|
||||
- **Phase 1 — floor owner.** New `plugin/relay/realtime_agent/floor.py`: pure, single-owner audio floor (`provider` / `relay_tts` / `android_filler` mouths; `idle/provider_speaking/hermes_filler/result_pending` labels). Wired behavior-preservingly into the broker (acquire/release on AUDIO_DELTA/AUDIO_DONE/RESPONSE_DONE; relay-TTS render holds the floor; filler gated by `can_speak`). Invariants in `test_realtime_floor.py`.
|
||||
- **Phase 2 — Tier B promotion (was default off).** `_run_brokered_tool` shields the run and waits `promote_after_ms`; if still running it detaches to the background, closes the pending provider call with an interim ack, optionally speaks a handoff, and `_deliver_background_result` speaks the answer once the floor is idle. New events `hermes.run.promoted` / `hermes.run.background_completed` + `tier`/`floor` on progress; 8 new settings. `test_realtime_promotion.py` (promote+pump-responsive, short=no-promote, cancel, detach-resume-replays).
|
||||
- **Phase 3 — default-on + Tier C + Android + docs.** Flipped `promotion_enabled` default **true** (safe: the path closes the pending call rather than holding an open response, so the socket only sees the normal between-turns idle gap). `hermes_run_task(mode="background")` detaches immediately (`tier:"durable"`). Settings exposed on `GET/PATCH /voice/realtime-agent/config`. Android: parse new events → "working on it" chip; Voice Settings → Realtime Agent → Background tasks (promote toggle, spoken-handoff toggle, result-delivery segmented control) → `RelayVoiceClient.updateRealtimeAgentPromotion()`.
|
||||
|
||||
**Why default-on despite the Phase 0 gate.** The implementation closes the pending function call with an interim background ack instead of parking an open provider response, so the worst-case "idle open-response" the gate worried about doesn't occur — the socket sits in the same idle state it does between any two user turns. The probe is retained to confirm per-provider survival; documented in config + ADR.
|
||||
|
||||
**Verified.** Python realtime suite **58 tests green** (`test_realtime_floor`, `test_realtime_promotion`, both provider suites, routes, profile-voice-config). `./gradlew lint` — Kotlin compiles clean; the only 2 lint errors are in the gitignored `local.properties` (absent in CI). Pre-existing unrelated `test_reads_hermes_xai_oauth_credential_pool` failure confirmed on `origin/dev` baseline.
|
||||
|
||||
**Next.** Confirm the xAI idle verdict on the relay host (where xAI creds live); fix the OpenAI `_session_update` rate field; run the lab smoke on a paired device. Open the PR to `dev`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-23 — Un-defer the voice/audio test suite (issue #32) + barge-in resume bug
|
||||
|
||||
**Context.** GitHub issue #32 tracked 5 voice/audio unit tests `@Ignore`'d during the v0.5.1 release because the full `:app:testGooglePlayDebugUnitTest` task "hung indefinitely." Scope had quietly grown to **8** ignored classes (3 of the "pure-logic, should-work" ones got swept in defensively). Branch `fix/voice-test-suite`.
|
||||
|
||||
**Root-cause of the hang.** Not Robolectric's classloader (the v0.5.1 hypothesis) — it was `BargeInPreferencesTest` building its DataStore on a `TestScope(StandardTestDispatcher() + Job())` whose scheduler is **never advanced**. DataStore's reader actor never ran, so `repo.flow.first()` suspended forever. Fixed by backing the DataStore with a real dispatcher scope (`CoroutineScope(Dispatchers.IO + Job())`); the `runTest{}` bodies still drive the suspend calls within the dispatch timeout.
|
||||
|
||||
**Real product bug found (not just test infra).** Un-ignoring `VoiceViewModelBargeInTest` surfaced a genuine regression: the barge-in **"resume after interruption"** feature was silently broken. `onBargeInDetected()` → `interruptSpeaking()` → `startTtsConsumer()` restarts the play worker, which immediately hits an empty `audioQueue`, fires `onQueueDrained` → `clearSpokenChunksState()` **synchronously** (on `Dispatchers.Main.immediate`) — wiping `spokenChunks` before the 600 ms resume watchdog reads it. The watchdog always saw an empty tail and dropped the resume. Fixed by snapshotting the un-played tail (`pendingResumeTail`) synchronously in `onBargeInDetected()`, the instant the interrupt fires, instead of re-reading live state later.
|
||||
|
||||
**VoicePlayerTest / Robolectric.** No separate source set needed (the issue's proposed Phase 3). The "Robolectric leaks across forks and hangs the suite" symptom was a misattribution of the DataStore hang. With that fixed, VoicePlayerTest runs cleanly in the normal `test` source set under `@RunWith(RobolectricTestRunner) @Config(sdk=[34])` — added `robolectric 4.14.1` (testImplementation) + `unitTests.isIncludeAndroidResources = true`.
|
||||
|
||||
**Result.** All 8 issue-#32 classes un-ignored and green. Full suite: **525 completed, 12 skipped, 0 failed, no hang (~30 s)**. The 12 skipped are unrelated pre-existing `@Ignore`s (`ConnectionStoreTest` et al.).
|
||||
|
||||
**Also fixed (pre-existing failures surfaced while greening the suite):**
|
||||
|
||||
- **`CardDispatchSyncBuilder` bug** — `buildSyntheticMessages` short-circuited on `msg.cards.isEmpty()`, silently dropping dispatches whose card was trimmed from the rolling buffer. This directly contradicted the SUT's own docstring (and `CardDispatchSyncBuilderTest.buildSyntheticMessages_unknownCardKey_stillEmitsBareEnvelope`), which require a bare-envelope audit record in that case. Fixed the guard to gate on `cardDispatches.isEmpty()` only; the `card == null` fallback already handles the missing-card path.
|
||||
- **4 lint errors in sideload-only bridge code compiled into googlePlay.** `NotificationPermission` (`AutoDisableWorker`) — the real `hasPostNotificationsPermission()` early-return guard was already correct; the existing `@SuppressLint("MissingPermission")` just used the wrong ID, so added `"NotificationPermission"`. `ForegroundServiceType` ×3 (`BridgeForegroundService`) — the service + its `FOREGROUND_SERVICE_*`/`POST_NOTIFICATIONS` permissions are declared only in the **sideload** manifest; googlePlay deliberately omits them (no device-control, Play-Store compliance), making the code unreachable there. Suppressed with a justification rather than weakening googlePlay.
|
||||
|
||||
**Verified.** `:app:testGooglePlayDebugUnitTest` green (0 failures); `:app:lint` green (0 errors).
|
||||
|
||||
**Next.** Commit, merge `fix/voice-test-suite` → `dev`, close issue #32.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-19 — Experimental Realtime Hermes Voice Agent
|
||||
|
||||
**Plan.** [docs/plans/2026-05-19-realtime-hermes-voice-agent.md](docs/plans/2026-05-19-realtime-hermes-voice-agent.md) — add a switchable Android voice engine that brokers a realtime provider session (OpenAI first, xAI ready) while keeping Hermes as authority for profiles, sessions, memory, tool execution, Android bridge safety, confirmations, and cancellation. Stable `Hermes chat + voice output` remains the default and is untouched.
|
||||
|
||||
**Surface added.**
|
||||
|
||||
- **Relay broker** — `plugin/relay/realtime_agent/` package with `RealtimeAgentHandler` (HTTP + WSS) mounted at `/voice/realtime-agent/*` next to the existing `/voice/realtime/*` lab routes. Five HTTP routes (`config GET/PATCH`, `providers/{id}/options GET`, `providers/{id}/validate POST`, `session POST`) plus a websocket at `/voice/realtime-agent/{session_id}`. Disabled by default — `realtime_agent_enabled=False` until an operator opts in via Settings → Voice or `RELAY_REALTIME_AGENT_ENABLED=1`.
|
||||
- **Hermes tool broker** — `realtime_agent/hermes_tool_broker.py` is the narrow bridge from a realtime provider's function call into the Hermes `/v1/runs` SSE surface. Only the four `hermes_*` schemas (`hermes_run_task`, `hermes_get_status`, `hermes_cancel`, `hermes_confirm`) are visible to the provider — the realtime side can ask Hermes to work, check progress, cancel, or answer a confirmation, but never call Android bridge or skill tools directly. Hermes events are normalized into `hermes.*` ws events that mirror into the chat timeline so voice mode never has to be exited to see what happened.
|
||||
- **Provider adapters** — `realtime_agent/providers/{base,openai,xai}.py` behind the normalized adapter contract. OpenAI is the first-class implementation; xAI is ready behind the same contract and toggled by configured auth. Both adapters keep all provider-event vocabulary local to the adapter — broker logic switches on the normalized `ProviderEvent` shape only.
|
||||
- **Android engine selector** — `VoicePreferences.voiceEngineMode` (DataStore-backed, persists across launches) with a clean radio selector at the top of Settings → Voice. The Realtime Agent option carries an `Experimental` badge and a concise limitation note; defaults always coerce unknown stored values back to the stable engine so a downgraded build cannot strand a user.
|
||||
- **Android client + overlay timeline** — `RelayVoiceClient.runRealtimeAgent` drives the brokered ws end-to-end and surfaces realtime transcripts, Hermes tool state, confirmation prompts, and final responses through `RealtimeAgentTimelineMirror` into the same overlay + chat surfaces the stable engine already uses. No exit/reload required to see brokered tool work.
|
||||
|
||||
**What is preserved.** `/voice/realtime/*` lab routes, `/voice/output/*`, `/voice/transcribe`, and `/voice/synthesize` are unchanged. Stable voice mode tests still pass. `voice:realtime` capability gates both surfaces, so the existing pairing-grant flow covers the new engine.
|
||||
|
||||
**Validation.**
|
||||
- `python -m unittest plugin.tests.test_realtime_agent_*` — relay broker + Hermes tool broker + provider adapter tests passing.
|
||||
- Sibling Python tests (`test_realtime_voice_routes`, `test_profile_voice_config`, `test_provider_options`) still green — stable voice surface is regression-clean.
|
||||
- `:app:compileSideloadDebugKotlin` builds clean. Android UI tests live on-device and were not in scope for this pass (no adb requested).
|
||||
|
||||
**Notes.** Realtime providers cannot pre-empt Hermes safety — destructive Android bridge actions still surface as Hermes confirmation prompts and require an operator `hermes_confirm` ws answer routed through the existing approval flow. Provider disconnect surfaces a `voice.error` with `recoverable=true` so the Android client can fall back to stable voice without corrupting the Hermes chat session.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-25 (II) — Remote-PC ergonomics pass: PowerShell / process / job / transfer / health tools
|
||||
|
||||
**Context.** Bailey shipped a feedback list from a real remote-PC session: `desktop_terminal` was 502'ing on long-lived launches, no process-management primitives (had to `netstat | taskkill` manually), no bulk file sync, PowerShell echoing instead of executing, and no daemon-health introspection. The biggest single ask was "detached job/process API with persistent logs." Explicit no-go: program-specific shortcuts (no ComfyUI helper).
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>One Hermes agent. Two ways to use it.</strong><br>
|
||||
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
|
||||
use a server-deployed Hermes from your laptop as if it were running locally.
|
||||
<strong>Your self-hosted Hermes agent, native on your phone.</strong><br>
|
||||
Chat, voice, and full agent management over your own infrastructure —<br>
|
||||
plus an experimental desktop CLI that gives the agent hands on your computer.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI-orange.svg" alt="Desktop CLI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI%20%28alpha%29-orange.svg" alt="Desktop CLI (alpha)"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
|
||||
@@ -35,107 +35,136 @@
|
||||
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop app + CLI](#1b-desktop-app--cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local**. Windows gets the native tray app first: pair, start/pause the daemon, view devices, task log, settings, overlay status, and emergency stop. The CLI remains the terminal/headless surface and powers macOS/Linux installs. Experimental computer-use tools are opt-in. | **Experimental** — `desktop-v0.3.0-alpha.18` (Windows tray installer + native CLI binaries, no Node required) |
|
||||
| **[Android app](#quick-start-android)** | Native phone client — streaming chat, hands-free voice, full agent management (models, keys, skills, profiles), and on sideload builds the agent can read your screen and act on it. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop CLI](#desktop-cli-alpha)** | The agent reaching back to **your machine** — local tool routing (files, terminal, screenshots, clipboard) plus a remote shell to the host. | **Alpha** — `desktop-v*` releases, expect heavy changes |
|
||||
|
||||
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
|
||||
Both share the same WSS relay and credentials store. **Pair once from either, both work.**
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Quick Start (Android)
|
||||
|
||||
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
|
||||
Install → connect → talk, in about two minutes. A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**.
|
||||
|
||||
### 1a. Android app
|
||||
|
||||
<!-- TODO: Uncomment when Play Store listing is live
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80"></a>
|
||||
-->
|
||||
### 1. Install the app
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases/latest)
|
||||
- **APK** — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Full walkthrough — integrity verification, signing fingerprint, what's in each build — in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
#### Sideload APK (GitHub Releases)
|
||||
Sideload builds check GitHub for new releases and show a one-tap update banner when you're behind; Play builds update through the Play Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
### 2. Have Hermes running
|
||||
|
||||
1. Download the file ending in **`-sideload-release.apk`** from [the latest release](https://github.com/Codename-11/hermes-relay/releases/latest) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
|
||||
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
|
||||
3. Open the APK from your downloads and tap **Install**.
|
||||
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
|
||||
Run upstream Hermes with its API server and dashboard enabled:
|
||||
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
|
||||
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
|
||||
|
||||
### 1b. Desktop app + CLI (experimental)
|
||||
echo "Android API URL: http://<this-computer-ip>:8642"
|
||||
echo "Android API key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
The desktop surface talks to a server-deployed Hermes over WSS. On Windows, the default installer launches the native tray app with pairing, daemon control, devices, task log, settings, overlay status, pause, and emergency stop. The same release still ships the `hermes-relay` CLI for shell/TUI use, scripting, headless daemon mode, and macOS/Linux.
|
||||
Windows commands, dashboard auth notes, and upstream links: [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
|
||||
The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
|
||||
### 3. Connect and talk
|
||||
|
||||
**Install tray app** (Windows PowerShell):
|
||||
Open the app, choose **Standard Hermes**, and enter your server's address and API key. The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|---|---|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session also unlocks voice. That's the whole standard setup.
|
||||
|
||||
**Going places?** Put your server's Tailscale URL in the setup form's "Remote access" field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
|
||||
### 4. Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen; if you can't scan, use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**. (`/hermes-relay-pair` and the dashed `hermes-pair` shim remain for chat-surface and older builds.)
|
||||
|
||||
- **Updating:** `hermes-relay-update` — idempotent; or re-run the install one-liner.
|
||||
- **Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a "Relay" tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server · macOS / Linux / Windows for the desktop CLI.
|
||||
|
||||
## Desktop CLI (alpha)
|
||||
|
||||
> **Alpha — expect heavy changes.** With [hermes-desktop](https://hermes-agent.nousresearch.com) now covering chat and management on the desktop, this surface is being refocused into a pure remote **"hands" connector**: the agent reaching back through the relay to run tools on your machine (files, terminal, screenshots, clipboard, editor). The chat and shell features that overlap hermes-desktop will be removed in a future release. Binaries are unsigned during the experimental phase — SmartScreen/Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, and more **on your machine** over the same WSS relay — with a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch. No Node required; installs are self-contained native binaries.
|
||||
|
||||
**Install** (Windows PowerShell / macOS / Linux):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI only** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay # interactive Hermes TUI in tmux
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
hermes-relay daemon # headless tool router (agent reaches you anytime)
|
||||
hermes-relay daemon # headless tool router — agent reaches you anytime
|
||||
hermes-relay # interactive Hermes TUI in tmux (legacy, being refocused)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
|
||||
- **Docs:** [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track:** tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
**No Node required** — the Windows tray installer bundles the compiled CLI sidecar; CLI-only installs use Bun-compiled native binaries (~60–110 MB per platform) via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias for CLI installs, self-update via `hermes-relay update`. Assets are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
|
||||
## Features
|
||||
|
||||
- **Docs**: [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
### Android
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
- **Streaming chat** — direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage provider keys (write-only, masked, server-rate-limited reveal), create and edit agent profiles including `SOUL.md`, and browse, install, and update skills from the hub. One dashboard sign-in covers it all
|
||||
- **Voice mode** — talk hands-free on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice providers and an opt-in provider-native Realtime Agent with background task handoff
|
||||
- **Works away from home** — add your server's Tailscale or public URL and the app roams automatically: LAN at home, fallback elsewhere. Routes are editable per connection, and an unreachable server gets a diagnosis ("away from the server's network? add a route"), not just a red dot
|
||||
- **Multi-Connection + profiles** — pair with multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay an agent profile's model + `SOUL.md` per chat
|
||||
- **Phone control (bridge)** — with the Relay plugin paired, the agent reads the screen and acts on it: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros, and event-driven waits. Guarded by safety rails: per-app blocklist (banking/payments/2FA default-blocked), destructive-verb confirmation, idle auto-disable, full activity log
|
||||
- **Notification companion** — opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
### Desktop CLI
|
||||
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
|
||||
- **Local tool routing** — `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` run on your machine; agent-proposed patches render as colored diffs with interactive approval
|
||||
- **Daemon mode** — headless tool router; the agent can reach you with no shell open
|
||||
- **Multi-endpoint pairing, reconnect-on-drop, TOFU cert pinning** — same model as the Android app
|
||||
- **Self-update** — `hermes-relay update` verifies SHA256 and atomic-swaps the binary
|
||||
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
|
||||
## Install with an AI agent
|
||||
|
||||
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
|
||||
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
|
||||
|
||||
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
|
||||
|
||||
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
|
||||
|
||||
### For AI Agents
|
||||
|
||||
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
|
||||
If an AI assistant (Claude, GPT, etc.) manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
|
||||
@@ -146,109 +175,38 @@ Read the canonical setup recipe before acting:
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via the plugin-provided `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
|
||||
Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes skill — invoke it from any chat with `/hermes-relay-self-setup` for re-setup, troubleshooting, or "is everything wired correctly?" checks. Single source, two delivery modes (raw URL pre-install + Hermes skill post-install), no drift.
|
||||
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
|
||||
|
||||
| Surface | Channel | What | Status |
|
||||
|---------|---------|------|--------|
|
||||
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
|
||||
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
|
||||
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
|
||||
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
|
||||
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
|
||||
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
|
||||
|
||||
## What's new in v0.6.0
|
||||
|
||||
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
|
||||
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
|
||||
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
|
||||
|
||||
See the [changelog](CHANGELOG.md) for the full list.
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
|
||||
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
|
||||
- **Voice mode** — Experimental server-mediated voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Hermes owns chat, tool calls, and approvals, while relay voice output defaults to provider-neutral streaming TTS (`xai_tts` first) with realtime voice-agent providers kept as a separate lab mode.
|
||||
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
|
||||
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
|
||||
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
|
||||
- **Notification companion** — Opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Bridge safety rails** — Per-app blocklist (banking, payments, 2FA default-blocked), destructive-verb confirmation modal (send, pay, delete, transfer…), idle auto-disable timer, optional persistent-status overlay, full activity log
|
||||
- **Security & pairing** — QR-code pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health, and peak-time charts
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the full sideload capability matrix.
|
||||
|
||||
### Desktop CLI
|
||||
|
||||
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
|
||||
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
|
||||
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval. Experimental `desktop_computer_*` control tools require `--experimental-computer-use` / `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`, task-scoped grants, and visible local approval.
|
||||
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
|
||||
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
|
||||
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
|
||||
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
|
||||
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
|
||||
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
|
||||
|
||||
## Getting Started
|
||||
|
||||
**Android:**
|
||||
|
||||
1. **Install the app** from the [link above](#1a-android-app)
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
**Desktop CLI:**
|
||||
|
||||
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
|
||||
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
|
||||
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Relay Server (:8767) [voice routes — API key or relay session]
|
||||
Phone (WSS/HTTP) --> Relay Server (:8767) [terminal, bridge, media, sessions]
|
||||
Desktop CLI (WSS) --> Relay Server (:8767) [tui, terminal, desktop tools]
|
||||
Phone (HTTP) --> Hermes Dashboard (:9119) [manage + standard voice — cookie sign-in]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
Desktop CLI (WSS) --> Relay (:8767) [desktop tools, tui, terminal]
|
||||
```
|
||||
|
||||
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
|
||||
Chat connects directly to the Hermes API Server with the API key — the same pattern used by Open WebUI and other Hermes frontends. The Manage tab and standard voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla install needs no plugin for either. The optional relay on `:8767` adds the power surfaces — terminal, bridge phone control, media handoff, desktop tools, and relay-side voice providers (preferred automatically when paired). One QR can configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, pairing, subcommands, local tool routing |
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs relay-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `v*`, Relay `relay-v*`, and desktop `desktop-v*`) |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, Desktop `desktop-v*`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -269,7 +227,7 @@ scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
scripts/dev.bat relay # Start Server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
@@ -278,7 +236,7 @@ scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── relay_server/ # WSS Server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
@@ -291,7 +249,7 @@ hermes-relay/
|
||||
├── user-docs/ # VitePress documentation site (Android + desktop sections)
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-relay / ci-desktop)
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-server / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
@@ -301,13 +259,13 @@ hermes-relay/
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
### Relay Server (optional — bridge, terminal, TUI, media, and voice routes)
|
||||
### Server (optional — bridge, terminal, TUI, media, and relay voice routes)
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
@@ -325,7 +283,7 @@ See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setu
|
||||
|
||||
### Hermes Plugin (for contributors)
|
||||
|
||||
End users should install via the [one-liner](#2-install-the-server-plugin-one-liner) at the top. For local development from a clone:
|
||||
End users should install via the [one-liner](#4-optional-install-relay-for-power-tools) above. For local development from a clone:
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
@@ -333,7 +291,7 @@ cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
Then restart hermes and run the plugin-provided `hermes pair` to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. `/hermes-relay-pair` and the dashed `hermes-pair` shim remain available for chat-surface and older-build compatibility.
|
||||
|
||||
## Hermes Agent
|
||||
|
||||
|
||||
+64
-59
@@ -17,13 +17,16 @@ Hermes-Relay now ships three independently versioned surfaces:
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Android app | `v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release.yml` |
|
||||
| Relay server / Python package | `relay-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-relay-version.sh` | `.github/workflows/release-relay.yml` |
|
||||
| Android app | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Server / Python package | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-server-version.sh` | `.github/workflows/release-server.yml` |
|
||||
| Desktop CLI | `desktop-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-desktop.yml` |
|
||||
|
||||
This split is intentional. The Relay server now carries features for both
|
||||
Android and desktop, so Relay fixes can ship without forcing an Android app
|
||||
versionCode bump, and desktop CLI alphas can continue on their own cadence.
|
||||
This split is intentional. The server now carries features for both Android
|
||||
and desktop, so server fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and desktop CLI alphas can continue on their own cadence.
|
||||
Historical Android releases before this naming split used bare `v*` tags, and
|
||||
historical server releases used `relay-v*` tags. New releases use the explicit
|
||||
surface prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
@@ -67,9 +70,9 @@ bash scripts/bump-android-version.sh 0.6.2
|
||||
`scripts/bump-version.sh` remains as a backward-compatible alias for the
|
||||
Android script.
|
||||
|
||||
### Relay server / Python package versioning
|
||||
### Server / Python package versioning
|
||||
|
||||
Relay version metadata lives in these relay-owned files and must stay in
|
||||
Server version metadata lives in these server-owned files and must stay in
|
||||
lockstep:
|
||||
|
||||
| File | Line | Purpose |
|
||||
@@ -81,20 +84,20 @@ lockstep:
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Relay releases via:
|
||||
Always bump Server releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-relay-version.sh 0.6.2
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Check the current metadata with:
|
||||
|
||||
```bash
|
||||
python scripts/check-relay-version-sync.py
|
||||
python scripts/check-server-version-sync.py
|
||||
```
|
||||
|
||||
The `relay-v*` release workflow validates the tag against the same metadata,
|
||||
runs Relay tests, builds a wheel and sdist, generates checksums, and publishes
|
||||
The `server-v*` release workflow validates the tag against the same metadata,
|
||||
runs server tests, builds a wheel and sdist, generates checksums, and publishes
|
||||
a GitHub Release with the package artifacts.
|
||||
|
||||
## Branching policy
|
||||
@@ -113,8 +116,8 @@ release" rule. The `[Unreleased]` section of `CHANGELOG.md` on `dev` is
|
||||
the accumulator: every merged PR appends bullets there. A release is a
|
||||
separate act, taken when the accumulated state on `dev` is worth shipping
|
||||
(see "When to cut a release" below). Cutting a release means opening a
|
||||
`release: vX.Y.Z` PR from `dev` into `main`, merging it `--no-ff`, then
|
||||
tagging `main`.
|
||||
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
|
||||
then tagging `main`.
|
||||
|
||||
**Server tracks `dev` for staging.** The hermes-host deployment pulls
|
||||
`dev` so merged features get exercised against real data before they
|
||||
@@ -153,14 +156,14 @@ Squash merges lose that detail and are **not** the house style.
|
||||
### Version bumps happen at release-prep on `dev`, NOT on feature branches
|
||||
|
||||
Feature branches **never** touch `gradle/libs.versions.toml`,
|
||||
relay-owned version metadata, or `desktop/package.json`.
|
||||
server-owned version metadata, or `desktop/package.json`.
|
||||
If two feature branches both bumped a release version, they'd collide on
|
||||
version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release: vX.Y.Z`; Relay commits use
|
||||
`release(relay): relay-vX.Y.Z`; desktop commits use the existing
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||
use `release(server): server-vX.Y.Z`; desktop commits use the existing
|
||||
`release: desktop-vX.Y.Z` convention. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
@@ -170,7 +173,7 @@ work. Android commits use `release: vX.Y.Z`; Relay commits use
|
||||
Light branch protection is enabled:
|
||||
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Relay) before merge. Force push and
|
||||
here. PR must pass CI (Android + Server) before merge. Force push and
|
||||
branch deletion blocked.
|
||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
||||
branches PR in. PR must pass CI. Force push and branch deletion
|
||||
@@ -206,10 +209,12 @@ hermes.key.password=YOUR_KEY_PASSWORD
|
||||
```
|
||||
|
||||
`local.properties`, `*.keystore`, and `*.jks` are already gitignored.
|
||||
Relative `hermes.keystore.path` values resolve from the repo root, so
|
||||
`release.keystore` works when the keystore lives beside this file.
|
||||
|
||||
> If the keystore at `hermes.keystore.path` is missing, `app/build.gradle.kts`
|
||||
> silently falls back to debug signing. The build succeeds but Play Console
|
||||
> rejects the AAB — always verify with `keytool -list -printcert` (step 3
|
||||
> rejects the AAB — always verify with `keytool -printcert` (step 3
|
||||
> below).
|
||||
|
||||
#### CI builds
|
||||
@@ -318,7 +323,7 @@ is a statement to users that "this is a thing worth updating to," so
|
||||
the threshold is intent-driven, not event-driven.
|
||||
|
||||
If you want to dogfood accumulated `main` state without declaring GA,
|
||||
tag a **pre-release** (`vX.Y.Z-rc.N`). Users can opt in via
|
||||
tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
|
||||
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||
the unstable build.
|
||||
|
||||
@@ -375,7 +380,7 @@ the new app version and a higher `appVersionCode`.
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
keytool -list -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
|
||||
keytool -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
|
||||
```
|
||||
|
||||
The `keytool` output must show your release certificate (the CN/OU/O
|
||||
@@ -395,7 +400,7 @@ Optional device smoke test: `scripts\dev.bat release` then
|
||||
### 4. Commit on `dev`, merge to `main`, tag from `main`
|
||||
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `v<version>` tag is cut from the
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
|
||||
```bash
|
||||
@@ -405,27 +410,27 @@ git pull --ff-only origin dev
|
||||
|
||||
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
app/src/main/assets/whats_new.txt docs/play-store-listing.md
|
||||
git commit -m "release: v0.6.2"
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag v0.6.2
|
||||
git push origin v0.6.2
|
||||
git tag android-v0.6.2
|
||||
git push origin android-v0.6.2
|
||||
```
|
||||
|
||||
Pushing a tag matching `v*` triggers `.github/workflows/release.yml`,
|
||||
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
|
||||
Relay/Python version files are intentionally not part of an Android app
|
||||
release unless the Relay package itself is also being released.
|
||||
Server/Python version files are intentionally not part of an Android app
|
||||
release unless the server package itself is also being released.
|
||||
|
||||
### Relay server / Python package release
|
||||
### Server / Python package release
|
||||
|
||||
Use this when Relay server behavior changes independently of Android app
|
||||
Use this when Server behavior changes independently of Android app
|
||||
delivery, for example desktop channel support, bridge routes, pairing
|
||||
server fixes, voice auth, or packaging changes.
|
||||
|
||||
@@ -433,23 +438,23 @@ server fixes, voice auth, or packaging changes.
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-relay-version.sh 0.6.2
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md
|
||||
git commit -m "release(relay): relay-v0.6.2"
|
||||
git commit -m "release(server): server-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag relay-v0.6.2
|
||||
git push origin relay-v0.6.2
|
||||
git tag server-v0.6.2
|
||||
git push origin server-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `relay-v*` triggers `.github/workflows/release-relay.yml`, which
|
||||
validates all relay-owned version metadata with
|
||||
`scripts/check-relay-version-sync.py`, runs Relay tests, builds a wheel and
|
||||
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the Relay
|
||||
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
|
||||
validates all server-owned version metadata with
|
||||
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
|
||||
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
|
||||
package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
@@ -506,9 +511,9 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
`RELEASE_NOTES.md` this will already be baked in. If for some reason
|
||||
it's missing, edit the body with:
|
||||
```bash
|
||||
gh release view vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
gh release view android-vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
# edit /tmp/body.md to add/fix the Download section
|
||||
gh release edit vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
gh release edit android-vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
```
|
||||
(This step was only needed as a retrofit for v0.1.0 — v0.1.1+ inherit
|
||||
the Download section automatically from `RELEASE_NOTES.md`.)
|
||||
@@ -517,11 +522,11 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
|
||||
## CI Behavior
|
||||
|
||||
Android, Relay, dashboard, and desktop now have separate CI/release lanes.
|
||||
This keeps a dashboard CSS fix from running the full Relay suite, and keeps
|
||||
Relay server changes from forcing an Android app `versionCode` bump.
|
||||
Android, Server, dashboard, and desktop now have separate CI/release lanes.
|
||||
This keeps a dashboard CSS fix from running the full server suite, and keeps
|
||||
server changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
1. Validates the tag matches `appVersionName` in
|
||||
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||
@@ -532,22 +537,22 @@ On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
6. Creates a GitHub Release named `v<version>` with `RELEASE_NOTES.md` as
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `v0.2.0-beta.1`) as a prerelease automatically.
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
|
||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
||||
|
||||
On every push of a tag matching `relay-v*`,
|
||||
`.github/workflows/release-relay.yml`:
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-server.yml`:
|
||||
|
||||
1. Validates the tag matches all relay-owned version metadata checked by
|
||||
`scripts/check-relay-version-sync.py`.
|
||||
2. Runs Relay syntax checks and the focused route/auth/session test slice.
|
||||
1. Validates the tag matches all server-owned version metadata checked by
|
||||
`scripts/check-server-version-sync.py`.
|
||||
2. Runs server syntax checks and the focused route/auth/session test slice.
|
||||
3. Builds the Python wheel and sdist with `python -m build`.
|
||||
4. Generates `dist/SHA256SUMS.txt`.
|
||||
5. Creates a GitHub Release named `relay-v<version>` with the wheel,
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
|
||||
sdist, and checksum file attached.
|
||||
|
||||
On every push of a tag matching `desktop-v*`,
|
||||
@@ -574,7 +579,7 @@ bump the version source for the surface you are shipping.
|
||||
|
||||
For an Android app hotfix:
|
||||
|
||||
1. `git checkout -b fix/short-name v0.6.1` — branch from the released
|
||||
1. `git checkout -b fix/short-name android-v0.6.1` — branch from the released
|
||||
Android tag (not from `main` or `dev`).
|
||||
2. Apply the fix, add a test, commit.
|
||||
3. Run `bash scripts/bump-android-version.sh 0.6.2` to update
|
||||
@@ -582,7 +587,7 @@ For an Android app hotfix:
|
||||
4. Update `RELEASE_NOTES.md`, `CHANGELOG.md`, in-app What's New, and Play
|
||||
listing notes as needed.
|
||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
||||
6. `git tag v0.6.2` from the new `main` tip and `git push origin v0.6.2`
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Upload to Play Console as normal.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
@@ -590,9 +595,9 @@ For an Android app hotfix:
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Relay server hotfix, branch from the affected `relay-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-relay-version.sh <next-version>`, merge to
|
||||
`main`, and tag `relay-v<next-version>`. Do not touch
|
||||
For a Server hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-server-version.sh <next-version>`, merge to
|
||||
`main`, and tag `server-v<next-version>`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -600,10 +605,10 @@ the fix, run `bash scripts/bump-relay-version.sh <next-version>`, merge to
|
||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||
You pushed a tag before bumping `gradle/libs.versions.toml`, or vice versa.
|
||||
Fix: update the file, commit, delete the remote tag
|
||||
(`git push --delete origin vX`), re-tag, and push again.
|
||||
(`git push --delete origin android-vX`), re-tag, and push again.
|
||||
|
||||
**Play Console rejects the AAB as debug-signed**
|
||||
Run `keytool -list -printcert -jarfile <aab>` locally — if it shows
|
||||
Run `keytool -printcert -jarfile <aab>` locally — if it shows
|
||||
`CN=Android Debug`, fix `local.properties` for local builds or
|
||||
`HERMES_KEYSTORE_BASE64` for CI. For CI, check the workflow summary; if it
|
||||
says "Debug-signed", one of the four `HERMES_*` secrets is missing or the
|
||||
|
||||
+26
-58
@@ -1,76 +1,44 @@
|
||||
# Hermes-Relay v0.7.0
|
||||
# Unreleased
|
||||
|
||||
**Release Date:** May 19, 2026
|
||||
**Since v0.6.1:** profile-aware chat/voice state, relay-owned voice provider settings, realtime voice lab/testbench routes, Android voice overlay polish, and Relay package voice-provider support.
|
||||
## Changed
|
||||
|
||||
v0.7.0 is a minor release for the profile and voice workstream. The stable Android path is still Hermes chat streaming plus relay-managed voice output, while realtime provider work remains isolated as a lab/testbench and planned experimental mode.
|
||||
- Android now defaults to a standard Hermes layout with **Chat**, **Manage**, and **Settings** in bottom navigation. Terminal and Bridge remain available under **Settings → Power tools** and through existing routes.
|
||||
- Added a native **Manage** surface backed by the Hermes dashboard/admin API for Skills, Cron, MCP servers/catalog, Profiles, Models, and Config. It supports dashboard sign-in, common management actions, cron run details, and read-only profile SOUL details without requiring relay pairing.
|
||||
- Relay-only features now show a consistent **Requires pairing** / **Pair to unlock** gate when the active connection is not paired.
|
||||
- Connections now model API auth, dashboard auth, and relay pairing separately. Dashboard URLs derive from the API host on port `9119` by default.
|
||||
|
||||
---
|
||||
|
||||
# Hermes-Relay-Android v0.8.1
|
||||
|
||||
**Release Date:** May 26, 2026
|
||||
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
|
||||
|
||||
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v0.7.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| sideload | `hermes-relay-0.7.0-sideload-release.apk` | Recommended for full bridge/device-control features, voice overlay testing, and profile-aware relay features. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| Google Play | `hermes-relay-0.7.0-googlePlay-release.aab` | Conservative Play-track build for chat, profiles, and voice without sideload-only bridge-control surfaces. |
|
||||
| googlePlay APK | `hermes-relay-0.7.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.7.0-sideload-release.aab` | Parity/testing artifact. |
|
||||
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
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 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
|
||||
## Fixed
|
||||
|
||||
### Profile-aware Hermes use
|
||||
### Voice mode crash with barge-in on legacy TTS playback
|
||||
|
||||
- Profile selection now resolves against the active server and keeps profile-specific chat sessions separate.
|
||||
- Default/Victor display is normalized so the selected profile name stays visible through streamed and finalized messages.
|
||||
- Session drawer and voice settings can reflect the active profile instead of treating every connection as one shared default context.
|
||||
- Profile API URL resolution handles per-profile Hermes API servers and avoids phone-side `localhost` fallbacks when a remote profile is selected.
|
||||
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
|
||||
|
||||
### Voice settings and output quality
|
||||
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
|
||||
|
||||
- Relay now owns profile voice configuration endpoints instead of depending on Hermes config edits for realtime voice settings.
|
||||
- Android can fetch provider/model/voice option metadata, save per-profile voice choices, and fall back to advanced manual entry when a provider cannot expose a complete option list.
|
||||
- Voice output uses balanced coalescing: assistant speech is grouped into natural chunks, while tool/status speech remains immediate.
|
||||
- Waveform and playback state are better aligned to real audio output, reducing premature mic return and output-state jitter.
|
||||
- Barge-in remains explicitly experimental, with known self-capture limitations documented in settings.
|
||||
|
||||
### Realtime provider lab and Relay package
|
||||
|
||||
- Added standalone voice lab CLI/TUI tooling, provider adapters, waveform/playback support, evaluation helpers, and generated WAV/JSONL artifact ignores for OpenAI, xAI, ElevenLabs, and stub testing.
|
||||
- Added relay routes for streaming voice output, realtime playground calls, provider options, and profile voice config.
|
||||
- Added a plan for the next experimental Realtime Hermes Voice Agent mode, where providers handle speech but Hermes remains the authority for profiles, sessions, memory, tools, confirmations, and transcript history.
|
||||
|
||||
### Android voice UI polish
|
||||
|
||||
- Voice mode includes better tap-to-talk, continuous-mode, overlay, compact-mode, and state-display behavior.
|
||||
- Continuous mode no longer starts a session solely because the preference is enabled; voice sessions start and stop through explicit controls.
|
||||
- Voice overlay state is closer to chat state, including live transcript/tool timeline surfaces without forcing an exit and reload.
|
||||
|
||||
### Included groundwork
|
||||
|
||||
- Desktop tray pairing and consent-flow improvements are included from the dev branch.
|
||||
- Experimental shared `relay-core`, `relay-ui`, and Quest prototype modules are included for future shared pairing/terminal/voice work. They do not change the Android phone app's default flow.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- Relay version metadata: `python scripts/check-relay-version-sync.py --expect 0.7.0` passed.
|
||||
- Android version metadata: `scripts\dev.bat version` reported `Hermes-Relay v0.7.0 (versionCode 9)`.
|
||||
- Relay route/auth/session/provider slice: 99 pytest tests passed.
|
||||
- Voice lab provider/tooling slice: 31 pytest tests passed.
|
||||
- Android sideload and Google Play Kotlin compile passed.
|
||||
- Focused Android voice/profile unit slice and release-CI unit slice passed.
|
||||
|
||||
## Post-install smoke
|
||||
|
||||
- Install the sideload APK over the existing sideload app with `adb install -r`.
|
||||
- Existing pairing should survive a same-flavor update. Re-pair only if you uninstall app data, switch flavor/applicationId, revoke the device, or intentionally clear the server session store.
|
||||
- Confirm the profile selector shows the expected server default and named profiles, then create/switch a chat while watching that the agent name remains stable.
|
||||
- In Voice settings, confirm the selected profile's provider/model/voice options load, save a per-profile voice, and run a short voice test.
|
||||
- In Voice mode, test tap-to-talk first, then continuous mode. Leave barge-in off unless you are explicitly testing the experimental self-capture path.
|
||||
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
|
||||
|
||||
### Desktop track (parallel lane to Android) — **experimental**
|
||||
|
||||
Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Server `server-v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
|
||||
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
|
||||
|
||||
@@ -47,7 +47,7 @@ Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed
|
||||
|
||||
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
|
||||
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls GitHub Releases API (`/repos/Codename-11/hermes-relay/releases/latest`), compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls the GitHub Releases API, filters to `desktop-v*`, compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
|
||||
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
|
||||
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
|
||||
|
||||
@@ -43,6 +43,14 @@ to tool state, safety prompts, or the current task.
|
||||
- **Audio quality guardrails** — normalize output volume across realtime and
|
||||
fallback TTS providers, keep pronunciation hints/profile voice tuning, and
|
||||
measure provider-specific delay, chunk gaps, and tail clipping.
|
||||
- **Pluggable Realtime Agent media transports** — add an OpenAI-first WebRTC
|
||||
transport option for Realtime Agent so mobile audio can use provider-native
|
||||
jitter buffering, interruption, and media handling instead of only relay
|
||||
WebSocket PCM. Design this as a provider transport interface
|
||||
(`websocket`, `webrtc`, future `livekit`/SIP-style bridges) so other
|
||||
realtime providers can opt in without forking the Hermes broker/tool
|
||||
contract. Hermes must still own tools, memory, confirmations, current data,
|
||||
and durable transcript state.
|
||||
- **Voice engine selector** — implemented as an opt-in experimental Realtime
|
||||
Agent engine in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
Follow-up work is provider-native turn-taking, richer confirmation handling,
|
||||
|
||||
+18
-17
@@ -54,11 +54,10 @@ android {
|
||||
Properties().apply { localProps.inputStream().use { stream -> load(stream) } }
|
||||
} else null
|
||||
|
||||
storeFile = file(
|
||||
System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
)
|
||||
val keystorePath = System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
storeFile = rootProject.file(keystorePath)
|
||||
storePassword = System.getenv("HERMES_KEYSTORE_PASSWORD")
|
||||
?: props?.getProperty("hermes.keystore.password") ?: ""
|
||||
keyAlias = System.getenv("HERMES_KEY_ALIAS")
|
||||
@@ -68,20 +67,18 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 3 — Bridge channel release tracks ────────────────────────────────
|
||||
// Google Play scrutinizes AccessibilityService heavily (policy review + manual
|
||||
// appeals are common), so Phase 3 ships two distinct tracks via flavor-merged
|
||||
// manifests + flavor-scoped strings + flavor-scoped accessibility configs:
|
||||
// ─── Bridge release tracks ─────────────────────────────────────────────────
|
||||
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
|
||||
// media, notification companion, relay sessions, and status. It does not
|
||||
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
|
||||
// control, SMS/call/contact/location, or unattended-control permissions.
|
||||
//
|
||||
// googlePlay — conservative use-case description targeted at Play Store
|
||||
// policy review. Subset of event types + flagDefault only.
|
||||
// No gestures, no interactive-window reporting. Feature gates
|
||||
// in BuildFlavor.kt hide tier 3/4/6 surfaces in the UI.
|
||||
// googlePlay — canonical Play Store install. Bridge Core only.
|
||||
//
|
||||
// sideload — full agent-control description for users who install the
|
||||
// APK directly (GitHub Releases, F-Droid, ADB). typeAllMask,
|
||||
// gestures, interactive windows, view-id reporting. All six
|
||||
// tiers enabled.
|
||||
// sideload — Device Control for users who install directly (GitHub
|
||||
// Releases, F-Droid, ADB). AccessibilityService, gestures,
|
||||
// screenshots, overlay/status chip, and phone utilities are
|
||||
// declared in the sideload manifest.
|
||||
//
|
||||
// applicationIdSuffix decision: sideload gets `.sideload` so both tracks can
|
||||
// coexist on the same device. The Play build keeps the base
|
||||
@@ -166,6 +163,9 @@ android {
|
||||
// both failing with RuntimeException from unmocked Log.w calls.
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
// Robolectric (VoicePlayerTest) needs merged Android resources +
|
||||
// manifest on the unit-test classpath to bootstrap its sandbox.
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ dependencies {
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
// MockWebServer for ADR 24 EndpointResolver tests — probes HEAD /health
|
||||
|
||||
-1
@@ -4,7 +4,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PowerFeatureGateUiTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun requiresPairingCard_showsPairToUnlock() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Requires pairing").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair to unlock").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("This feature uses relay grants", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredPairingCard_showsPairAgain() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Pairing expired").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair again").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardSignInCard_usesDashboardLanguage() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Manage",
|
||||
summary = "Open dashboard-backed management features.",
|
||||
status = PowerFeatureGateStatus.DashboardSignInRequired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Dashboard sign-in required").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Open sign-in").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
-1
@@ -6,7 +6,6 @@ import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
+64
-162
@@ -1,22 +1,19 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the onboarding pager flow.
|
||||
*
|
||||
* These tests require an Android device or emulator because they use
|
||||
* Compose UI testing APIs and interact with real Compose components.
|
||||
* Instrumented tests for the Standard-first onboarding pager.
|
||||
*/
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@@ -24,270 +21,175 @@ class OnboardingFlowTest {
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
onComplete = { _, _, _ -> }
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page 1: Welcome ---
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesRelayTitle() {
|
||||
fun firstPage_showsHermesForAndroidTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes-Relay")
|
||||
.onNodeWithText("Hermes-Relay for Android")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsWelcomeDescription() {
|
||||
fun firstPage_showsStandardFirstDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
|
||||
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Skip button ---
|
||||
|
||||
@Test
|
||||
fun skipButton_isAlwaysVisible_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip")
|
||||
.onNodeWithText("Standard")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Navigation: Next button ---
|
||||
|
||||
@Test
|
||||
fun nextButton_isDisplayed_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.onNodeWithText("Advanced")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Setup Guide")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Docs")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toPage2() {
|
||||
fun nextButton_navigatesForward_toChatPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1 -> Page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2 is "Talk to Your Agent"
|
||||
composeTestRule
|
||||
.onNodeWithText("Talk to Your Agent")
|
||||
.onNodeWithText("Chat")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughAllPages() {
|
||||
fun canNavigateForward_throughStandardAndPowerPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1: Hermes-Relay (Welcome)
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2: Talk to Your Agent (Chat)
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 3: Remote Terminal
|
||||
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 4: Device Bridge
|
||||
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Connect").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 5: Connect to Hermes
|
||||
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 6: Relay Server (last page)
|
||||
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Back button ---
|
||||
|
||||
@Test
|
||||
fun backButton_hiddenOnFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// On page 1, Back should not exist
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_visibleOnPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Go to page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
|
||||
// Go back to page 1
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Page 5: Connect page ---
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiServerUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4) // 0-indexed, page 5 is index 4
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiKeyField() {
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Key (optional)", substring = true)
|
||||
.onNodeWithText("Standard Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_whereDoIFindThis_showsHelpDialog() {
|
||||
fun standardSetup_showsApiFields() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Tap "Where do I find this?"
|
||||
composeTestRule
|
||||
.onNodeWithText("Where do I find this?")
|
||||
.performClick()
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should show
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_helpDialog_canBeDismissed() {
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Where do I find this?").performClick()
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog is showing
|
||||
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
|
||||
|
||||
// Dismiss it
|
||||
composeTestRule.onNodeWithText("Got it").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should be gone
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- Page 6: Relay page ---
|
||||
|
||||
@Test
|
||||
fun relayPage_showsOptionalMessaging() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5) // Last page
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("This is optional", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayPage_showsRelayUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Relay URL (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Get Started button ---
|
||||
|
||||
@Test
|
||||
fun lastPage_showsGetStartedButton() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
// Default URL is "http://localhost:8642" which is non-blank
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.onNodeWithText("Connect")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
// --- Skip button visibility across pages ---
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnAllPages() {
|
||||
fun connectPage_keepsPairingOptional() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Check skip on first page
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
|
||||
// Navigate through all pages and check skip
|
||||
for (i in 0 until 5) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
repeat(4) {
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now — set up later in Settings")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,52 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for Terminal and Bridge empty state screens.
|
||||
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
|
||||
*/
|
||||
class EmptyStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
// --- Terminal Screen ---
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTitle() {
|
||||
fun terminalScreen_showsCurrentTopBar() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val terminalViewModel = TerminalViewModel(app)
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote Terminal")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPhase2Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 2")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Secure shell access", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Full ANSI terminal emulator", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("tmux session management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Bridge Screen ---
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTitle() {
|
||||
fun bridgeScreen_showsCurrentTopBar() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Device Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPhase3Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Agent-controlled device interaction", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Permission management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,23 @@
|
||||
Merged on top of `app/src/main/AndroidManifest.xml` by AGP when the
|
||||
`googlePlayDebug` / `googlePlayRelease` variants are built.
|
||||
|
||||
The AccessibilityService is declared exactly once in `app/src/main/AndroidManifest.xml`.
|
||||
The flavor distinction is purely at the resource layer: this flavor's
|
||||
`res/xml/accessibility_service_config.xml` carries the conservative use-case
|
||||
description required for Google Play policy review, and `res/values/strings.xml`
|
||||
carries the description string. Gradle's resource merger picks the right
|
||||
files at build time, so we don't need to redeclare the <service> here.
|
||||
Google Play ships Hermes Bridge Core only. It intentionally does not merge
|
||||
any Device Control services or permissions.
|
||||
|
||||
This file is intentionally kept as an empty overlay so future flavor-specific
|
||||
permissions / activities have an obvious home. Mirror structural additions
|
||||
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
|
||||
track-specific.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Media3 ExoPlayer contributes a power-management permission from its
|
||||
library manifest. Strip it from the merged Play artifact. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WAKE_LOCK"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- googlePlay inherits main manifest's specialUse-only FGS type
|
||||
directly — no override needed. The sideload manifest ADDS
|
||||
mediaProjection via tools:replace; googlePlay gets the safe
|
||||
default. -->
|
||||
<application />
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Google Play flavor strings.
|
||||
|
||||
`a11y_description_googleplay` is the user-facing description shown in
|
||||
Android's Accessibility settings when enabling the Hermes Bridge service.
|
||||
It is ALSO what Play Store reviewers read when evaluating our
|
||||
AccessibilityService use-case declaration, so phrasing matters: stay
|
||||
narrowly scoped, emphasize user confirmation, emphasize dormancy until
|
||||
the user opts in inside the app.
|
||||
|
||||
Do not reference voice or vision features here — tier 3/4/6 are gated
|
||||
off for this flavor via FeatureFlags.BuildFlavor.
|
||||
-->
|
||||
<resources>
|
||||
<string name="a11y_service_label">Hermes-Bridge</string>
|
||||
<string name="a11y_description_googleplay">Hermes assists you by reading on-screen content and summarizing notifications. The service is read-only — it does not perform taps, type text, or control other apps. It is dormant until you explicitly enable Bridge mode in the app.</string>
|
||||
</resources>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Google Play AccessibilityService configuration.
|
||||
|
||||
Conservative event-type subset targeted at the "read notifications,
|
||||
summarize messages, reply with confirmation" use case Play Store policy
|
||||
review expects. Does NOT subscribe to typeAllMask, does NOT request
|
||||
gestures, does NOT request flagRetrieveInteractiveWindows.
|
||||
|
||||
Keep these attributes aligned with the description in strings.xml
|
||||
(`a11y_description_googleplay`) — if the description widens, reviewers
|
||||
will expect the config to widen too.
|
||||
-->
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/a11y_description_googleplay"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewClicked"
|
||||
android:accessibilityFlags="flagDefault"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:notificationTimeout="100"
|
||||
android:canRetrieveWindowContent="true" />
|
||||
@@ -6,74 +6,9 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- === A8 wake-lock: keep CPU awake while dispatching bridge gestures === -->
|
||||
<!-- Normal-protection permission (no runtime prompt). Held only inside
|
||||
WakeLockManager.wakeForAction { ... }, with a 10s hard timeout and
|
||||
ref-counted release. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- === PHASE3-accessibility: AccessibilityService + bridge permissions === -->
|
||||
<!-- BIND_ACCESSIBILITY_SERVICE is intentionally NOT declared as a
|
||||
<uses-permission> here — it's a system-only permission granted to
|
||||
services that declare android:permission on their <service> tag
|
||||
(see the BridgeAccessibilityService entry below). Declaring it as
|
||||
a uses-permission trips lint's [ProtectedPermissions] check.
|
||||
|
||||
FOREGROUND_SERVICE* are for the persistent notification that
|
||||
Agent safety-rails will wire in Wave 2 for MediaProjection-backed
|
||||
screenshots. POST_NOTIFICATIONS is required on API 33+ for that
|
||||
same foreground-service notification. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- FOREGROUND_SERVICE_MEDIA_PROJECTION moved to sideload manifest.
|
||||
googlePlay doesn't need screen recording: /screenshot route is
|
||||
gated sideload-only in BridgeCommandHandler. Declaring the
|
||||
permission on the Play track would flag review since our
|
||||
accessibility use-case ("read-only screen reading") doesn't
|
||||
justify screen capture. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- === END PHASE3-accessibility === -->
|
||||
|
||||
<!-- === PHASE3-safety-rails: safety service + overlay === -->
|
||||
<!-- SYSTEM_ALERT_WINDOW is user-granted via Settings.ACTION_MANAGE_OVERLAY_PERMISSION.
|
||||
Used for (a) the destructive-verb confirmation modal that must be
|
||||
visible even when Hermes isn't in the foreground, and (b) the optional
|
||||
floating "Hermes active" status chip. The permission is declared here
|
||||
so the user-visible grant flow triggers, but the overlay itself only
|
||||
appears when the user has explicitly consented.
|
||||
|
||||
FOREGROUND_SERVICE_SPECIAL_USE is required on Android 14+ for the
|
||||
persistent "Bridge active" notification (BridgeForegroundService),
|
||||
because the specialUse type needs its own declared permission. -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- === PHASE3-baseline-handlers: package visibility for /get_apps + /open_app === -->
|
||||
<!-- On Android 11+ (API 30+), apps can only see other packages that
|
||||
are implicitly visible (own UID, system apps, etc.) unless they
|
||||
declare a <queries> filter or hold QUERY_ALL_PACKAGES. The
|
||||
BridgeCommandHandler /get_apps route uses
|
||||
queryIntentActivities(ACTION_MAIN + CATEGORY_LAUNCHER) to enumerate
|
||||
launchable apps, and BridgeSafetySettingsScreen uses the same call
|
||||
to populate the blocklist UI — both need this declaration to see
|
||||
the full launcher set. Without it queryIntentActivities silently
|
||||
returns a near-empty list (typical symptom: blocklist UI shows
|
||||
only Hermes-Relay itself + a handful of system apps).
|
||||
|
||||
Declaring an <intent> filter with ACTION_MAIN + CATEGORY_LAUNCHER
|
||||
is the Play-policy-safe approach — it does NOT require the
|
||||
restricted QUERY_ALL_PACKAGES permission, which Play would
|
||||
otherwise demand a policy declaration for. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
<!-- === END PHASE3-baseline-handlers === -->
|
||||
|
||||
<application
|
||||
android:name=".HermesRelayApp"
|
||||
android:allowBackup="true"
|
||||
@@ -89,6 +24,7 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -106,30 +42,6 @@
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- === PHASE3-accessibility: AccessibilityService declaration === -->
|
||||
<!-- The @xml/accessibility_service_config resource is provided by
|
||||
the flavor-specific source sets (app/src/googlePlay/ and
|
||||
app/src/sideload/) owned by Agent flavor-split. Each flavor declares its
|
||||
own accessibility use-case description and flag bitset — the
|
||||
googlePlay track declares a conservative "notifications + reply
|
||||
with confirmation" use case, the sideload track declares the
|
||||
full agent-control use case. Gradle merges the flavor XML into
|
||||
main at build time.
|
||||
-->
|
||||
<service
|
||||
android:name=".accessibility.HermesAccessibilityService"
|
||||
android:exported="true"
|
||||
android:label="@string/a11y_service_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
<!-- === END PHASE3-accessibility === -->
|
||||
|
||||
<!-- === PHASE3-notif-listener: notification companion service === -->
|
||||
<service
|
||||
android:name=".notifications.HermesNotificationCompanion"
|
||||
@@ -142,46 +54,6 @@
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
<!-- === PHASE3-safety-rails: safety service + overlay === -->
|
||||
<!-- BridgeForegroundService is a plain (non-exported) foreground
|
||||
service driven by BridgeViewModel based on the master toggle.
|
||||
It owns the persistent "Hermes agent has device control"
|
||||
notification.
|
||||
|
||||
foregroundServiceType is the OR of two API 34+ subtypes:
|
||||
|
||||
- specialUse — backs the persistent "bridge active"
|
||||
indicator we shipped with Tier 5 safety rails. Comes
|
||||
with the SPECIAL_USE foreground-service permission and
|
||||
the Play Console policy declaration.
|
||||
|
||||
- mediaProjection — REQUIRED by Android 14+ before any
|
||||
call to MediaProjectionManager.getMediaProjection().
|
||||
Without this declaration, getMediaProjection() returns
|
||||
a projection that the system auto-revokes within a
|
||||
frame, leaving us with a permanently-null
|
||||
MediaProjectionHolder.projection. Symptom on the
|
||||
device: consent dialog appears, user allows full
|
||||
screen, dialog closes, grant evaporates. Sample-tested
|
||||
on a Samsung S24 / Android 14 on 2026-04-12.
|
||||
|
||||
Both types share the same notification + same lifecycle —
|
||||
one service, one notification, two type slots.
|
||||
|
||||
Android 14+ requires a <property> tag justifying the
|
||||
specialUse subtype. The mediaProjection subtype does NOT
|
||||
need a property tag because it has its own dedicated
|
||||
permission (FOREGROUND_SERVICE_MEDIA_PROJECTION). -->
|
||||
<service
|
||||
android:name=".bridge.BridgeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains a persistent WebSocket connection to the user's Hermes server for real-time chat relay and notification mirroring. The service is dormant until the user explicitly enables Bridge mode in the app." />
|
||||
</service>
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
v0.7.0 - Profiles, voice settings, and realtime voice polish
|
||||
|
||||
Profiles
|
||||
* Profile switching now keeps profile-specific chat/session state separate.
|
||||
* Default/Victor display is normalized so the selected agent name stays visible.
|
||||
* Profile API URL handling is safer for remote Hermes profile servers.
|
||||
v0.8.1 - Voice mode crash fix
|
||||
|
||||
Voice
|
||||
* Voice settings can show and save provider/model/voice choices per profile.
|
||||
* Voice output uses balanced coalescing for smoother replies.
|
||||
* Waveform and playback state better follow real audio output.
|
||||
* Continuous mode no longer starts a session just because auto mode is enabled.
|
||||
* Barge-in is now clearly marked experimental with known self-capture caveats.
|
||||
|
||||
Relay
|
||||
* Added relay-owned profile voice config, provider options, streaming voice output,
|
||||
and realtime voice playground endpoints for OpenAI/xAI/ElevenLabs testing.
|
||||
* Fixed a crash that could hit voice mode when barge-in was enabled on the
|
||||
legacy text-to-speech path — the agent's first words no longer cut off
|
||||
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
|
||||
paths were never affected.
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
@@ -50,6 +51,10 @@ class MainActivity : ComponentActivity() {
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val data = result.data
|
||||
if (!BuildFlavor.isSideload) {
|
||||
Log.w(TAG, "Ignoring MediaProjection result on Google Play Bridge Core build")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
Log.i(TAG, "MediaProjection consent granted — handing off to FGS")
|
||||
BridgeForegroundService.grantMediaProjection(this, result.resultCode, data)
|
||||
@@ -88,13 +93,15 @@ class MainActivity : ComponentActivity() {
|
||||
// Hand the launcher to the process-singleton rendezvous so
|
||||
// BridgeViewModel.requestScreenCapture() can fire the consent
|
||||
// dialog without holding an Activity reference.
|
||||
ScreenCaptureRequester.install {
|
||||
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
try {
|
||||
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.install {
|
||||
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
try {
|
||||
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
@@ -140,15 +147,21 @@ class MainActivity : ComponentActivity() {
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
// access manager only attempts dismiss when an activity is
|
||||
// registered AND the user has opted in.
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
}
|
||||
// Re-probe the credential-lock state on resume so the Bridge
|
||||
// tab badge updates immediately if the user just changed their
|
||||
// lock screen in system Settings between app sessions.
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@@ -157,7 +170,9 @@ class MainActivity : ComponentActivity() {
|
||||
// Drop the launcher closure so we don't hold a stale Activity ref
|
||||
// after destroy. ScreenCaptureRequester.request() will return false
|
||||
// until the next MainActivity instance reinstalls itself.
|
||||
ScreenCaptureRequester.uninstall()
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.uninstall()
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -65,10 +65,10 @@ import kotlinx.serialization.json.put
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `unattended.supported` is false on the googlePlay flavor — the Play
|
||||
* APK has no wake-lock path — which lets the agent distinguish "user
|
||||
* hasn't opted in" from "this build can't do unattended at all" without
|
||||
* a separate probe.
|
||||
* `bridge.device_control_supported` and `unattended.supported` are false on
|
||||
* the googlePlay flavor — the Play APK ships Bridge Core without
|
||||
* AccessibilityService, wake locks, overlays, screenshots, or unattended
|
||||
* control — which lets the agent avoid attempting sideload-only tools.
|
||||
*
|
||||
* The legacy top-level keys (`screen_on`, `battery`, `current_app`,
|
||||
* `accessibility_enabled`, `ts`) are ALSO emitted for backwards
|
||||
@@ -188,6 +188,7 @@ class BridgeStatusReporter(
|
||||
val currentApp = HermesAccessibilityService.instance?.currentApp
|
||||
val accessibilityGranted = HermesAccessibilityService.instance != null
|
||||
val masterEnabled = HermesAccessibilityService.instance?.isMasterEnabled() ?: false
|
||||
val deviceControlSupported = BuildFlavor.isSideload
|
||||
|
||||
// Screen-capture grant — the process-singleton holder is non-null
|
||||
// iff the user granted MediaProjection consent this session.
|
||||
@@ -257,10 +258,17 @@ class BridgeStatusReporter(
|
||||
})
|
||||
})
|
||||
put("bridge", buildJsonObject {
|
||||
put("master_enabled", masterEnabled)
|
||||
put("accessibility_granted", accessibilityGranted)
|
||||
put("screen_capture_granted", screenCaptureGranted)
|
||||
put("overlay_granted", overlayGranted)
|
||||
put("device_control_supported", deviceControlSupported)
|
||||
put("master_enabled", if (deviceControlSupported) masterEnabled else false)
|
||||
put(
|
||||
"accessibility_granted",
|
||||
if (deviceControlSupported) accessibilityGranted else false,
|
||||
)
|
||||
put(
|
||||
"screen_capture_granted",
|
||||
if (deviceControlSupported) screenCaptureGranted else false,
|
||||
)
|
||||
put("overlay_granted", if (deviceControlSupported) overlayGranted else false)
|
||||
put("notification_listener_granted", notificationListenerGranted)
|
||||
})
|
||||
put("safety", buildJsonObject {
|
||||
@@ -285,11 +293,18 @@ class BridgeStatusReporter(
|
||||
// when both `enabled=true` and this is true, commands
|
||||
// will wake the screen but stop at the lock screen.
|
||||
put("unattended", buildJsonObject {
|
||||
put("supported", BuildFlavor.isSideload)
|
||||
put("enabled", UnattendedAccessManager.enabled.value)
|
||||
put("supported", deviceControlSupported)
|
||||
put(
|
||||
"enabled",
|
||||
if (deviceControlSupported) UnattendedAccessManager.enabled.value else false,
|
||||
)
|
||||
put(
|
||||
"credential_lock_detected",
|
||||
UnattendedAccessManager.credentialLockDetected.value,
|
||||
if (deviceControlSupported) {
|
||||
UnattendedAccessManager.credentialLockDetected.value
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -300,8 +315,8 @@ class BridgeStatusReporter(
|
||||
// groups above.
|
||||
put("screen_on", screenOn)
|
||||
put("battery", batteryFinal)
|
||||
put("current_app", currentApp ?: "unknown")
|
||||
put("accessibility_enabled", accessibilityGranted)
|
||||
put("current_app", if (deviceControlSupported) currentApp ?: "unknown" else "unknown")
|
||||
put("accessibility_enabled", if (deviceControlSupported) accessibilityGranted else false)
|
||||
put("ts", System.currentTimeMillis())
|
||||
}
|
||||
)
|
||||
|
||||
+6
-7
@@ -267,13 +267,12 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
* # Fallback semantics
|
||||
*
|
||||
* `service.windows` returns an empty list unless the accessibility
|
||||
* config XML requests `flagRetrieveInteractiveWindows`. That flag is
|
||||
* **only** set in the `sideload` flavor — the `googlePlay` flavor
|
||||
* deliberately runs on the conservative config subset to pass Play
|
||||
* Store policy review. When `windows` is empty (or throws, or every
|
||||
* window's root is null) we fall back to a single-element list
|
||||
* wrapping [rootInActiveWindow], preserving pre-P1 behaviour on
|
||||
* `googlePlay` builds.
|
||||
* config XML requests `flagRetrieveInteractiveWindows`. The service is
|
||||
* declared only by the `sideload` manifest, and that sideload config sets
|
||||
* the flag. When `windows` is empty (or throws, or every window's root is
|
||||
* null) we fall back to a single-element list wrapping
|
||||
* [rootInActiveWindow], preserving pre-P1 behaviour for tests and
|
||||
* defensive runtime fallback.
|
||||
*
|
||||
* Returns an empty list only if the service cannot read any window
|
||||
* root at all (e.g. lock screen, master-off state). Callers should
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -19,51 +25,168 @@ 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 {
|
||||
class RealtimePcmPlayer(context: Context? = null) {
|
||||
private val trackLock = Any()
|
||||
private val writeLock = Any()
|
||||
private val audioManager =
|
||||
context?.applicationContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
private val realtimeAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
|
||||
Log.i(TAG, "Realtime PCM audio focus change=$change")
|
||||
}
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var audioFocusHeld: Boolean = false
|
||||
private var currentSampleRate: Int = 0
|
||||
private var currentVolume: Float = 1f
|
||||
private var estimatedPlaybackEndAtMs: Long = 0L
|
||||
private var playbackStarted: Boolean = false
|
||||
private var pendingStartBytes: Int = 0
|
||||
private var firstBufferedAtMs: Long = 0L
|
||||
private var lastUnderrunCount: Int = 0
|
||||
private var lastHeadPositionLogAtMs: Long = 0L
|
||||
private var lastLoggedHeadFrames: Int = 0
|
||||
private var headAdvanceConfirmed: Boolean = false
|
||||
private var playbackStartedAtMs: Long = 0L
|
||||
private var totalFramesWritten: Long = 0L
|
||||
// (endFrame, rms) per written chunk — lets [playbackAmplitude] report the
|
||||
// amplitude of the audio actually at the hardware cursor right now, instead
|
||||
// of the chunk that most recently *arrived* over the socket.
|
||||
private val playbackAmpQueue = ArrayDeque<FrameAmp>()
|
||||
private var lastPlaybackGapDiagnosticAtMs: Long = 0L
|
||||
private var lastMutedVolumeDiagnosticAtMs: Long = 0L
|
||||
private var adaptiveStartPrebufferMs: Long = RealtimePcmBufferPolicy.START_PREBUFFER_MS
|
||||
private var playbackGapSeenThisTrack: Boolean = false
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
val isActive: Boolean
|
||||
get() = audioTrack != null
|
||||
get() = synchronized(trackLock) { audioTrack != null }
|
||||
|
||||
val audioSessionId: Int
|
||||
get() = audioTrack?.audioSessionId ?: 0
|
||||
get() = synchronized(trackLock) { audioTrack?.audioSessionId ?: 0 }
|
||||
|
||||
fun write(pcm: ByteArray, sampleRate: Int): Float {
|
||||
if (pcm.isEmpty()) return 0f
|
||||
val level = computePcm16LeRms(pcm)
|
||||
val track = ensureTrack(sampleRate)
|
||||
try {
|
||||
val written = track.write(pcm, 0, pcm.size)
|
||||
if (written > 0) {
|
||||
_amplitude.value = level
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val written = synchronized(writeLock) {
|
||||
val track = try {
|
||||
synchronized(trackLock) {
|
||||
val currentTrack = ensureTrackLocked(sampleRate)
|
||||
notePlaybackGapLocked(currentTrack, now)
|
||||
currentTrack
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM track preparation failed: ${e.message}")
|
||||
synchronized(trackLock) { releaseTrackLocked(reason = "PCM track preparation failure") }
|
||||
return@synchronized 0
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM write failed: ${e.message}")
|
||||
return 0f
|
||||
|
||||
try {
|
||||
val prerollWritten = maybeWriteStartupPreroll(track, sampleRate)
|
||||
if (prerollWritten < 0) {
|
||||
Log.w(TAG, "PCM preroll write returned $prerollWritten; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM preroll write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
// Intentionally do NOT start playback on the bare silent preroll.
|
||||
// Starting here would begin draining ~120ms of silence with zero
|
||||
// real audio queued, guaranteeing an immediate underrun on the
|
||||
// first speech chunk. The real audio written just below feeds the
|
||||
// normal start decision, and the end-of-turn flush
|
||||
// (voice.output_audio.done) force-starts anything still buffered.
|
||||
|
||||
val writtenBytes = writeBlocking(track, pcm)
|
||||
if (writtenBytes < 0) {
|
||||
Log.w(TAG, "PCM write returned $writtenBytes; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
var accepted = 0
|
||||
if (writtenBytes > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(writtenBytes, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(level)
|
||||
maybeStartPlaybackLocked(track, sampleRate, force = false)
|
||||
updateUnderrunCursorLocked(track)
|
||||
logPlaybackHealthLocked(track, now)
|
||||
accepted = writtenBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM write failed: ${e.message}")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write failure")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
}
|
||||
if (written > 0) {
|
||||
_amplitude.value = level
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
private fun writeBlocking(track: AudioTrack, pcm: ByteArray): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
track.write(pcm, 0, pcm.size)
|
||||
}
|
||||
|
||||
fun flushBufferedPlayback(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0L
|
||||
maybeStartPlaybackLocked(track, currentSampleRate, force = true)
|
||||
val remaining = remainingPlaybackMsLocked(now, cushionMs)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM flush playState=${readPlayState(track)} " +
|
||||
"headFrames=${readHeadFrames(track)} remainingMs=$remaining " +
|
||||
"underruns=${readUnderrunCount(track)}",
|
||||
)
|
||||
remaining
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
audioTrack?.let { track ->
|
||||
Log.i(TAG, "Stopping streaming PCM playback")
|
||||
try { track.pause() } catch (_: Exception) { }
|
||||
try { track.flush() } catch (_: Exception) { }
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
synchronized(writeLock) {
|
||||
synchronized(trackLock) {
|
||||
releaseTrackLocked(reason = "stop")
|
||||
currentSampleRate = 0
|
||||
estimatedPlaybackEndAtMs = 0L
|
||||
}
|
||||
}
|
||||
audioTrack = null
|
||||
currentSampleRate = 0
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
fun estimatedRemainingPlaybackMs(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
remainingPlaybackMsLocked(now, cushionMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
currentVolume = clamped
|
||||
try { audioTrack?.setVolume(clamped) } catch (_: Exception) { }
|
||||
synchronized(trackLock) {
|
||||
currentVolume = clamped
|
||||
try { audioTrack?.setVolume(clamped) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun duck() {
|
||||
@@ -74,19 +197,70 @@ class RealtimePcmPlayer {
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
private fun ensureTrack(sampleRate: Int): AudioTrack {
|
||||
private fun releaseTrackLocked(reason: String) {
|
||||
audioTrack?.let { track ->
|
||||
Log.i(TAG, "Stopping streaming PCM playback ($reason)")
|
||||
try { track.pause() } catch (_: Exception) { }
|
||||
try { track.flush() } catch (_: Exception) { }
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
}
|
||||
abandonAudioFocusLocked()
|
||||
settleAdaptivePrebufferLocked()
|
||||
audioTrack = null
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = 0
|
||||
lastHeadPositionLogAtMs = 0L
|
||||
lastLoggedHeadFrames = 0
|
||||
headAdvanceConfirmed = false
|
||||
playbackStartedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
playbackAmpQueue.clear()
|
||||
playbackGapSeenThisTrack = false
|
||||
}
|
||||
|
||||
private fun enqueuePlaybackAmplitudeLocked(rms: Float) {
|
||||
// [totalFramesWritten] has already been advanced past this chunk, so it
|
||||
// is the chunk's end frame. The cursor reaches this amplitude once
|
||||
// playbackHeadPosition passes the previous end frame.
|
||||
playbackAmpQueue.addLast(FrameAmp(endFrame = totalFramesWritten, rms = rms))
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeFirst()
|
||||
}
|
||||
|
||||
/**
|
||||
* Amplitude of the audio currently at the hardware cursor (0 if not playing
|
||||
* or drained). This is the playback-synced signal a UI waveform should draw:
|
||||
* it advances with [AudioTrack.getPlaybackHeadPosition], so it matches what
|
||||
* the user hears rather than what most recently arrived over the socket.
|
||||
*/
|
||||
fun playbackAmplitude(): Float = synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0f
|
||||
if (!playbackStarted) return@synchronized 0f
|
||||
val head = readHeadFrames(track).toLong()
|
||||
// Drop fully-played chunks so the head of the queue is the one playing now.
|
||||
while (playbackAmpQueue.size > 1 && playbackAmpQueue.first().endFrame <= head) {
|
||||
playbackAmpQueue.removeFirst()
|
||||
}
|
||||
amplitudeAtHead(playbackAmpQueue, head)
|
||||
}
|
||||
|
||||
private fun ensureTrackLocked(sampleRate: Int): AudioTrack {
|
||||
val existing = audioTrack
|
||||
if (existing != null && currentSampleRate == sampleRate) {
|
||||
return existing
|
||||
}
|
||||
stop()
|
||||
releaseTrackLocked(reason = "sample rate changed")
|
||||
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val bufferSize = max(minBuffer, sampleRate / 2 * 2)
|
||||
val bufferSize = RealtimePcmBufferPolicy.streamBufferSize(
|
||||
minBufferBytes = minBuffer,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
val format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
@@ -95,12 +269,7 @@ class RealtimePcmPlayer {
|
||||
|
||||
val track = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAudioFormat(format)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
@@ -116,14 +285,402 @@ class RealtimePcmPlayer {
|
||||
AudioTrack.MODE_STREAM,
|
||||
)
|
||||
}
|
||||
track.play()
|
||||
try { track.setVolume(currentVolume) } catch (_: Exception) { }
|
||||
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
throw IllegalStateException("AudioTrack failed to initialize")
|
||||
}
|
||||
|
||||
requestAudioFocusLocked()
|
||||
audioTrack = track
|
||||
currentSampleRate = sampleRate
|
||||
Log.i(TAG, "Started streaming PCM playback at ${sampleRate}Hz session=${track.audioSessionId}")
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
// Log requested vs. actual allocated frames. If a device coerces our
|
||||
// sub-second request back up to a multi-second allocation, that's the
|
||||
// tell-tale of deep-buffer routing (the cold-start parking class) and
|
||||
// explains a regression of the silent-first-turn bug on new hardware.
|
||||
val requestedFrames = bufferSize / BYTES_PER_FRAME
|
||||
val actualFrames = try { track.bufferSizeInFrames } catch (_: Exception) { -1 }
|
||||
Log.i(
|
||||
TAG,
|
||||
"Initialized streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} buffer=${bufferSize}B " +
|
||||
"requestedFrames=$requestedFrames actualFrames=$actualFrames " +
|
||||
"(${frameMs(actualFrames, sampleRate)}ms)",
|
||||
)
|
||||
return track
|
||||
}
|
||||
|
||||
private fun frameMs(frames: Int, sampleRate: Int): Long {
|
||||
if (frames <= 0 || sampleRate <= 0) return 0L
|
||||
return (frames * 1000L / sampleRate)
|
||||
}
|
||||
|
||||
private fun noteWrittenBytesLocked(writtenBytes: Int, sampleRate: Int) {
|
||||
if (writtenBytes <= 0 || sampleRate <= 0) return
|
||||
totalFramesWritten += (writtenBytes / BYTES_PER_FRAME).toLong()
|
||||
val durationMs = ((writtenBytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!playbackStarted) {
|
||||
if (firstBufferedAtMs == 0L) firstBufferedAtMs = now
|
||||
pendingStartBytes += writtenBytes
|
||||
return
|
||||
}
|
||||
val base = max(now, estimatedPlaybackEndAtMs)
|
||||
estimatedPlaybackEndAtMs = base + durationMs
|
||||
}
|
||||
|
||||
private fun maybeWriteStartupPreroll(track: AudioTrack, sampleRate: Int): Int {
|
||||
if (
|
||||
synchronized(trackLock) {
|
||||
playbackStarted ||
|
||||
pendingStartBytes > 0 ||
|
||||
firstBufferedAtMs > 0L ||
|
||||
sampleRate <= 0 ||
|
||||
audioTrack !== track
|
||||
}
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val prerollMs = startupPrerollMsLocked()
|
||||
val silenceBytes = RealtimePcmBufferPolicy.bytesForDurationMs(sampleRate, prerollMs)
|
||||
if (silenceBytes <= 0) return 0
|
||||
|
||||
val written = writeBlocking(track, ByteArray(silenceBytes))
|
||||
if (written > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(written, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(0f) // preroll is silence
|
||||
Log.i(
|
||||
TAG,
|
||||
"Primed realtime PCM playback with " +
|
||||
"${RealtimePcmBufferPolicy.durationMsForBytes(written, sampleRate)}ms " +
|
||||
"silent preroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
private fun startupPrerollMsLocked(): Long {
|
||||
return RealtimePcmBufferPolicy.STARTUP_PREROLL_MS
|
||||
}
|
||||
|
||||
private fun maybeStartPlaybackLocked(
|
||||
track: AudioTrack,
|
||||
sampleRate: Int,
|
||||
force: Boolean,
|
||||
) {
|
||||
if (playbackStarted || pendingStartBytes <= 0 || sampleRate <= 0) return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val waitedMs = if (firstBufferedAtMs > 0L) now - firstBufferedAtMs else 0L
|
||||
val decision = RealtimePcmBufferPolicy.startDecision(
|
||||
pendingBytes = pendingStartBytes,
|
||||
sampleRate = sampleRate,
|
||||
waitedMs = waitedMs,
|
||||
force = force,
|
||||
startPrebufferMs = adaptiveStartPrebufferMs,
|
||||
)
|
||||
if (!decision.shouldStart) return
|
||||
|
||||
try {
|
||||
requestAudioFocusLocked()
|
||||
track.play()
|
||||
try { track.setVolume(currentVolume) } catch (_: Exception) { }
|
||||
} catch (e: Exception) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
audioTrack = null
|
||||
throw e
|
||||
}
|
||||
|
||||
playbackStarted = true
|
||||
estimatedPlaybackEndAtMs = now + decision.bufferedMs
|
||||
playbackStartedAtMs = now
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = readHeadFrames(track)
|
||||
headAdvanceConfirmed = false
|
||||
Log.i(
|
||||
TAG,
|
||||
"Started streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} prebuffer=${decision.bufferedMs}ms " +
|
||||
"waited=${waitedMs}ms target=${adaptiveStartPrebufferMs}ms " +
|
||||
"reason=${decision.reason} playState=${readPlayState(track)} " +
|
||||
"headFrames=$lastLoggedHeadFrames ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically logs whether the AudioTrack hardware cursor is actually
|
||||
* advancing. This is the decisive signal for the "speaking animation + valid
|
||||
* PCM logs but no sound" class of bug:
|
||||
*
|
||||
* - head frames advancing + still no sound → output route / volume problem
|
||||
* (e.g. the Samsung HAL not opening the path until a volume key nudges it).
|
||||
* - head frames pinned at the start value → the track was play()'d but the
|
||||
* mixer never pulled from it (focus / state problem on this device).
|
||||
*/
|
||||
private fun logPlaybackHealthLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val headFrames = readHeadFrames(track)
|
||||
// First-frame detection runs on EVERY write until confirmed (not gated by
|
||||
// the throttle) and uses a fresh timestamp, so time-to-first-audio is
|
||||
// accurate to write cadence rather than the 1s health-log window — the
|
||||
// throttle/stale-`now` combination otherwise inflates it by ~1.5s.
|
||||
if (!headAdvanceConfirmed && headFrames > 0) {
|
||||
headAdvanceConfirmed = true
|
||||
val freshNow = SystemClock.elapsedRealtime()
|
||||
val ttfaMs = if (playbackStartedAtMs > 0L) freshNow - playbackStartedAtMs else -1L
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM time-to-first-audio=${ttfaMs}ms (headFrames=$headFrames)",
|
||||
)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Realtime audio started",
|
||||
detail = "First sample reached the speaker after ${ttfaMs}ms.",
|
||||
)
|
||||
}
|
||||
if (now - lastHeadPositionLogAtMs < HEAD_POSITION_LOG_THROTTLE_MS) return
|
||||
val advancedFrames = headFrames - lastLoggedHeadFrames
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM playback health playState=${readPlayState(track)} " +
|
||||
"headFrames=$headFrames advanced=$advancedFrames " +
|
||||
"underruns=${readUnderrunCount(track)} ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
if (advancedFrames <= 0) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Realtime PCM hardware cursor not advancing (headFrames=$headFrames " +
|
||||
"playState=${readPlayState(track)}); audio queued but mixer is not pulling",
|
||||
)
|
||||
maybeRecordStuckCursorDiagnosticLocked(track, now)
|
||||
}
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = headFrames
|
||||
}
|
||||
|
||||
/**
|
||||
* If the hardware cursor never started after [STUCK_CURSOR_DIAGNOSTIC_MS] of
|
||||
* "playing", surface it to the in-app Diagnostics screen once per track —
|
||||
* this is the field-visible signal for the cold-start parking class when no
|
||||
* logcat cable is attached. Write-sampled here; the [VoiceViewModel] watchdog
|
||||
* provides the timer-driven guarantee when writes stall.
|
||||
*/
|
||||
private fun maybeRecordStuckCursorDiagnosticLocked(track: AudioTrack, now: Long) {
|
||||
if (headAdvanceConfirmed || playbackStartedAtMs <= 0L) return
|
||||
val stuckMs = now - playbackStartedAtMs
|
||||
if (stuckMs < STUCK_CURSOR_DIAGNOSTIC_MS) return
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio not starting",
|
||||
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
|
||||
"(${mediaVolumeSummaryLocked()}).",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot of playback progress for the [VoiceViewModel] watchdog
|
||||
* and drain cross-check. Reads are cheap and lock-guarded.
|
||||
*/
|
||||
fun snapshot(): RealtimePlaybackSnapshot = synchronized(trackLock) {
|
||||
val track = audioTrack
|
||||
RealtimePlaybackSnapshot(
|
||||
active = track != null,
|
||||
playbackStarted = playbackStarted,
|
||||
headFrames = track?.let { readHeadFrames(it) } ?: 0,
|
||||
framesWritten = totalFramesWritten,
|
||||
sampleRate = currentSampleRate,
|
||||
playStatePlaying = track != null && readPlayState(track) == "playing",
|
||||
startedAtElapsedMs = playbackStartedAtMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readHeadFrames(track: AudioTrack): Int =
|
||||
try { track.playbackHeadPosition } catch (_: Exception) { lastLoggedHeadFrames }
|
||||
|
||||
private fun readPlayState(track: AudioTrack): String =
|
||||
try {
|
||||
when (track.playState) {
|
||||
AudioTrack.PLAYSTATE_PLAYING -> "playing"
|
||||
AudioTrack.PLAYSTATE_PAUSED -> "paused"
|
||||
AudioTrack.PLAYSTATE_STOPPED -> "stopped"
|
||||
else -> "unknown"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"error"
|
||||
}
|
||||
|
||||
private fun notePlaybackGapLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
val platformUnderrun = underrunCount > lastUnderrunCount
|
||||
val estimatedDrained = estimatedPlaybackEndAtMs > 0L &&
|
||||
now > estimatedPlaybackEndAtMs + RealtimePcmBufferPolicy.UNDERFLOW_GRACE_MS
|
||||
if (!platformUnderrun && !estimatedDrained) return
|
||||
|
||||
val reason = if (platformUnderrun) {
|
||||
"platform underrun ${lastUnderrunCount}→$underrunCount"
|
||||
} else {
|
||||
"stream gap ${now - estimatedPlaybackEndAtMs}ms"
|
||||
}
|
||||
Log.w(TAG, "Realtime PCM continuing after $reason")
|
||||
recordPlaybackGapDiagnosticLocked(now, reason)
|
||||
playbackGapSeenThisTrack = true
|
||||
increaseAdaptivePrebufferLocked(reason)
|
||||
|
||||
// Provider-native realtime streams can legitimately arrive in uneven
|
||||
// bursts while the model decides to call tools. Keep the AudioTrack
|
||||
// alive so already queued speech is not flushed and the next chunk can
|
||||
// resume naturally after Android's underrun recovery.
|
||||
if (estimatedDrained) {
|
||||
estimatedPlaybackEndAtMs = now
|
||||
}
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
|
||||
private fun increaseAdaptivePrebufferLocked(reason: String) {
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs + ADAPTIVE_PREBUFFER_STEP_MS)
|
||||
.coerceAtMost(RealtimePcmBufferPolicy.MAX_ADAPTIVE_START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer increased to ${adaptiveStartPrebufferMs}ms " +
|
||||
"after $reason",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleAdaptivePrebufferLocked() {
|
||||
if (playbackGapSeenThisTrack) return
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs - ADAPTIVE_PREBUFFER_DECAY_MS)
|
||||
.coerceAtLeast(RealtimePcmBufferPolicy.START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer relaxed to ${adaptiveStartPrebufferMs}ms",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordPlaybackGapDiagnosticLocked(now: Long, reason: String) {
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio stream gap",
|
||||
detail = reason,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val mediaVolume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
if (mediaVolume == 0 && now - lastMutedVolumeDiagnosticAtMs > MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS) {
|
||||
lastMutedVolumeDiagnosticAtMs = now
|
||||
Log.w(TAG, "Realtime PCM playback is starting while media volume is muted")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime voice volume muted",
|
||||
detail = "Media volume is 0/${maxVolume ?: "?"}.",
|
||||
)
|
||||
}
|
||||
if (audioFocusHeld) {
|
||||
Log.i(TAG, "Realtime PCM audio focus already held ${mediaVolumeSummaryLocked()}")
|
||||
return
|
||||
}
|
||||
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val request = audioFocusRequest ?: AudioFocusRequest.Builder(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAcceptsDelayedFocusGain(false)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
.build()
|
||||
.also { audioFocusRequest = it }
|
||||
manager.requestAudioFocus(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
}
|
||||
audioFocusHeld = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM audio focus result=$result held=$audioFocusHeld ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun abandonAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
if (!audioFocusHeld) return
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Realtime PCM audio focus abandon failed: ${it.message}")
|
||||
}
|
||||
audioFocusHeld = false
|
||||
}
|
||||
|
||||
private fun mediaVolumeSummaryLocked(): String {
|
||||
val manager = audioManager ?: return "mediaVolume=unknown"
|
||||
val volume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val musicActive = runCatching { manager.isMusicActive }.getOrNull()
|
||||
return "mediaVolume=${volume ?: "?"}/${maxVolume ?: "?"} musicActive=${musicActive ?: "?"}"
|
||||
}
|
||||
|
||||
private fun updateUnderrunCursorLocked(track: AudioTrack) {
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
if (underrunCount > lastUnderrunCount) {
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUnderrunCount(track: AudioTrack): Int =
|
||||
try { track.underrunCount } catch (_: Exception) { lastUnderrunCount }
|
||||
|
||||
private fun remainingPlaybackMsLocked(now: Long, cushionMs: Long): Long {
|
||||
if (audioTrack == null) return 0L
|
||||
if (!playbackStarted) {
|
||||
return RealtimePcmBufferPolicy.durationMsForBytes(
|
||||
bytes = pendingStartBytes,
|
||||
sampleRate = currentSampleRate,
|
||||
) + cushionMs.coerceAtLeast(0L)
|
||||
}
|
||||
return (estimatedPlaybackEndAtMs - now + cushionMs).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun computePcm16LeRms(pcm: ByteArray): Float {
|
||||
val usable = pcm.size - (pcm.size % 2)
|
||||
if (usable <= 0) return 0f
|
||||
@@ -149,5 +706,125 @@ class RealtimePcmPlayer {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RealtimePcmPlayer"
|
||||
private const val DEFAULT_DRAIN_CUSHION_MS = 250L
|
||||
private const val PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS = 5_000L
|
||||
private const val MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS = 10_000L
|
||||
private const val ADAPTIVE_PREBUFFER_STEP_MS = 240L
|
||||
private const val ADAPTIVE_PREBUFFER_DECAY_MS = 120L
|
||||
private const val HEAD_POSITION_LOG_THROTTLE_MS = 1_000L
|
||||
private const val STUCK_CURSOR_DIAGNOSTIC_MS = 1_200L
|
||||
private const val BYTES_PER_FRAME = 2 // mono 16-bit PCM
|
||||
private const val MAX_AMP_QUEUE = 1_024
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-free value snapshot of realtime playback progress, consumed by the
|
||||
* [com.hermesandroid.relay.viewmodel.VoiceViewModel] first-frame watchdog and
|
||||
* drain cross-check.
|
||||
*/
|
||||
data class RealtimePlaybackSnapshot(
|
||||
val active: Boolean,
|
||||
val playbackStarted: Boolean,
|
||||
val headFrames: Int,
|
||||
val framesWritten: Long,
|
||||
val sampleRate: Int,
|
||||
val playStatePlaying: Boolean,
|
||||
val startedAtElapsedMs: Long,
|
||||
)
|
||||
|
||||
/** A written PCM chunk's RMS amplitude tagged with the frame it finishes at. */
|
||||
internal data class FrameAmp(val endFrame: Long, val rms: Float)
|
||||
|
||||
/**
|
||||
* Returns the amplitude of the first chunk that has not finished playing
|
||||
* ([FrameAmp.endFrame] > [headFrames]) — i.e. the audio at the cursor right now.
|
||||
* 0 when the queue is empty or fully drained. Pure for unit testing.
|
||||
*/
|
||||
internal fun amplitudeAtHead(queue: List<FrameAmp>, headFrames: Long): Float {
|
||||
for (entry in queue) {
|
||||
if (entry.endFrame > headFrames) return entry.rms
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
internal data class RealtimePcmStartDecision(
|
||||
val shouldStart: Boolean,
|
||||
val bufferedMs: Long,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
internal object RealtimePcmBufferPolicy {
|
||||
// Realtime voice is latency-sensitive: the provider streams PCM at (or faster
|
||||
// than) realtime, so the start prebuffer only needs to cover network jitter,
|
||||
// not the whole turn. The large [STREAM_BUFFER_MS] AudioTrack buffer absorbs
|
||||
// bursts *after* playback starts; the start thresholds just decide when the
|
||||
// very first sample is allowed to leave the queue.
|
||||
//
|
||||
// A short turn whose audio arrives faster than realtime used to satisfy
|
||||
// neither the (2.4s) prebuffer nor the (1.2s) max-wait, so it never started
|
||||
// mid-stream and depended entirely on the end-of-turn flush. Lowering these
|
||||
// lets streaming start on the first few chunks while keeping enough cushion
|
||||
// to ride out jitter.
|
||||
const val STARTUP_PREROLL_MS = 120L
|
||||
const val START_PREBUFFER_MS = 320L
|
||||
const val MIN_PREBUFFER_MS = 160L
|
||||
const val MAX_PREBUFFER_WAIT_MS = 280L
|
||||
const val MAX_ADAPTIVE_START_PREBUFFER_MS = 1_200L
|
||||
// Keep the AudioTrack buffer modest. A multi-second buffer gets routed to
|
||||
// Samsung's "deep buffer" output mixer, whose thread is suspended at rest and
|
||||
// cold-starts very slowly — the hardware cursor (playbackHeadPosition) stays
|
||||
// pinned at 0 for ~2-5s after play() even though playState=PLAYING, focus is
|
||||
// held and volume is up. That parked window is the inaudible first/short
|
||||
// turn. A sub-second buffer keeps playback on the primary (fast) mixer path,
|
||||
// which begins pulling immediately. The ~700ms still absorbs normal network
|
||||
// jitter; longer provider gaps (tool calls) underrun-and-resume regardless of
|
||||
// buffer size and are handled by notePlaybackGapLocked.
|
||||
const val STREAM_BUFFER_MS = 700L
|
||||
const val UNDERFLOW_GRACE_MS = 180L
|
||||
|
||||
fun streamBufferSize(minBufferBytes: Int, sampleRate: Int): Int {
|
||||
val target = bytesForDurationMs(sampleRate, STREAM_BUFFER_MS)
|
||||
return max(minBufferBytes, target)
|
||||
}
|
||||
|
||||
fun startDecision(
|
||||
pendingBytes: Int,
|
||||
sampleRate: Int,
|
||||
waitedMs: Long,
|
||||
force: Boolean,
|
||||
startPrebufferMs: Long = START_PREBUFFER_MS,
|
||||
): RealtimePcmStartDecision {
|
||||
val bufferedMs = durationMsForBytes(pendingBytes, sampleRate)
|
||||
val targetPrebufferMs = startPrebufferMs.coerceIn(
|
||||
START_PREBUFFER_MS,
|
||||
MAX_ADAPTIVE_START_PREBUFFER_MS,
|
||||
)
|
||||
val reason = when {
|
||||
force && pendingBytes > 0 -> "flush"
|
||||
bufferedMs >= targetPrebufferMs -> "prebuffer"
|
||||
bufferedMs >= MIN_PREBUFFER_MS && waitedMs >= MAX_PREBUFFER_WAIT_MS -> "max-wait"
|
||||
else -> "buffering"
|
||||
}
|
||||
return RealtimePcmStartDecision(
|
||||
shouldStart = reason != "buffering",
|
||||
bufferedMs = bufferedMs,
|
||||
reason = reason,
|
||||
)
|
||||
}
|
||||
|
||||
fun durationMsForBytes(bytes: Int, sampleRate: Int): Long {
|
||||
if (bytes <= 0 || sampleRate <= 0) return 0L
|
||||
return ((bytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
}
|
||||
|
||||
fun bytesForDurationMs(sampleRate: Int, durationMs: Long): Int {
|
||||
if (sampleRate <= 0 || durationMs <= 0L) return 0
|
||||
return (sampleRate * 2L * durationMs / 1000L).toInt()
|
||||
}
|
||||
|
||||
fun startupPrerollBytes(sampleRate: Int): Int =
|
||||
bytesForDurationMs(sampleRate, STARTUP_PREROLL_MS)
|
||||
}
|
||||
|
||||
@@ -8,13 +8,79 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures a short mono 16-bit PCM sample for realtime voice test runs.
|
||||
* Captures mono 16-bit PCM for realtime voice test runs.
|
||||
*
|
||||
* [capture] grabs a fixed short window (legacy/back-compat). [captureUntilStopped]
|
||||
* records open-endedly until [requestStop] is called (tap-to-stop), which is what
|
||||
* the Mic demo needs to capture a full spoken sentence.
|
||||
*/
|
||||
class RealtimePcmRecorder(
|
||||
private val sampleRate: Int = 16_000,
|
||||
) {
|
||||
@Volatile
|
||||
private var capturing = false
|
||||
|
||||
/** Signals an in-flight [captureUntilStopped] to finish and return. */
|
||||
fun requestStop() {
|
||||
capturing = false
|
||||
}
|
||||
|
||||
val isCapturing: Boolean
|
||||
get() = capturing
|
||||
|
||||
/**
|
||||
* Records until [requestStop] is called or [maxDurationMs] elapses, invoking
|
||||
* [onLevel] (0..1 RMS) per read so the UI can show a live input waveform.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun captureUntilStopped(
|
||||
maxDurationMs: Long = 15_000,
|
||||
onLevel: ((Float) -> Unit)? = null,
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val maxBytes = ((sampleRate * maxDurationMs) / 1000L * 2L).toInt()
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
|
||||
val out = ByteArrayOutputStream(minBuffer * 4)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
capturing = true
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (capturing && out.size() < maxBytes) {
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
onLevel?.invoke(rms16Le(buffer, read))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
capturing = false
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun capture(durationMs: Long = 800): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
@@ -60,4 +126,20 @@ class RealtimePcmRecorder(
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
private fun rms16Le(buffer: ByteArray, length: Int): Float {
|
||||
val usable = length - (length % 2)
|
||||
if (usable <= 0) return 0f
|
||||
var sum = 0.0
|
||||
var i = 0
|
||||
while (i < usable) {
|
||||
val low = buffer[i].toInt() and 0xff
|
||||
val high = buffer[i + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt() / 32768.0
|
||||
sum += sample * sample
|
||||
i += 2
|
||||
}
|
||||
val rms = sqrt(sum / (usable / 2))
|
||||
return sqrt((rms / 0.28).coerceIn(0.0, 1.0)).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -83,9 +84,33 @@ class VoicePlayer(
|
||||
private var visualizer: Visualizer? = null
|
||||
private var visualizerAttached = false
|
||||
|
||||
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
|
||||
// thread-confined — every accessor (the audioSessionId getter included)
|
||||
// calls verifyApplicationThread() and throws "Player is accessed on the
|
||||
// wrong thread" if touched off the player's construction thread. The
|
||||
// barge-in pipeline reads [audioSessionId] from BargeInListener's
|
||||
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
|
||||
// can't expose the raw getter. Instead we cache the id from the
|
||||
// main-thread Media3 callbacks below and serve the getter from this
|
||||
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
|
||||
// first sentence played for ~2 syllables before the IO read threw.)
|
||||
@Volatile private var cachedAudioSessionId: Int = 0
|
||||
|
||||
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
|
||||
|
||||
init {
|
||||
// AnalyticsListener callbacks are delivered on the player's
|
||||
// application (main) thread, so caching the id here is the
|
||||
// authoritative, thread-correct way to track it as Media3 allocates
|
||||
// and reallocates the underlying AudioTrack.
|
||||
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioSessionIdChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioSessionId: Int,
|
||||
) {
|
||||
cachedAudioSessionId = audioSessionId
|
||||
}
|
||||
})
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
@@ -94,8 +119,16 @@ class VoicePlayer(
|
||||
// actually begins — the audio session id is stable from
|
||||
// player construction on Media3 1.x but some OEM pipelines
|
||||
// don't allocate the track until playback starts.
|
||||
if (isPlaying && !visualizerAttached) {
|
||||
attachVisualizer(exoPlayer.audioSessionId)
|
||||
if (isPlaying) {
|
||||
// Belt-and-braces with the analytics listener above: this
|
||||
// runs on the main thread too, so reading the getter here
|
||||
// is safe and guarantees the cache is warm by the time
|
||||
// playback is audible (and thus by the time barge-in
|
||||
// starts its IO reader).
|
||||
cachedAudioSessionId = exoPlayer.audioSessionId
|
||||
if (!visualizerAttached) {
|
||||
attachVisualizer(cachedAudioSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,13 +244,20 @@ class VoicePlayer(
|
||||
* poll this property briefly rather than assume it's hot-ready at
|
||||
* [VoicePlayer] construction time.
|
||||
*
|
||||
* Exposed read-only. Internally the same id drives the Visualizer
|
||||
* attach logic in [attachVisualizer]; B4 reads it via a provider
|
||||
* lambda so the listener can re-check across the 1 s poll window
|
||||
* without holding a stale reference.
|
||||
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
|
||||
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
|
||||
* thread-confined and [BargeInListener] reads this from its
|
||||
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
|
||||
* throws `IllegalStateException: Player is accessed on the wrong thread`.
|
||||
* The cache is populated from main-thread Media3 callbacks (the
|
||||
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
|
||||
*
|
||||
* Exposed read-only. B4 reads it via a provider lambda so the listener
|
||||
* can re-check across the 1 s poll window without holding a stale
|
||||
* reference.
|
||||
*/
|
||||
val audioSessionId: Int
|
||||
get() = exoPlayer.audioSessionId
|
||||
get() = cachedAudioSessionId
|
||||
|
||||
/**
|
||||
* Set the playback volume of the underlying ExoPlayer.
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -40,6 +41,15 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val pairedSessionMetaJson: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Orchestrates pairing + session token lifecycle for the relay channel.
|
||||
*
|
||||
@@ -90,6 +100,7 @@ class AuthManager(
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val HINT_API_KEY_PRESENT = "api_key_present"
|
||||
private const val KEY_PAIRED_META = "paired_session_meta_json"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
@@ -103,6 +114,16 @@ class AuthManager(
|
||||
*/
|
||||
const val CONNECTION_ID_LEGACY: String = "legacy"
|
||||
|
||||
internal fun shouldPreservePairedSessionOnAuthFail(
|
||||
currentState: AuthState,
|
||||
rawReason: String,
|
||||
): Boolean {
|
||||
val lower = rawReason.lowercase()
|
||||
return currentState is AuthState.Paired &&
|
||||
"timeout" in lower &&
|
||||
("auth" in lower || "authentication" in lower)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of a connection's stored device id without making
|
||||
* that connection active. Used by the connection removal path so it
|
||||
@@ -124,6 +145,56 @@ class AuthManager(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): ConnectionAuthSecrets = withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
ConnectionAuthSecrets(
|
||||
sessionToken = store.getString(KEY_SESSION_TOKEN),
|
||||
refreshToken = store.getString(KEY_REFRESH_TOKEN),
|
||||
deviceId = store.getString(KEY_DEVICE_ID),
|
||||
apiKey = store.getString(KEY_API_KEY),
|
||||
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun importStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
secrets: ConnectionAuthSecrets,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
|
||||
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
|
||||
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
|
||||
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
|
||||
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokenStoreForBackup(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): SessionTokenStore {
|
||||
val appContext = context.applicationContext
|
||||
return KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
private fun writeOrRemove(
|
||||
store: SessionTokenStore,
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value == null) {
|
||||
store.remove(key)
|
||||
} else {
|
||||
store.putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `profiles` array from an `auth.ok` payload into a list of
|
||||
* [Profile] entries. Extracted out of [handleAuthOk] so it's
|
||||
@@ -202,6 +273,48 @@ class AuthManager(
|
||||
private var _store: SessionTokenStore? = null
|
||||
private val storeMutex = Mutex()
|
||||
|
||||
/**
|
||||
* The encrypted-store filename for this connection — shared by [store]
|
||||
* and the plain hint file below so they always describe the same store.
|
||||
*/
|
||||
private val tokenPrefsName: String =
|
||||
tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain (non-encrypted) mirror of one boolean fact: "does this
|
||||
* connection have an API key stored?". Read at startup WITHOUT touching
|
||||
* the Keystore, so [ConnectionViewModel] can build the API client
|
||||
* immediately for key-less connections — the common local setup —
|
||||
* instead of queueing behind the encrypted store's first decrypt.
|
||||
*
|
||||
* Why this exists: on StrongBox devices every keystore operation runs
|
||||
* ~550ms and Tink serializes them process-globally; a measured S25
|
||||
* Ultra cold start spent 15 seconds in that marathon before
|
||||
* `getApiKey()` could return — only to answer "there is no key".
|
||||
*
|
||||
* The hint stores ONLY presence, never key material. It defaults to
|
||||
* `true` (unknown ⇒ assume a key exists ⇒ wait for the real decrypt),
|
||||
* so a missing or stale hint can never strip auth off a keyed
|
||||
* connection — the failure mode is "slow like before", never "401s".
|
||||
* It converges in [setApiKey]/[clearApiKey], in init's store
|
||||
* hydration, and after legacy migration.
|
||||
*/
|
||||
private val hintPrefs by lazy {
|
||||
context.getSharedPreferences("${tokenPrefsName}_plain_hints", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** True only when a previously-recorded hint says "no API key stored". */
|
||||
fun apiKeyKnownAbsent(): Boolean = !hintPrefs.getBoolean(HINT_API_KEY_PRESENT, true)
|
||||
|
||||
private fun recordApiKeyHint(present: Boolean) {
|
||||
_apiKeyPresent.value = present
|
||||
hintPrefs.edit().putBoolean(HINT_API_KEY_PRESENT, present).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily construct the best available token store. First tries
|
||||
* [KeystoreTokenStore] — if that fails on broken OEM keystores we fall
|
||||
@@ -217,19 +330,14 @@ class AuthManager(
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Multi-connection: pick the EncryptedSharedPreferences
|
||||
// filename based on the bound connection. The legacy sentinel
|
||||
// keeps the pre-multi-connection install on its original file
|
||||
// so the existing paired device keeps working with no
|
||||
// migration.
|
||||
val prefsName = tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
// Multi-connection: [tokenPrefsName] picks the
|
||||
// EncryptedSharedPreferences filename for the bound
|
||||
// connection. The legacy sentinel keeps the pre-multi-
|
||||
// connection install on its original file so the existing
|
||||
// paired device keeps working with no migration.
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(context, prefsName)
|
||||
KeystoreTokenStore.tryCreate(context, tokenPrefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(context, tokenPrefsName)
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
@@ -419,7 +527,9 @@ class AuthManager(
|
||||
} else {
|
||||
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
|
||||
}
|
||||
_apiKeyPresent.value = !s.getString(KEY_API_KEY).isNullOrBlank()
|
||||
// Converge the plain api-key-present hint with the decrypted
|
||||
// truth (also repairs a hint that predates legacy migration).
|
||||
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,16 +857,16 @@ class AuthManager(
|
||||
val s = store()
|
||||
if (trimmed.isBlank()) {
|
||||
s.remove(KEY_API_KEY)
|
||||
_apiKeyPresent.value = false
|
||||
recordApiKeyHint(false)
|
||||
} else {
|
||||
s.putString(KEY_API_KEY, trimmed)
|
||||
_apiKeyPresent.value = true
|
||||
recordApiKeyHint(true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearApiKey() {
|
||||
store().remove(KEY_API_KEY)
|
||||
_apiKeyPresent.value = false
|
||||
recordApiKeyHint(false)
|
||||
}
|
||||
|
||||
val isPaired: Boolean
|
||||
@@ -888,6 +998,13 @@ class AuthManager(
|
||||
?: "Unknown error"
|
||||
val humanized = humanizeAuthFailReason(rawReason)
|
||||
Log.w(TAG, "handleAuthFail: raw=$rawReason humanized=$humanized")
|
||||
if (shouldPreservePairedSessionOnAuthFail(_authState.value, rawReason)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleAuthFail: preserving paired session after transient auth timeout"
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingPairContextAfterAuthFailure(rawReason)
|
||||
_authState.value = AuthState.Failed(humanized)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -70,8 +70,10 @@ class AutoDisableWorker(private val context: Context) {
|
||||
// call is also wrapped in runCatching to swallow SecurityException as
|
||||
// a belt-and-braces. Suppress here rather than inlining the check —
|
||||
// the helper exists so the same gate can grow more conditions later
|
||||
// without each call site re-implementing it.
|
||||
@SuppressLint("MissingPermission")
|
||||
// without each call site re-implementing it. Both IDs are needed:
|
||||
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
|
||||
// on API 33+); `MissingPermission` is the generic fallback.
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
private fun postNotification() {
|
||||
ensureChannel()
|
||||
if (!hasPostNotificationsPermission()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
@@ -300,6 +301,16 @@ class BridgeForegroundService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ForegroundServiceType: lint requires the manifest `<service>` to declare
|
||||
// `foregroundServiceType` for targetSdk >= 34. The SIDELOAD manifest does
|
||||
// (specialUse|mediaProjection) + declares the matching FOREGROUND_SERVICE_*
|
||||
// permissions. The GOOGLEPLAY flavor deliberately omits this service AND
|
||||
// those permissions (no device-control capability for Play-Store
|
||||
// compliance), so this code is unreachable there — the service can't be
|
||||
// started without a manifest declaration. Lint analyzes the merged
|
||||
// googlePlay manifest and can't see the sideload guarantee, so suppress
|
||||
// here rather than weaken googlePlay by granting it specialUse.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
|
||||
@@ -39,6 +39,8 @@ data class ChatMessage(
|
||||
val estimatedCost: Double? = null,
|
||||
// Agent/personality name for display on assistant messages
|
||||
val agentName: String? = null,
|
||||
// Small provenance badges rendered on assistant bubbles.
|
||||
val badges: List<String> = emptyList(),
|
||||
// File attachments (images, documents, etc.)
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
/**
|
||||
@@ -77,7 +79,17 @@ data class ChatMessage(
|
||||
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* so they're not re-sent on the next turn.
|
||||
*/
|
||||
val voiceIntent: VoiceIntentTrace? = null
|
||||
val voiceIntent: VoiceIntentTrace? = null,
|
||||
/**
|
||||
* Provider-native Realtime Agent turns can answer without calling Hermes.
|
||||
* Those local-only assistant turns need to be spliced into the next Hermes
|
||||
* chat/run payload so switching back to normal chat preserves context.
|
||||
*
|
||||
* Hermes-backed realtime turns leave this null because Hermes already owns
|
||||
* the durable session turn; the provider's spoken summary is UI/runtime
|
||||
* provenance, not another canonical assistant message.
|
||||
*/
|
||||
val realtimeTurn: RealtimeTurnTrace? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -129,6 +141,24 @@ data class VoiceIntentTrace(
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Local provider-native realtime turn that has not necessarily been absorbed
|
||||
* into the Hermes session yet.
|
||||
*
|
||||
* Stored on the assistant message so the next normal chat send can emit a
|
||||
* compact OpenAI-format user/assistant pair before the live user message. This
|
||||
* keeps Realtime Agent and Hermes Chat + Voice Output as one conversation even
|
||||
* when the realtime provider answered directly.
|
||||
*/
|
||||
data class RealtimeTurnTrace(
|
||||
val userText: String,
|
||||
val assistantText: String,
|
||||
val provider: String? = null,
|
||||
val model: String? = null,
|
||||
val voice: String? = null,
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A file attachment sent with a message.
|
||||
*
|
||||
@@ -204,6 +234,8 @@ data class ToolCall(
|
||||
val success: Boolean?,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
val runId: String? = null,
|
||||
val provenance: String? = null,
|
||||
// Duration tracking
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val completedAt: Long? = null
|
||||
|
||||
@@ -3,6 +3,18 @@ package com.hermesandroid.relay.data
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
@Serializable
|
||||
data class DashboardConnectionStatus(
|
||||
val checkedAtMillis: Long? = null,
|
||||
val reachable: Boolean = false,
|
||||
val authRequired: Boolean? = null,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authenticated: Boolean? = null,
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
@@ -30,9 +42,9 @@ import java.net.URI
|
||||
*
|
||||
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
|
||||
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
|
||||
* free to mean what Hermes's server config means by it (agent profiles —
|
||||
* name + model + description defined under `agent.profiles` in config.yaml).
|
||||
* A follow-up pass will introduce the new `Profile` concept on top.
|
||||
* free to mean upstream Hermes profiles: separate host-side Hermes homes
|
||||
* under `~/.hermes/profiles/<name>/`, each with its own config, SOUL, memory,
|
||||
* sessions, skills, cron, and provider state.
|
||||
*/
|
||||
@Serializable
|
||||
data class Connection(
|
||||
@@ -41,6 +53,24 @@ data class Connection(
|
||||
val apiServerUrl: String,
|
||||
val relayUrl: String,
|
||||
val tokenStoreKey: String,
|
||||
/**
|
||||
* Hermes dashboard/admin URL. Dashboard management features use this
|
||||
* separately from the relay pairing channel; a blank/null value means
|
||||
* "derive from [apiServerUrl] using the conventional same-host :9119".
|
||||
*/
|
||||
val dashboardUrl: String? = null,
|
||||
val dashboardAuthRequired: Boolean? = null,
|
||||
val dashboardAuthProviders: List<String> = emptyList(),
|
||||
val dashboardLastStatus: DashboardConnectionStatus? = null,
|
||||
/**
|
||||
* Candidate host routes for this saved Hermes server. Standard setup
|
||||
* stores at least one candidate here so API, dashboard, voice, and Relay
|
||||
* helpers can follow LAN/Tailscale/public handoff before Relay pairing.
|
||||
* Older installs and legacy serialized records default to an empty list.
|
||||
*/
|
||||
val routeCandidates: List<EndpointCandidate> = emptyList(),
|
||||
/** Optional user preference such as "lan" or "tailscale"; null means Auto. */
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
@@ -48,6 +78,12 @@ data class Connection(
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
@@ -57,6 +93,8 @@ data class Connection(
|
||||
*/
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
* from a connection UUID. Trimmed to the first 8 characters of the
|
||||
@@ -80,5 +118,213 @@ data class Connection(
|
||||
apiServerUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
apiServerUrl: String,
|
||||
dashboardPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
val derived = deriveDefaultDashboardUrl(apiServerUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "ws"
|
||||
"https" -> "wss"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun buildRouteCandidates(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
role = inferRouteRole(apiServerUrl),
|
||||
priority = 0,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
.map { it.first.trim() to it.second.trim() }
|
||||
.filter { (_, url) -> url.isNotBlank() }
|
||||
.forEachIndexed { index, (role, url) ->
|
||||
endpointCandidateFromApiUrl(
|
||||
role = role.ifBlank { inferRouteRole(url) },
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay a freshly-rebuilt candidate list onto an existing stored
|
||||
* one, preserving the stored extras (priority > 0) that the rebuild
|
||||
* doesn't already cover. URL edits rebuild only the route(s) the
|
||||
* user actually touched — without this merge, saving an API or
|
||||
* Relay URL collapsed the stored list to a single candidate,
|
||||
* silently dropping the setup wizard's Tailscale route (or a
|
||||
* pairing payload's extra endpoints) and killing LAN/VPN roaming.
|
||||
*
|
||||
* Stored extras are preserved **verbatim** (role, priority, relay
|
||||
* URL) rather than re-derived, so payload-specified relay URLs
|
||||
* survive. Host:port collisions defer to the rebuilt entry.
|
||||
*/
|
||||
fun mergeRouteCandidates(
|
||||
rebuilt: List<EndpointCandidate>,
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize hand-typed API-URL input: trim, strip trailing slashes,
|
||||
* default a missing scheme to `http://`, and default a missing port
|
||||
* to [defaultPort] — most Hermes API servers speak plain HTTP on
|
||||
* 8642, and a bare `192.168.1.10` / Tailscale `100.x.y.z` is by far
|
||||
* the most common thing users type.
|
||||
*
|
||||
* URLs that already carry a scheme are preserved **verbatim**
|
||||
* (including a wrong one like `ws://`, so downstream validators can
|
||||
* complain precisely): an explicit `https://hermes.example.com` may
|
||||
* be a reverse proxy on 443, and force-appending :8642 would break
|
||||
* it. Port-defaulting applies only to scheme-less input, where the
|
||||
* user is visibly relying on our defaults.
|
||||
*/
|
||||
fun normalizeApiUrlInput(raw: String, defaultPort: Int = 8642): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
|
||||
val withScheme = "http://$trimmed"
|
||||
val uri = runCatching { URI(withScheme) }.getOrNull()
|
||||
val canAppendPort = uri != null &&
|
||||
!uri.host.isNullOrBlank() &&
|
||||
uri.port <= 0 &&
|
||||
uri.rawPath.isNullOrEmpty() &&
|
||||
uri.rawQuery == null
|
||||
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
|
||||
}
|
||||
|
||||
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
|
||||
fun endpointCandidateFromApiUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = if (uri.port > 0) uri.port else 8642
|
||||
val resolvedRelayUrl = relayUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl)
|
||||
?: return null
|
||||
val transportHint = when {
|
||||
resolvedRelayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
resolvedRelayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
?.lowercase()
|
||||
?: return "custom"
|
||||
return when {
|
||||
host.endsWith(".ts.net") || isTailscaleIpv4(host) -> "tailscale"
|
||||
host == "localhost" ||
|
||||
host == "127.0.0.1" ||
|
||||
host == "::1" ||
|
||||
isPrivateLanIpv4(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTailscaleIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return parts[0] == 100 && parts[1] in 64..127
|
||||
}
|
||||
|
||||
private fun isPrivateLanIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,8 @@ class ConnectionStore private constructor(
|
||||
// callers shouldn't rely on insertion order of a duplicate
|
||||
// add, and the alternative (throwing) makes migration code
|
||||
// more brittle than it needs to be.
|
||||
val next = current.filterNot { it.id == connection.id } + connection
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.filterNot { it.id == connection.id } + normalized
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -177,7 +178,8 @@ class ConnectionStore private constructor(
|
||||
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
|
||||
return@edit
|
||||
}
|
||||
val next = current.map { if (it.id == connection.id) connection else it }
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.map { if (it.id == connection.id) normalized else it }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -212,27 +214,83 @@ class ConnectionStore private constructor(
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { connection ->
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory-reset helper: clear the persisted connection list, active
|
||||
* pointer, legacy profile aliases, and every known per-connection auth
|
||||
* store. Unlike removing one connection, this intentionally does not pick
|
||||
* a successor; callers are resetting the app back to "no connection".
|
||||
*/
|
||||
suspend fun clearAllConnections() {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
val normalizedConnections = connections.map { it.withDashboardDefaults() }
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (normalizedConnections.isEmpty()) {
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
} else {
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(normalizedConnections)
|
||||
}
|
||||
if (normalizedActiveId == null) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = normalizedActiveId
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteTokenStoresFor(connection: Connection) {
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +352,8 @@ class ConnectionStore private constructor(
|
||||
pairedAt = pairedAtMillis,
|
||||
transportHint = transportHint,
|
||||
expiresAt = expiresAtMillis,
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
@@ -340,6 +400,8 @@ class ConnectionStore private constructor(
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
|
||||
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
|
||||
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
|
||||
pairedAt = null,
|
||||
lastActiveSessionId = legacyLastSessionId,
|
||||
transportHint = null,
|
||||
@@ -355,6 +417,33 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDashboardStatus(
|
||||
connectionId: String,
|
||||
status: DashboardConnectionStatus,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) {
|
||||
target.copy(
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
dashboardAuthRequired = status.authRequired,
|
||||
dashboardAuthProviders = status.authProviders,
|
||||
dashboardLastStatus = status,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encoding helpers ---------------------------------------------------
|
||||
|
||||
private fun encodeConnections(list: List<Connection>): String =
|
||||
@@ -364,12 +453,36 @@ class ConnectionStore private constructor(
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString(connectionListSerializer, raw)
|
||||
.map { it.withDashboardDefaults() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import android.util.Log
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
|
||||
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.StoredDashboardCookie
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -20,8 +24,8 @@ import java.io.File
|
||||
/**
|
||||
* Manages app data: backup, restore, and reset.
|
||||
*
|
||||
* Backup format is a JSON file containing settings and connection info.
|
||||
* Tokens are NOT included in backups for security.
|
||||
* Backup format is a JSON file containing full connection metadata and
|
||||
* credentials. Treat exported files as sensitive secrets.
|
||||
*/
|
||||
class DataManager(
|
||||
private val context: Context,
|
||||
@@ -51,8 +55,7 @@ class DataManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup data model -- only non-sensitive settings.
|
||||
* Tokens and device IDs are never included.
|
||||
* Backup data model.
|
||||
*
|
||||
* **Schema history:**
|
||||
* - v1: `serverUrl` only (single endpoint, pre-API-split).
|
||||
@@ -66,22 +69,50 @@ class DataManager(
|
||||
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
|
||||
* get `connections = emptyList()` since the old string list was not
|
||||
* structurally compatible.
|
||||
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||
* paired metadata, and dashboard cookies.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 4,
|
||||
val version: Int = 5,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
val theme: String = "auto",
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
val activeConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionSecretBackup(
|
||||
val connectionId: String,
|
||||
val tokenStoreKey: String,
|
||||
val auth: ConnectionAuthSecrets = ConnectionAuthSecrets(),
|
||||
val dashboardCookies: List<DashboardCookieBackup> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardCookieBackup(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Export app settings to a JSON string.
|
||||
* Does NOT include session tokens or device IDs (security).
|
||||
* Includes connection credentials. The export UI must warn the user that
|
||||
* the resulting JSON file is sensitive.
|
||||
*
|
||||
* The `sessionLabels` parameter is a legacy dead parameter — it was
|
||||
* previously sourced from `AuthManager.sessionLabels`, a field removed
|
||||
@@ -100,7 +131,7 @@ class DataManager(
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val connectionsSnapshot = connectionStore?.connections?.value
|
||||
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
@@ -108,19 +139,56 @@ class DataManager(
|
||||
"(caller constructed DataManager without the multi-connection ctor arg)",
|
||||
)
|
||||
}
|
||||
val connectionSecrets = connectionsSnapshot.map { connection ->
|
||||
ConnectionSecretBackup(
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
auth = AuthManager.exportStoredSecrets(context, connection.tokenStoreKey),
|
||||
dashboardCookies = EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).load().map { it.toBackup() },
|
||||
)
|
||||
}
|
||||
val backup = AppBackup(
|
||||
version = 4,
|
||||
version = 5,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
connections = connectionsSnapshot ?: emptyList(),
|
||||
exportedAt = System.currentTimeMillis()
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
)
|
||||
return json.encodeToString(backup)
|
||||
}
|
||||
|
||||
suspend fun restoreConnectionBackup(backup: AppBackup) {
|
||||
val store = connectionStore ?: return
|
||||
deleteSensitivePreferenceFiles()
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
backup.connectionSecrets.forEach { secret ->
|
||||
val connection = connectionsById[secret.connectionId] ?: return@forEach
|
||||
AuthManager.importStoredSecrets(
|
||||
context = context,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
secrets = secret.auth,
|
||||
)
|
||||
EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).save(secret.dashboardCookies.map { it.toStoredCookie() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from a JSON string.
|
||||
* Returns the parsed backup, or null if invalid.
|
||||
@@ -221,6 +289,11 @@ class DataManager(
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
|
||||
// Multi-connection reset: clear the hot ConnectionStore state and
|
||||
// delete every per-connection token store before the global
|
||||
// DataStore is wiped.
|
||||
connectionStore?.clearAllConnections()
|
||||
|
||||
// Clear all DataStore preferences
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
|
||||
@@ -231,15 +304,7 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the EncryptedSharedPreferences file for auth tokens
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val authFile = File(prefsDir, "$AUTH_PREFS_NAME.xml")
|
||||
if (authFile.exists()) {
|
||||
authFile.delete()
|
||||
Log.d(TAG, "Deleted auth preferences file")
|
||||
}
|
||||
}
|
||||
deleteSensitivePreferenceFiles()
|
||||
|
||||
// Clear cache directory
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -254,6 +319,61 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val stores = buildSet {
|
||||
add(AUTH_PREFS_NAME)
|
||||
add(Connection.LEGACY_TOKEN_STORE_KEY)
|
||||
prefsDir.listFiles()?.forEach { file ->
|
||||
if (file.extension == "xml") {
|
||||
val name = file.nameWithoutExtension
|
||||
if (
|
||||
name.startsWith("hermes_auth_") ||
|
||||
name.startsWith("hermes_dashboard_")
|
||||
) {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stores.forEach { storeName ->
|
||||
try {
|
||||
context.deleteSharedPreferences(storeName)
|
||||
Log.d(TAG, "Deleted auth preferences file: $storeName")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($storeName) failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.toBackup(): DashboardCookieBackup =
|
||||
DashboardCookieBackup(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
private fun DashboardCookieBackup.toStoredCookie(): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
|
||||
@@ -75,21 +75,18 @@ object FeatureFlags {
|
||||
/**
|
||||
* Compile-time gating based on the active Gradle product flavor.
|
||||
*
|
||||
* Phase 3 ships Bridge on two tracks with very different AccessibilityService
|
||||
* scope: the `googlePlay` flavor carries a conservative event-type subset and
|
||||
* a "notifications + confirmations" description for Play Store policy review,
|
||||
* and the `sideload` flavor carries the full agent-control surface. The tier
|
||||
* flags below let UI code hide tier 3/4/6 surfaces on the Play build without
|
||||
* a runtime check — Kotlin's `val … get() = current == SIDELOAD` resolves at
|
||||
* each call site, but because `current` is a compile-time string, R8 is able
|
||||
* to fold the check away in release builds.
|
||||
* Phase 3 keeps "Hermes Bridge" as the umbrella, but only the `sideload`
|
||||
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
|
||||
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
|
||||
* companion, media, and session-grant surfaces without screen reading, taps,
|
||||
* typing, screenshots, overlays, or unattended control.
|
||||
*
|
||||
* Tier definitions (see `Phase 3 — Bridge Channel.md` in the vault):
|
||||
* 1. baseline — both tracks (app open, tap, navigate within app)
|
||||
* 2. notifications — both tracks (read notifications, summarize, reply)
|
||||
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
|
||||
* 1. baseline — sideload only (app open, tap, navigate within app)
|
||||
* 2. screen context — sideload only (Accessibility tree / screen reads)
|
||||
* 3. voice-first — sideload only (always-on voice capture)
|
||||
* 4. vision-first — sideload only (always-on screen reading)
|
||||
* 5. safety rails — both tracks (confirmation dialogs, action log)
|
||||
* 5. safety rails — sideload only (confirmation dialogs, action log)
|
||||
* 6. ambitious future — sideload only (cross-app macros, scheduling)
|
||||
*/
|
||||
object BuildFlavor {
|
||||
@@ -112,11 +109,11 @@ object BuildFlavor {
|
||||
*/
|
||||
val isSideload: Boolean get() = current == SIDELOAD
|
||||
|
||||
val bridgeTier1: Boolean = true // baseline — both tracks
|
||||
val bridgeTier2: Boolean = true // notifications, calendar — both tracks
|
||||
val bridgeTier1: Boolean get() = current == SIDELOAD // baseline device control
|
||||
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
|
||||
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
|
||||
val bridgeTier4: Boolean get() = current == SIDELOAD // vision-first
|
||||
val bridgeTier5: Boolean = true // safety rails — always on
|
||||
val bridgeTier5: Boolean get() = current == SIDELOAD // safety rails
|
||||
val bridgeTier6: Boolean get() = current == SIDELOAD // future ambitious
|
||||
|
||||
/** Human-readable badge label for the Settings → About version row. */
|
||||
|
||||
@@ -12,8 +12,10 @@ import kotlinx.serialization.Serializable
|
||||
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
|
||||
* and added [systemMessage], sourced from each profile's `SOUL.md`.
|
||||
*
|
||||
* A Profile is a NAMED AGENT CONFIG within a Connection. Switching profile
|
||||
* changes the active agent identity for the Android chat surface:
|
||||
* A Profile is an upstream Hermes profile context within a Connection.
|
||||
* Upstream stores named profiles as separate Hermes homes under
|
||||
* `~/.hermes/profiles/<name>/`. Switching profile changes the active agent
|
||||
* identity for the Android chat surface:
|
||||
* - which profile API server the phone routes chat/session calls to when
|
||||
* the relay advertises [apiServerUrl];
|
||||
* - which profile name the phone sends to the server for new sessions and
|
||||
|
||||
@@ -87,6 +87,12 @@ class ProfileSelectionStore(
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,12 @@ class ProfileSessionStore(
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileSessionsDataStore: DataStore<Preferences>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Compact text context sent to the relay when opening a provider-native
|
||||
* Realtime Agent session.
|
||||
*/
|
||||
data class RealtimeConversationContextMessage(
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val source: String? = null,
|
||||
)
|
||||
@@ -24,10 +24,19 @@ import kotlinx.coroutines.flow.map
|
||||
*/
|
||||
data class VoiceSettings(
|
||||
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 3000L,
|
||||
val autoTts: Boolean = false,
|
||||
val language: String = "",
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
* across turns (persistent conversation). When false, falls back to the
|
||||
* legacy one-session-per-utterance path. See
|
||||
* docs/plans/2026-05-24-realtime-persistent-session.md.
|
||||
*/
|
||||
val realtimePersistentSession: Boolean = true,
|
||||
)
|
||||
|
||||
enum class VoiceEngineMode(val storageValue: String) {
|
||||
@@ -40,22 +49,40 @@ enum class VoiceEngineMode(val storageValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoiceAudioRoute(val storageValue: String) {
|
||||
Auto("auto"),
|
||||
Standard("standard"),
|
||||
Relay("relay");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoiceAudioRoute =
|
||||
values().firstOrNull { it.storageValue == value } ?: Auto
|
||||
}
|
||||
}
|
||||
|
||||
class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENGINE_MODE = stringPreferencesKey("voice_engine_mode")
|
||||
private val KEY_AUDIO_ROUTE = stringPreferencesKey("voice_audio_route")
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
|
||||
private val KEY_LANGUAGE = stringPreferencesKey("voice_language")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
|
||||
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
|
||||
const val DEFAULT_AUDIO_ROUTE = "auto"
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
|
||||
const val DEFAULT_AUTO_TTS = false
|
||||
const val DEFAULT_LANGUAGE = ""
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
}
|
||||
|
||||
val settings: Flow<VoiceSettings> = dataStore.data
|
||||
@@ -64,10 +91,17 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
engineMode = VoiceEngineMode.fromStorage(
|
||||
prefs[KEY_ENGINE_MODE] ?: DEFAULT_ENGINE_MODE,
|
||||
).storageValue,
|
||||
audioRoute = VoiceAudioRoute.fromStorage(
|
||||
prefs[KEY_AUDIO_ROUTE] ?: DEFAULT_AUDIO_ROUTE,
|
||||
).storageValue,
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
@@ -76,6 +110,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setAudioRoute(route: VoiceAudioRoute) {
|
||||
dataStore.edit { it[KEY_AUDIO_ROUTE] = route.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setInteractionMode(mode: String) {
|
||||
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
}
|
||||
@@ -91,4 +129,12 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
suspend fun setLanguage(language: String) {
|
||||
dataStore.edit { it[KEY_LANGUAGE] = language }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setRealtimePersistentSession(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.diagnostics
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
enum class DiagnosticCategory(val label: String) {
|
||||
Api("API"),
|
||||
Relay("Relay"),
|
||||
Session("Session"),
|
||||
Voice("Voice"),
|
||||
Endpoint("Route"),
|
||||
Auth("Auth"),
|
||||
}
|
||||
|
||||
enum class DiagnosticSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class DiagnosticLogEntry(
|
||||
val timestampMs: Long,
|
||||
val category: DiagnosticCategory,
|
||||
val severity: DiagnosticSeverity,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
val endpointRole: String? = null,
|
||||
val url: String? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
)
|
||||
|
||||
object DiagnosticsLog {
|
||||
private const val MAX_ENTRIES = 200
|
||||
private const val MAX_TEXT_LENGTH = 180
|
||||
|
||||
private val lock = Any()
|
||||
private val _entries = MutableStateFlow<List<DiagnosticLogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<DiagnosticLogEntry>> = _entries.asStateFlow()
|
||||
|
||||
fun record(
|
||||
category: DiagnosticCategory,
|
||||
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
) {
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
category = category,
|
||||
severity = severity,
|
||||
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
|
||||
detail = clean(detail),
|
||||
endpointRole = clean(endpointRole),
|
||||
url = sanitizeUrl(url),
|
||||
elapsedMs = elapsedMs,
|
||||
)
|
||||
synchronized(lock) {
|
||||
_entries.value = (_entries.value + entry).takeLast(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
fun recent(
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 30,
|
||||
): List<DiagnosticLogEntry> {
|
||||
val source = entries.value.asReversed()
|
||||
val filtered = if (categories == null) {
|
||||
source
|
||||
} else {
|
||||
source.filter { it.category in categories }
|
||||
}
|
||||
return filtered.take(limit.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun sanitizeUrl(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val noQuery = trimmed.substringBefore('?').substringBefore('#')
|
||||
val schemeEnd = noQuery.indexOf("://")
|
||||
val noUserInfo = if (schemeEnd >= 0) {
|
||||
val prefix = noQuery.substring(0, schemeEnd + 3)
|
||||
val rest = noQuery.substring(schemeEnd + 3)
|
||||
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, slash)
|
||||
val path = rest.substring(slash)
|
||||
val safeAuthority = authority.substringAfterLast('@')
|
||||
prefix + safeAuthority + path
|
||||
} else {
|
||||
noQuery
|
||||
}
|
||||
return noUserInfo.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
private fun clean(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return trimmed
|
||||
.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import android.util.Log
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -78,12 +81,21 @@ class ConnectionManager(
|
||||
private val context: Context? = null,
|
||||
/**
|
||||
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
|
||||
* a non-null [deviceIdProvider], every call to [connect] first consults
|
||||
* the resolver before opening the WSS; on network changes the resolver
|
||||
* is re-run and we hot-swap to the new winner. When null the manager
|
||||
* uses the caller-supplied URL verbatim (pre-ADR-24 behavior).
|
||||
* either [endpointCandidatesProvider] or a non-null [deviceIdProvider],
|
||||
* every call to [connect] first consults the resolver before opening the
|
||||
* WSS; on network changes the resolver is re-run and we hot-swap to the
|
||||
* new winner. When null the manager uses the caller-supplied URL verbatim
|
||||
* (pre-ADR-24 behavior).
|
||||
*/
|
||||
private val endpointResolver: EndpointResolver? = null,
|
||||
/**
|
||||
* Candidate supplier for the active saved connection. This is the
|
||||
* standard-Hermes route source: it works before Relay pairing, so API,
|
||||
* dashboard, voice, and future Relay calls can hand off between LAN and
|
||||
* Tailscale using the same resolver. If it returns an empty list, we fall
|
||||
* back to the legacy per-device PairingPreferences source below.
|
||||
*/
|
||||
private val endpointCandidatesProvider: (suspend () -> List<EndpointCandidate>)? = null,
|
||||
/**
|
||||
* Suspending supplier for the active device id. Used to key into
|
||||
* [PairingPreferences.getDeviceEndpoints] during resolution. `null`
|
||||
@@ -121,7 +133,10 @@ class ConnectionManager(
|
||||
@Volatile
|
||||
private var client: OkHttpClient = buildClient()
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var serverUrl: String? = null
|
||||
private var reconnectAttempt = 0
|
||||
private var shouldReconnect = true
|
||||
@@ -158,18 +173,47 @@ class ConnectionManager(
|
||||
* user-preferred endpoint that doesn't respond to HEAD /health falls
|
||||
* back through the normal priority chain.
|
||||
*
|
||||
* Cleared on [disconnect] per ADR 24's "clears on disconnect" semantics
|
||||
* from the UI card.
|
||||
* Two writers feed this: a sticky [Connection.preferredRouteRole] is
|
||||
* restored into it on connection load, and the Routes card's transient
|
||||
* "Use now" writes it directly without persisting. Cleared on
|
||||
* [disconnect] per ADR 24's "clears on disconnect" semantics.
|
||||
*
|
||||
* Exposed as [manualRoleOverrideFlow] so the Routes card can label the
|
||||
* current route as automatic / preferred / manually switched.
|
||||
*/
|
||||
@Volatile
|
||||
private var manualRoleOverride: String? = null
|
||||
private val _manualRoleOverride = MutableStateFlow<String?>(null)
|
||||
val manualRoleOverrideFlow: StateFlow<String?> = _manualRoleOverride.asStateFlow()
|
||||
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/**
|
||||
* Debounce job for network-change re-resolution. Android fires one
|
||||
* onAvailable per satisfying network (Wi-Fi + cell + VPN can land within
|
||||
* milliseconds of each other, and registration itself replays every
|
||||
* current network), so each event cancels the previous pending resolve
|
||||
* and the last one wins after a short settle window.
|
||||
*/
|
||||
@Volatile
|
||||
private var networkResolveJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
// Register at construction, not on first connect(). Standard
|
||||
// (no-Relay) connections never open the WSS socket, but their HTTP
|
||||
// surfaces (chat, dashboard, voice) still need [activeEndpoint] to
|
||||
// follow LAN/Tailscale handoffs — leaving registration inside
|
||||
// connect() left the whole ADR 24 network-aware path dormant for
|
||||
// exactly those users. No-op when [context] is null (tests).
|
||||
ensureNetworkCallbackRegistered()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
// Settle window before re-resolving after a network event. Long
|
||||
// enough to coalesce the onAvailable burst of a handoff, short
|
||||
// enough that a route swap still feels immediate.
|
||||
private const val NETWORK_RESOLVE_DEBOUNCE_MS = 300L
|
||||
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
|
||||
// on the WSS upgrade, we're IP-banned server-side — retrying at
|
||||
// our normal 1-30s cadence re-fills the ban bucket and keeps us
|
||||
@@ -185,6 +229,12 @@ class ConnectionManager(
|
||||
_insecureMode.value = enabled
|
||||
if (enabled) {
|
||||
Log.w(TAG, "⚠ INSECURE MODE ENABLED — ws:// connections allowed. Do NOT use in production.")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Insecure relay mode enabled",
|
||||
detail = "ws:// connections are allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,9 +255,23 @@ class ConnectionManager(
|
||||
_activeEndpoint.value = resolved
|
||||
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
|
||||
"relay=${resolved.relay.url} (fallback would have been $url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay route selected",
|
||||
endpointRole = resolved.role,
|
||||
url = resolved.relay.url,
|
||||
)
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
Log.d(TAG, "connect: no resolver winner — using supplied url $url")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Using configured relay URL",
|
||||
detail = "No resolver winner",
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
@@ -220,14 +284,31 @@ class ConnectionManager(
|
||||
* the callback from re-running the resolve loop inside another
|
||||
* resolve loop.
|
||||
*/
|
||||
private fun connectToUrlOnMainPath(url: String) {
|
||||
private fun connectToUrlOnMainPath(
|
||||
url: String,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
val isInsecure = url.startsWith("ws://") && !url.startsWith("wss://")
|
||||
if (isInsecure && !_insecureMode.value) {
|
||||
Log.e(TAG, "Blocked ws:// connection — insecure mode is disabled. Use wss:// or enable insecure mode in Settings.")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket blocked",
|
||||
detail = "ws:// is disabled",
|
||||
url = url,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
Log.e(TAG, "Invalid URL scheme — must start with ws:// or wss://")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket URL invalid",
|
||||
detail = "URL must start with ws:// or wss://",
|
||||
url = url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,16 +317,39 @@ class ConnectionManager(
|
||||
// hits the HTTP root and comes back as 404 Not Found during the
|
||||
// upgrade handshake. We still accept an explicit path if present.
|
||||
val normalized = normalizeRelayUrl(url)
|
||||
val existingState = _connectionState.value
|
||||
if (serverUrl == normalized &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
) {
|
||||
Log.i(TAG, "connect: already ${existingState.name.lowercase()} to $normalized — skipping duplicate open")
|
||||
return
|
||||
}
|
||||
val previousSocket = webSocket
|
||||
|
||||
_isInsecureConnection.value = isInsecure
|
||||
if (isInsecure) {
|
||||
Log.w(TAG, "⚠ Connecting over INSECURE ws:// to: $normalized")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Opening insecure relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
} else {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Opening relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
}
|
||||
|
||||
serverUrl = normalized
|
||||
shouldReconnect = true
|
||||
reconnectAttempt = 0
|
||||
doConnect(normalized)
|
||||
doConnect(normalized, previousSocket, replaceReason)
|
||||
}
|
||||
|
||||
// ----- ADR 24 — multi-endpoint resolution --------------------------------
|
||||
@@ -265,28 +369,37 @@ class ConnectionManager(
|
||||
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
|
||||
val resolver = endpointResolver ?: return null
|
||||
val ctx = context ?: return null
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val endpoints: List<EndpointCandidate> = try {
|
||||
val endpoints = try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
endpointCandidatesProvider?.invoke()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
} ?: run {
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
if (endpoints.isEmpty()) return null
|
||||
|
||||
// Manual override: if the user pinned a role in the Endpoints card,
|
||||
// try that one first; fall through to the strict-priority algorithm
|
||||
// if it isn't reachable.
|
||||
manualRoleOverride?.let { preferredRole ->
|
||||
_manualRoleOverride.value?.let { preferredRole ->
|
||||
val preferred = endpoints.firstOrNull {
|
||||
it.role.equals(preferredRole, ignoreCase = true)
|
||||
}
|
||||
@@ -305,36 +418,58 @@ class ConnectionManager(
|
||||
/**
|
||||
* User-triggered re-probe. Forces a fresh resolve + reconnect regardless
|
||||
* of cache state. Backs the "Probe now" row action in the Endpoints card.
|
||||
* Fire-and-forget wrapper around [probeAndReconnectNow] for callers that
|
||||
* don't need the outcome.
|
||||
*/
|
||||
fun probeAndReconnect() {
|
||||
scope.launch { probeAndReconnectNow() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaitable body of [probeAndReconnect]. Returns the resolved winner —
|
||||
* or null when no candidate answered — so callers (probe-status UI) can
|
||||
* report the outcome instead of guessing with a fixed delay.
|
||||
*
|
||||
* Unlike the pre-2026-06 version this ALWAYS publishes the resolve
|
||||
* outcome to [activeEndpoint]: a standard (no relay socket) connection
|
||||
* whose probes all failed used to early-return before publishing,
|
||||
* leaving the Routes card stuck on "Resolving" with no feedback. The
|
||||
* only exception is the live-socket transient-miss guard shared with
|
||||
* [refreshActiveEndpoint].
|
||||
*/
|
||||
suspend fun probeAndReconnectNow(): EndpointCandidate? {
|
||||
endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return@launch
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
webSocket?.close(1000, "Endpoint re-probe")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// — keep the live route published rather than downgrading every
|
||||
// HTTP surface to the saved URL. Mirrors refreshActiveEndpoint.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,9 +477,22 @@ class ConnectionManager(
|
||||
* WSS reconnect. Used by HTTP-only surfaces (chat/voice/relay HTTP)
|
||||
* so they can follow LAN/Tailscale/VPN route changes even when the relay
|
||||
* socket is currently disconnected or intentionally not paired.
|
||||
*
|
||||
* @param clearProbeCache wipe the resolver's probe cache first. Pass
|
||||
* `true` from "the world may have changed" triggers (app resume,
|
||||
* network change) — otherwise a route that died within the positive
|
||||
* cache TTL (60s) can still be returned as the winner.
|
||||
*/
|
||||
suspend fun refreshActiveEndpoint(): EndpointCandidate? {
|
||||
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
|
||||
if (clearProbeCache) endpointResolver?.clearCache()
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// (slow resume, mid-handoff blip) — keep publishing the live
|
||||
// route instead of downgrading every HTTP surface to the saved
|
||||
// URL. Mirrors scheduleNetworkReResolve's guard.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
return resolved
|
||||
}
|
||||
@@ -355,11 +503,11 @@ class ConnectionManager(
|
||||
* cycle — call [probeAndReconnect] to apply immediately.
|
||||
*/
|
||||
fun setManualRoleOverride(role: String?) {
|
||||
manualRoleOverride = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${manualRoleOverride ?: "(cleared)"}")
|
||||
_manualRoleOverride.value = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${_manualRoleOverride.value ?: "(cleared)"}")
|
||||
}
|
||||
|
||||
fun getManualRoleOverride(): String? = manualRoleOverride
|
||||
fun getManualRoleOverride(): String? = _manualRoleOverride.value
|
||||
|
||||
private fun markActiveEndpointUnreachable(reason: String) {
|
||||
val active = _activeEndpoint.value ?: return
|
||||
@@ -367,27 +515,48 @@ class ConnectionManager(
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
private fun resolveAndSwitchIfNeeded(closeReason: String) {
|
||||
/**
|
||||
* Debounced network-change re-resolution, shared by both NetworkCallback
|
||||
* events. Re-runs the resolver and publishes the winner to
|
||||
* [activeEndpoint] so HTTP-only surfaces (chat, dashboard, standard
|
||||
* voice) follow the route change even when no relay socket exists. When
|
||||
* a socket IS up, additionally swaps it to a differing winner, or
|
||||
* reconnects a disconnected socket on the same winner — preserving the
|
||||
* pre-refactor relay-path behavior.
|
||||
*/
|
||||
private fun scheduleNetworkReResolve(closeReason: String) {
|
||||
if (endpointResolver == null) return
|
||||
val current = serverUrl ?: return
|
||||
scope.launch {
|
||||
networkResolveJob?.cancel()
|
||||
networkResolveJob = scope.launch {
|
||||
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null) {
|
||||
_activeEndpoint.value = null
|
||||
// Don't clear a live socket's endpoint on a transient probe
|
||||
// miss — only drop the published route when nothing is
|
||||
// actually connected.
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val newUrl = resolved.relay.url
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
if (current == null) return@launch
|
||||
// After an explicit disconnect() the route still publishes above
|
||||
// (HTTP surfaces keep roaming), but no socket action: without
|
||||
// this gate a network event whose winner differs from the last
|
||||
// URL would resurrect a socket the user deliberately closed.
|
||||
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||
// the swap path never re-checked it.)
|
||||
if (!shouldReconnect) return@launch
|
||||
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "endpoint fallback: swapping $current → $normalizedNew")
|
||||
webSocket?.close(1000, closeReason)
|
||||
connectToUrlOnMainPath(newUrl)
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "endpoint fallback: same winner is disconnected — reconnecting $current")
|
||||
Log.i(TAG, "network change: same winner is disconnected — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
@@ -400,35 +569,15 @@ class ConnectionManager(
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
|
||||
if (endpointResolver == null) return
|
||||
val url = serverUrl ?: return
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val newUrl = resolved?.relay?.url
|
||||
if (newUrl == null) {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Only swap if the winner actually differs from the
|
||||
// currently-connected URL. Avoids dropping a healthy
|
||||
// socket on a no-op network flap (Wi-Fi scan, cell
|
||||
// handover that ends up on the same route, etc.).
|
||||
if (normalizedNew != url) {
|
||||
Log.i(TAG, "network change: swapping $url → $normalizedNew")
|
||||
webSocket?.close(1000, "Network change — switching endpoint")
|
||||
connectToUrlOnMainPath(newUrl)
|
||||
}
|
||||
}
|
||||
endpointResolver?.clearCache()
|
||||
scheduleNetworkReResolve("Network change — switching endpoint")
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost")
|
||||
resolveAndSwitchIfNeeded("Network lost — switching endpoint")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -475,14 +624,21 @@ class ConnectionManager(
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket disconnect requested",
|
||||
url = serverUrl,
|
||||
)
|
||||
webSocket?.close(1000, "Client disconnect")
|
||||
webSocket = null
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_isInsecureConnection.value = false
|
||||
// ADR 24: clear manual override on explicit disconnect — the
|
||||
// Routes card's "Prefer this route" menu contract is that it lasts
|
||||
// until the user disconnects, then resets to resolver-picked.
|
||||
manualRoleOverride = null
|
||||
// ADR 24: clear manual override on explicit disconnect — a "Use
|
||||
// now" switch lasts until the user disconnects, then resets to
|
||||
// resolver-picked. A sticky preferredRouteRole is re-installed by
|
||||
// the ViewModel on the next connection load.
|
||||
_manualRoleOverride.value = null
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
|
||||
@@ -499,17 +655,38 @@ class ConnectionManager(
|
||||
webSocket?.send(text)
|
||||
}
|
||||
|
||||
private fun doConnect(url: String) {
|
||||
private fun isActiveSocket(socket: WebSocket): Boolean = webSocket === socket
|
||||
|
||||
private fun doConnect(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
val existingState = _connectionState.value
|
||||
if (previousSocketToClose == null &&
|
||||
serverUrl == url &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
) {
|
||||
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
|
||||
return
|
||||
}
|
||||
|
||||
_connectionState.value = if (reconnectAttempt > 0) {
|
||||
ConnectionState.Reconnecting
|
||||
} else {
|
||||
ConnectionState.Connecting
|
||||
}
|
||||
|
||||
scope.launch { doConnectInternal(url) }
|
||||
scope.launch { doConnectInternal(url, previousSocketToClose, replaceReason) }
|
||||
}
|
||||
|
||||
private fun doConnectInternal(url: String) {
|
||||
private fun doConnectInternal(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
// Rebuild the client so the CertificatePinner picks up the current
|
||||
// pin store snapshot — crucial right after applyServerIssuedCodeAndReset
|
||||
// wipes a pin for re-pair. buildClient() does a tiny DataStore read
|
||||
@@ -521,12 +698,24 @@ class ConnectionManager(
|
||||
.build()
|
||||
|
||||
Log.i(TAG, "doConnect: opening WSS to $url")
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
val newSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onOpen: stale WSS handshake ignored ($url)")
|
||||
runCatching { webSocket.close(1000, "Stale relay socket") }
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
lastUpgradeResponseCode = null
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket connected",
|
||||
url = url,
|
||||
)
|
||||
|
||||
// TOFU: record the peer cert fingerprint if we don't have one
|
||||
// yet. OkHttp populates response.handshake when the connection
|
||||
@@ -549,6 +738,10 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onMessage: stale WSS envelope ignored ($url)")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val envelope = json.decodeFromString<Envelope>(text)
|
||||
multiplexer.route(envelope)
|
||||
@@ -563,14 +756,40 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onClosed: stale WSS close ignored ($url code=$code reason=$reason)")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "onClosed: code=$code reason=$reason")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay socket closed",
|
||||
detail = "code=$code reason=$reason",
|
||||
url = url,
|
||||
)
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onFailure: stale WSS failure ignored ($url ${t.javaClass.simpleName}: ${t.message})")
|
||||
return
|
||||
}
|
||||
val code = response?.code
|
||||
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=$code)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket failed",
|
||||
detail = listOfNotNull(
|
||||
t.javaClass.simpleName,
|
||||
t.message,
|
||||
code?.let { "HTTP $it" },
|
||||
).joinToString(": "),
|
||||
url = url,
|
||||
)
|
||||
lastUpgradeResponseCode = code
|
||||
if (response == null) {
|
||||
markActiveEndpointUnreachable("socket failure")
|
||||
@@ -579,6 +798,13 @@ class ConnectionManager(
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
webSocket = newSocket
|
||||
previousSocketToClose
|
||||
?.takeIf { it !== newSocket }
|
||||
?.let { staleSocket ->
|
||||
runCatching { staleSocket.close(1000, replaceReason) }
|
||||
staleSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
@@ -591,6 +817,13 @@ class ConnectionManager(
|
||||
// the rate limiter and block ourselves.
|
||||
if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate says no pair context — aborting retry")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Session,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect skipped",
|
||||
detail = "No paired session or pending pair code",
|
||||
url = serverUrl,
|
||||
)
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
return
|
||||
}
|
||||
@@ -604,11 +837,27 @@ class ConnectionManager(
|
||||
// server's full block window instead.
|
||||
val backoffMs = if (lastUpgradeResponseCode == 429) {
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect delayed",
|
||||
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
RATE_LIMIT_BACKOFF_MS
|
||||
} else {
|
||||
(BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
if (lastUpgradeResponseCode != 429) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay reconnect scheduled",
|
||||
detail = "Retrying in ${backoffMs / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
delay(backoffMs)
|
||||
|
||||
@@ -19,16 +19,46 @@ class ConnectivityObserver(private val context: Context) {
|
||||
|
||||
fun observe(): Flow<Status> = callbackFlow {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
if (connectivityManager == null) {
|
||||
trySend(Status.Unavailable)
|
||||
awaitClose { }
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun hasAnyInternetNetwork(): Boolean =
|
||||
connectivityManager.allNetworks.any { network ->
|
||||
hasInternetCapability(connectivityManager.getNetworkCapabilities(network))
|
||||
}
|
||||
|
||||
fun sendCurrentStatus(fallbackWhenNone: Status) {
|
||||
trySend(statusForInternetAvailability(hasAnyInternetNetwork(), fallbackWhenNone))
|
||||
}
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(Status.Available)
|
||||
sendCurrentStatus(Status.Available)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
) {
|
||||
sendCurrentStatus(
|
||||
if (hasInternetCapability(networkCapabilities)) {
|
||||
Status.Available
|
||||
} else {
|
||||
Status.Lost
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(Status.Lost)
|
||||
sendCurrentStatus(Status.Lost)
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
trySend(Status.Unavailable)
|
||||
sendCurrentStatus(Status.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +69,19 @@ class ConnectivityObserver(private val context: Context) {
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
// Emit current state
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
val isConnected = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
trySend(if (isConnected) Status.Available else Status.Unavailable)
|
||||
sendCurrentStatus(Status.Unavailable)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hasInternetCapability(caps: NetworkCapabilities?): Boolean =
|
||||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
|
||||
internal fun statusForInternetAvailability(
|
||||
hasAnyInternetNetwork: Boolean,
|
||||
fallbackWhenNone: ConnectivityObserver.Status,
|
||||
): ConnectivityObserver.Status =
|
||||
if (hasAnyInternetNetwork) ConnectivityObserver.Status.Available else fallbackWhenNone
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.KeystoreTokenStore
|
||||
import com.hermesandroid.relay.auth.LegacyEncryptedPrefsTokenStore
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// Status/session/provider snapshots are @Serializable so the Manage tab's
|
||||
// disk cache (DashboardManageDiskCache) can persist Loaded entries verbatim.
|
||||
@Serializable
|
||||
data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardAuthProvider(
|
||||
val name: String,
|
||||
val displayName: String? = null,
|
||||
val supportsPassword: Boolean = false,
|
||||
) {
|
||||
val isRedirectProvider: Boolean
|
||||
get() = !supportsPassword
|
||||
}
|
||||
|
||||
data class DashboardLoginResponse(
|
||||
val ok: Boolean,
|
||||
val next: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardAuthSession(
|
||||
val authenticated: Boolean,
|
||||
val username: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardWsTicket(
|
||||
val ticket: String,
|
||||
val ttlSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Native client for the Hermes dashboard/admin server (:9119).
|
||||
*
|
||||
* This is deliberately separate from [HermesApiClient] and all relay pairing
|
||||
* clients. Dashboard cookies authenticate standard admin surfaces such as
|
||||
* skills/cron/MCP/profile config; relay pairing remains the auth path for
|
||||
* terminal, bridge, media relay, and profile memory file editing.
|
||||
*/
|
||||
class DashboardApiClient(
|
||||
baseUrl: String,
|
||||
private val okHttpClient: OkHttpClient = defaultClient(),
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
},
|
||||
) {
|
||||
private val baseUrl: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
suspend fun getStatus(): Result<DashboardStatus> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/status").mapCatching { parseStatus(it) }
|
||||
}
|
||||
|
||||
suspend fun getAuthProviders(): Result<List<DashboardAuthProvider>> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/auth/providers").mapCatching { root ->
|
||||
parseProviders(root["providers"])
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
getJson(normalized)
|
||||
}
|
||||
|
||||
suspend fun getJsonElement(path: String): Result<JsonElement> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.get()
|
||||
.build()
|
||||
executeJsonElement(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun postJsonObject(
|
||||
path: String,
|
||||
payload: JsonObject = JsonObject(emptyMap()),
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun putJsonObject(
|
||||
path: String,
|
||||
payload: JsonObject,
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.put(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun deleteJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.delete()
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
/** DELETE with a JSON body — upstream's `DELETE /api/env` reads the key from the body. */
|
||||
suspend fun deleteJsonObjectWithBody(
|
||||
path: String,
|
||||
payload: JsonObject,
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.delete(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
// --- Models (dashboard parity with hermes-desktop Settings → Model) ---
|
||||
|
||||
/** Full provider/model universe — REST twin of the TUI's `model.options` RPC. */
|
||||
suspend fun getModelOptions(): Result<JsonObject> = getJsonObject("/api/model/options")
|
||||
|
||||
/**
|
||||
* Assign the main model in `~/.hermes/config.yaml` (new sessions only).
|
||||
* Upstream may answer `{ok: false, confirm_required: true, warning: ...}`
|
||||
* for expensive models — re-call with [confirmExpensive] after the user
|
||||
* accepts the warning.
|
||||
*/
|
||||
suspend fun setMainModel(
|
||||
provider: String,
|
||||
model: String,
|
||||
confirmExpensive: Boolean = false,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/model/set",
|
||||
payload = buildJsonObject {
|
||||
put("scope", "main")
|
||||
put("provider", provider)
|
||||
put("model", model)
|
||||
if (confirmExpensive) put("confirm_expensive_model", true)
|
||||
},
|
||||
)
|
||||
|
||||
// --- Env / keys (dashboard parity with hermes-desktop Settings → Keys) ---
|
||||
|
||||
/** Curated env-var inventory: name → {is_set, redacted_value, description, category, ...}. */
|
||||
suspend fun getEnvVars(): Result<JsonObject> = getJsonObject("/api/env")
|
||||
|
||||
suspend fun setEnvVar(key: String, value: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/env",
|
||||
payload = buildJsonObject {
|
||||
put("key", key)
|
||||
put("value", value)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun deleteEnvVar(key: String): Result<JsonObject> =
|
||||
deleteJsonObjectWithBody(
|
||||
path = "/api/env",
|
||||
payload = buildJsonObject { put("key", key) },
|
||||
)
|
||||
|
||||
/** Server rate-limits reveals (5 per 30s) and audit-logs each one. */
|
||||
suspend fun revealEnvVar(key: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/env/reveal",
|
||||
payload = buildJsonObject { put("key", key) },
|
||||
)
|
||||
|
||||
// --- Skills hub (dashboard parity with hermes-desktop Browse-hub tab) ---
|
||||
|
||||
/**
|
||||
* Parallel multi-source hub search. Response carries `results` (name /
|
||||
* description / source / identifier / trust_level / repo / tags),
|
||||
* `source_counts`, `timed_out`, and `installed` (identifier → lock entry)
|
||||
* so already-installed results can be marked. Server caps limit at 50 and
|
||||
* fans out with a 30s overall timeout — keep client read timeouts above that.
|
||||
*/
|
||||
suspend fun searchSkillsHub(query: String, limit: Int = 20): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/search?q=${queryValue(query)}&limit=${limit.coerceIn(1, 50)}")
|
||||
|
||||
/** SKILL.md + manifest for an identifier WITHOUT installing — read before you trust. */
|
||||
suspend fun previewSkillsHub(identifier: String): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/preview?identifier=${queryValue(identifier)}")
|
||||
|
||||
/**
|
||||
* Configured hub sources + featured skills (`{sources, index_available,
|
||||
* featured, installed}`) — content for the browse dialog before the first
|
||||
* search. Featured entries share the search-result payload shape.
|
||||
*/
|
||||
suspend fun getSkillsHubSources(): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/sources")
|
||||
|
||||
/**
|
||||
* Spawns `hermes skills install <identifier>` server-side and returns
|
||||
* `{ok, pid}` immediately — the install completes in the background, so
|
||||
* callers should message "started" and refresh the skills list later.
|
||||
*/
|
||||
suspend fun installSkillsHub(identifier: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/skills/hub/install",
|
||||
payload = buildJsonObject { put("identifier", identifier) },
|
||||
)
|
||||
|
||||
/** Async spawn like install; takes the installed skill *name*, not the hub identifier. */
|
||||
suspend fun uninstallSkillsHub(name: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/skills/hub/uninstall",
|
||||
payload = buildJsonObject { put("name", name) },
|
||||
)
|
||||
|
||||
/** Async spawn of `hermes skills update` for all hub-installed skills. */
|
||||
suspend fun updateSkillsHub(): Result<JsonObject> =
|
||||
postJsonObject("/api/skills/hub/update")
|
||||
|
||||
// --- Profiles (write surface) ---
|
||||
|
||||
/** Full SOUL.md text — upstream returns the complete file, safe for round-trip editing. */
|
||||
suspend fun putProfileSoul(name: String, content: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/soul",
|
||||
payload = buildJsonObject { put("content", content) },
|
||||
)
|
||||
|
||||
suspend fun createProfile(
|
||||
name: String,
|
||||
cloneFromDefault: Boolean = true,
|
||||
description: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/profiles",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put("clone_from_default", cloneFromDefault)
|
||||
if (!description.isNullOrBlank()) put("description", description)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun setProfileDescription(name: String, description: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/description",
|
||||
payload = buildJsonObject { put("description", description) },
|
||||
)
|
||||
|
||||
suspend fun setProfileModel(
|
||||
name: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/model",
|
||||
payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("model", model)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun toggleSkill(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/skills/toggle",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put("enabled", enabled)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun pauseCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/pause${profileQuery(profile)}")
|
||||
|
||||
suspend fun resumeCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/resume${profileQuery(profile)}")
|
||||
|
||||
suspend fun triggerCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/trigger${profileQuery(profile)}")
|
||||
|
||||
suspend fun getCronJobRuns(
|
||||
jobId: String,
|
||||
profile: String? = null,
|
||||
limit: Int = 20,
|
||||
): Result<JsonObject> =
|
||||
getJsonObject("/api/cron/jobs/${pathSegment(jobId)}/runs${profileLimitQuery(profile, limit)}")
|
||||
|
||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
||||
payload = buildJsonObject { put("enabled", enabled) },
|
||||
)
|
||||
|
||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
||||
|
||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
name: String,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
enable: Boolean = true,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/mcp/catalog/install",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put(
|
||||
"env",
|
||||
buildJsonObject {
|
||||
env.forEach { (key, value) -> put(key, value) }
|
||||
},
|
||||
)
|
||||
put("enable", enable)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun setActiveProfile(name: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/profiles/active",
|
||||
payload = buildJsonObject { put("name", name) },
|
||||
)
|
||||
|
||||
suspend fun getProfileSoul(name: String): Result<JsonObject> =
|
||||
getJsonObject("/api/profiles/${pathSegment(name)}/soul")
|
||||
|
||||
suspend fun deleteProfile(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/profiles/${pathSegment(name)}")
|
||||
|
||||
suspend fun loginPassword(
|
||||
provider: String = "basic",
|
||||
username: String,
|
||||
password: String,
|
||||
next: String = "/",
|
||||
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
|
||||
val payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("next", next)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/auth/password-login")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
executeJson(request, "Dashboard sign-in").mapCatching { root ->
|
||||
DashboardLoginResponse(
|
||||
ok = root.booleanField("ok") ?: true,
|
||||
next = root.stringField("next"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentSession(): Result<DashboardAuthSession> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/auth/me")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return@withContext Result.success(DashboardAuthSession(authenticated = false))
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "Dashboard session"))
|
||||
}
|
||||
val root = response.readJsonObject(json)
|
||||
Result.success(parseAuthSession(root))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this dashboard build exposes the hermes-desktop voice routes
|
||||
* (`/api/audio/transcribe` + `/api/audio/speak`). HEAD on a POST-only
|
||||
* FastAPI route returns 405 when the path exists and 404 when it doesn't;
|
||||
* an auth-gated 401/403 also proves the route is registered.
|
||||
*/
|
||||
suspend fun audioRoutesPresent(): Boolean = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/transcribe")
|
||||
.head()
|
||||
.build()
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { it.code != 404 }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestWsTicket(): Result<DashboardWsTicket> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/auth/ws-ticket")
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
|
||||
executeJson(request, "Dashboard websocket ticket").mapCatching { root ->
|
||||
val ticket = root.stringField("ticket")
|
||||
?: root.stringField("ws_ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
DashboardWsTicket(
|
||||
ticket = ticket,
|
||||
ttlSeconds = root.intField("ttl_seconds") ?: root.intField("ttl"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun authLoginUrl(provider: String, next: String = "/"): String =
|
||||
authLoginUrl(baseUrl = baseUrl, provider = provider, next = next)
|
||||
|
||||
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
|
||||
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
|
||||
|
||||
fun shutdown() {
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
okHttpClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
private suspend fun getJson(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$path")
|
||||
.get()
|
||||
.build()
|
||||
executeJson(request, path)
|
||||
}
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
Result.success(response.readJsonObject(json))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeJsonElement(request: Request, operation: String): Result<JsonElement> {
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
Result.success(response.readJsonElement(json))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
fun pathSegment(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
private fun queryValue(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
fun authLoginUrl(baseUrl: String, provider: String, next: String = "/"): String {
|
||||
val root = baseUrl.trim().trimEnd('/')
|
||||
return "$root/auth/login?provider=${queryValue(provider)}&next=${queryValue(next)}"
|
||||
}
|
||||
|
||||
fun authLandingPath(baseUrl: String): String {
|
||||
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return "/"
|
||||
val basePath = httpUrl.encodedPath.trimEnd('/')
|
||||
return when {
|
||||
basePath.isBlank() || basePath == "/" -> "/"
|
||||
else -> "$basePath/"
|
||||
}
|
||||
}
|
||||
|
||||
fun gatewayWebSocketUrl(baseUrl: String, ticket: String, path: String = "/api/ws"): String? {
|
||||
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return null
|
||||
val websocketPrefix = when (httpUrl.scheme) {
|
||||
"https" -> "wss://"
|
||||
"http" -> "ws://"
|
||||
else -> return null
|
||||
}
|
||||
val normalizedPath = if (path.startsWith("/")) path else "/$path"
|
||||
val basePath = httpUrl.encodedPath.trimEnd('/')
|
||||
val encodedPath = when {
|
||||
basePath.isBlank() || basePath == "/" -> normalizedPath
|
||||
else -> "$basePath$normalizedPath"
|
||||
}
|
||||
val url = httpUrl.newBuilder()
|
||||
.encodedPath(encodedPath)
|
||||
.addQueryParameter("ticket", ticket)
|
||||
.build()
|
||||
.toString()
|
||||
return websocketPrefix + url.substringAfter("://")
|
||||
}
|
||||
|
||||
private fun profileQuery(profile: String?): String {
|
||||
val trimmed = profile?.trim().orEmpty()
|
||||
return if (trimmed.isBlank()) "" else "?profile=${pathSegment(trimmed)}"
|
||||
}
|
||||
|
||||
private fun profileLimitQuery(profile: String?, limit: Int): String {
|
||||
val params = buildList {
|
||||
val trimmed = profile?.trim().orEmpty()
|
||||
if (trimmed.isNotBlank()) add("profile=${pathSegment(trimmed)}")
|
||||
add("limit=${limit.coerceIn(1, 100)}")
|
||||
}
|
||||
return params.joinToString(prefix = "?", separator = "&")
|
||||
}
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
val providersElement = root["auth_providers"]
|
||||
?: root["providers"]
|
||||
?: authObject?.get("providers")
|
||||
val providers = parseProviders(providersElement)
|
||||
return DashboardStatus(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAuthSession(root: JsonObject): DashboardAuthSession {
|
||||
val user = root["user"] as? JsonObject
|
||||
val session = root["session"] as? JsonObject
|
||||
val explicitAuthenticated = root.booleanField("authenticated")
|
||||
?: root.booleanField("ok")
|
||||
val flatIdentityPresent =
|
||||
root.stringField("user_id") != null ||
|
||||
root.stringField("email") != null ||
|
||||
root.stringField("display_name") != null ||
|
||||
root.stringField("provider") != null ||
|
||||
root["expires_at"] != null
|
||||
val authenticated = explicitAuthenticated
|
||||
?: (user != null || session != null || flatIdentityPresent)
|
||||
return DashboardAuthSession(
|
||||
authenticated = authenticated,
|
||||
username = root.stringField("username")
|
||||
?: root.stringField("display_name")
|
||||
?: root.stringField("email")
|
||||
?: root.stringField("user_id")
|
||||
?: user.stringField("username")
|
||||
?: user.stringField("name")
|
||||
?: session.stringField("username"),
|
||||
provider = root.stringField("provider")
|
||||
?: session.stringField("provider")
|
||||
?: user.stringField("provider"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseProviders(element: JsonElement?): List<DashboardAuthProvider> {
|
||||
return when (element) {
|
||||
is JsonArray -> element.mapNotNull { provider(it) }
|
||||
is JsonObject -> element.entries.mapNotNull { (key, value) ->
|
||||
val name = key.trim().takeIf { it.isNotBlank() }
|
||||
if (name != null && value is JsonObject) {
|
||||
provider(name, value)
|
||||
} else {
|
||||
provider(value) ?: name?.let {
|
||||
DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}.distinctBy { it.name }
|
||||
}
|
||||
|
||||
private fun provider(element: JsonElement?): DashboardAuthProvider? {
|
||||
return when (element) {
|
||||
is JsonPrimitive -> element.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it)) }
|
||||
is JsonObject -> {
|
||||
val name = element.stringField("id")
|
||||
?: element.stringField("name")
|
||||
?: element.stringField("provider")
|
||||
?: element.stringField("type")
|
||||
name?.let { provider(it, element) }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun provider(name: String, element: JsonObject): DashboardAuthProvider =
|
||||
DashboardAuthProvider(
|
||||
name = name,
|
||||
displayName = element.stringField("display_name")
|
||||
?: element.stringField("label")
|
||||
?: element.stringField("title"),
|
||||
supportsPassword = element.booleanField("supports_password")
|
||||
?: isPasswordProvider(name),
|
||||
)
|
||||
|
||||
private fun isPasswordProvider(name: String): Boolean =
|
||||
name.equals("basic", ignoreCase = true) ||
|
||||
name.equals("password", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
class InMemoryDashboardCookieStore : DashboardCookieStore {
|
||||
private val lock = Any()
|
||||
private var cookies: List<StoredDashboardCookie> = emptyList()
|
||||
|
||||
override fun load(): List<StoredDashboardCookie> = synchronized(lock) { cookies }
|
||||
|
||||
override fun save(cookies: List<StoredDashboardCookie>) {
|
||||
synchronized(lock) {
|
||||
this.cookies = cookies
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
synchronized(lock) {
|
||||
cookies = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EncryptedDashboardCookieStore(
|
||||
context: Context,
|
||||
connectionId: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : DashboardCookieStore {
|
||||
private val serializer = ListSerializer(StoredDashboardCookie.serializer())
|
||||
private val appContext = context.applicationContext
|
||||
private val prefsName = prefsName(connectionId)
|
||||
|
||||
// DEFERRED on purpose. Building the Keystore-backed prefs takes 1-4s
|
||||
// on StrongBox devices and serializes through a process-GLOBAL Tink
|
||||
// lock (AndroidKeysetManager.Builder.build) — eager construction here
|
||||
// froze the main thread for ~11s at app start when several stores were
|
||||
// built concurrently (frozen-sphere incident, 2026-06-11). Construction
|
||||
// is now free on any thread; the expensive build happens on the first
|
||||
// actual cookie access, which is always an OkHttp/IO thread.
|
||||
private val store: SessionTokenStore by lazy {
|
||||
KeystoreTokenStore.tryCreate(appContext, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, prefsName)
|
||||
}
|
||||
|
||||
override fun load(): List<StoredDashboardCookie> {
|
||||
val raw = store.getString(KEY_COOKIES) ?: return emptyList()
|
||||
return runCatching { json.decodeFromString(serializer, raw) }
|
||||
.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
override fun save(cookies: List<StoredDashboardCookie>) {
|
||||
store.putString(KEY_COOKIES, json.encodeToString(serializer, cookies))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(KEY_COOKIES)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_COOKIES = "dashboard_cookies_json"
|
||||
|
||||
fun prefsName(connectionId: String): String =
|
||||
"hermes_dashboard_${connectionId.take(8)}"
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardCookieJar(
|
||||
private val store: DashboardCookieStore,
|
||||
private val clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
) : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val now = clockMillis()
|
||||
val incoming = cookies.map { StoredDashboardCookie.fromCookie(it) }
|
||||
.filterNot { it.isExpired(now) }
|
||||
val retained = store.load()
|
||||
.filterNot { it.isExpired(now) }
|
||||
.filterNot { old -> incoming.any { it.key == old.key } }
|
||||
store.save(retained + incoming)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val now = clockMillis()
|
||||
val stored = store.load().filterNot { it.isExpired(now) }
|
||||
if (stored.size != store.load().size) {
|
||||
store.save(stored)
|
||||
}
|
||||
return stored.mapNotNull { it.toCookie() }
|
||||
.filter { it.matches(url) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that resolves the backing per-connection store at request time.
|
||||
*
|
||||
* Long-lived OkHttpClients (e.g. the standard voice client, remembered once
|
||||
* per process in RelayApp) can't bind a fixed [DashboardCookieStore] because
|
||||
* the active Connection — and therefore the encrypted cookie file — changes
|
||||
* when the user switches connections. A null store (no active connection)
|
||||
* degrades to an empty jar rather than failing the request.
|
||||
*/
|
||||
class DynamicDashboardCookieJar(
|
||||
private val storeProvider: () -> DashboardCookieStore?,
|
||||
) : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val store = storeProvider() ?: return
|
||||
DashboardCookieJar(store).saveFromResponse(url, cookies)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val store = storeProvider() ?: return emptyList()
|
||||
return DashboardCookieJar(store).loadForRequest(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun importDashboardCookieHeader(
|
||||
store: DashboardCookieStore,
|
||||
url: String,
|
||||
cookieHeader: String?,
|
||||
clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
): Int {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return 0
|
||||
val raw = cookieHeader?.trim().orEmpty()
|
||||
if (raw.isBlank()) return 0
|
||||
|
||||
val now = clockMillis()
|
||||
// CookieManager.getCookie(url) returns only "name=value" pairs; it does
|
||||
// not expose the original Set-Cookie Path attribute. Store imported
|
||||
// WebView auth cookies at root so a cookie observed on /auth/callback is
|
||||
// still sent to /api/auth/me during native session verification.
|
||||
val cookiePath = "/"
|
||||
val imported = raw.split(";")
|
||||
.mapNotNull { part ->
|
||||
val index = part.indexOf('=')
|
||||
if (index <= 0) return@mapNotNull null
|
||||
val name = part.substring(0, index).trim()
|
||||
val value = part.substring(index + 1).trim()
|
||||
if (name.isBlank()) return@mapNotNull null
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = Long.MAX_VALUE,
|
||||
domain = httpUrl.host,
|
||||
path = cookiePath,
|
||||
secure = httpUrl.isHttps,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
persistent = false,
|
||||
)
|
||||
}
|
||||
.filterNot { it.isExpired(now) }
|
||||
if (imported.isEmpty()) return 0
|
||||
|
||||
val retained = store.load()
|
||||
.filterNot { it.isExpired(now) }
|
||||
.filterNot { old -> imported.any { it.key == old.key } }
|
||||
store.save(retained + imported)
|
||||
return imported.size
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StoredDashboardCookie(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
) {
|
||||
val key: String
|
||||
get() = "${name.lowercase()}|${domain.lowercase()}|$path"
|
||||
|
||||
fun isExpired(nowMillis: Long): Boolean =
|
||||
persistent && expiresAt <= nowMillis
|
||||
|
||||
fun toCookie(): Cookie? {
|
||||
return runCatching {
|
||||
val builder = Cookie.Builder()
|
||||
.name(name)
|
||||
.value(value)
|
||||
.path(path)
|
||||
if (hostOnly) {
|
||||
builder.hostOnlyDomain(domain)
|
||||
} else {
|
||||
builder.domain(domain)
|
||||
}
|
||||
if (persistent) {
|
||||
builder.expiresAt(expiresAt)
|
||||
}
|
||||
if (secure) builder.secure()
|
||||
if (httpOnly) builder.httpOnly()
|
||||
builder.build()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCookie(cookie: Cookie): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = cookie.name,
|
||||
value = cookie.value,
|
||||
expiresAt = cookie.expiresAt,
|
||||
domain = cookie.domain,
|
||||
path = cookie.path,
|
||||
secure = cookie.secure,
|
||||
httpOnly = cookie.httpOnly,
|
||||
hostOnly = cookie.hostOnly,
|
||||
persistent = cookie.persistent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Response.readJsonObject(json: Json): JsonObject {
|
||||
val raw = body.string()
|
||||
if (raw.isBlank()) return JsonObject(emptyMap())
|
||||
return json.parseToJsonElement(raw).jsonObject
|
||||
}
|
||||
|
||||
private fun Response.readJsonElement(json: Json): JsonElement {
|
||||
val raw = body.string()
|
||||
if (raw.isBlank()) return JsonObject(emptyMap())
|
||||
return json.parseToJsonElement(raw)
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val bodyDetail = runCatching { response.body.string() }.getOrDefault("")
|
||||
val detail = bodyDetail.take(240).ifBlank { response.message }
|
||||
return IOException("$operation failed - HTTP ${response.code}: $detail")
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(name: String): String? =
|
||||
((this?.get(name) as? JsonPrimitive)?.contentOrNull)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun JsonObject?.booleanField(name: String): Boolean? =
|
||||
(this?.get(name) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject?.intField(name: String): Int? =
|
||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
||||
@@ -2,18 +2,46 @@ package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Last observed probe result for a single [EndpointCandidate], keyed by
|
||||
* [EndpointResolver.cacheKey] in [EndpointResolver.probeOutcomes]. Unlike the
|
||||
* probe *cache* (a short-TTL "don't re-ask the network" optimization), this is
|
||||
* a UI-facing record of what actually happened — it survives [EndpointResolver
|
||||
* .clearCache] so the Routes card can keep showing the most recent
|
||||
* reachability verdict between probes.
|
||||
*/
|
||||
data class RouteProbeOutcome(
|
||||
val reachable: Boolean,
|
||||
/** Short human-readable failure reason; null when [reachable]. */
|
||||
val detail: String? = null,
|
||||
/** Resolver-clock timestamp of when the probe finished. */
|
||||
val atMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks the highest-priority **reachable** [EndpointCandidate] from a
|
||||
@@ -27,8 +55,9 @@ import java.util.concurrent.TimeUnit
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
|
||||
* per-candidate timeout. The cache lives 60 seconds per `(role|host:port)`
|
||||
* key so repeated `connect()` calls don't hammer the network.
|
||||
* per-candidate timeout. Positive results are cached longer than negative
|
||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||
* transient handoff misses do not pin a good fallback offline.
|
||||
* * **Network-change re-evaluate.** `ConnectionManager`'s network callback
|
||||
* bumps the caller into `resolve()` again on `onAvailable`, and marks the
|
||||
* active endpoint unreachable on `onLost` via [markUnreachable].
|
||||
@@ -66,6 +95,26 @@ class EndpointResolver(
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
|
||||
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
||||
|
||||
/**
|
||||
* Last probe verdict per candidate, keyed by [cacheKey]. Drives the
|
||||
* per-row reachability line in the Routes card. Deliberately NOT wiped by
|
||||
* [clearCache] — the cache controls when we re-ask the network; this
|
||||
* records what the network last said.
|
||||
*/
|
||||
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
|
||||
|
||||
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
|
||||
_probeOutcomes.update { outcomes ->
|
||||
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
|
||||
reachable = reachable,
|
||||
detail = detail,
|
||||
atMillis = clock(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EndpointResolver"
|
||||
/**
|
||||
@@ -77,7 +126,7 @@ class EndpointResolver(
|
||||
*/
|
||||
const val PROBE_TIMEOUT_MS = 4_000L
|
||||
/**
|
||||
* Probe-result cache TTL. Widened from ADR 24's 30s to 60s for
|
||||
* Successful probe-result cache TTL. Widened from ADR 24's 30s to 60s for
|
||||
* two reasons: (1) HEAD /health on every tab open was burning
|
||||
* battery unnecessarily on mobile, (2) NetworkCallback's
|
||||
* onAvailable / onLost invalidates the cache on real network
|
||||
@@ -87,6 +136,17 @@ class EndpointResolver(
|
||||
*/
|
||||
const val CACHE_TTL_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Failed probe-result cache TTL. Keep this intentionally short:
|
||||
* Android may report a new cellular/VPN network before Tailscale has
|
||||
* finished routing, so a single early ConnectException must not keep a
|
||||
* viable fallback route suppressed through the voice resume window.
|
||||
*/
|
||||
const val NEGATIVE_CACHE_TTL_MS = 2_000L
|
||||
|
||||
/** Shared timeout wording so HEAD-timeout and socket-timeout read the same. */
|
||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
@@ -127,11 +187,25 @@ class EndpointResolver(
|
||||
if (winner != null) {
|
||||
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
||||
"api=${winner.api.host}:${winner.api.port} priority=$priority")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Endpoint selected",
|
||||
detail = "priority=$priority",
|
||||
endpointRole = winner.role,
|
||||
url = winner.relay.url,
|
||||
)
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "resolve: no reachable candidate across ${candidates.size} record(s)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "No reachable endpoint",
|
||||
detail = "${candidates.size} configured route(s) failed health probes",
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -193,10 +267,8 @@ class EndpointResolver(
|
||||
}
|
||||
|
||||
val reachable = probe(candidate)
|
||||
probeCache[key] = CacheEntry(
|
||||
expiresAt = now + CACHE_TTL_MS,
|
||||
reachable = reachable,
|
||||
)
|
||||
val ttl = if (reachable) CACHE_TTL_MS else NEGATIVE_CACHE_TTL_MS
|
||||
probeCache[key] = CacheEntry(expiresAt = now + ttl, reachable = reachable)
|
||||
return reachable
|
||||
}
|
||||
|
||||
@@ -209,9 +281,19 @@ class EndpointResolver(
|
||||
* We never raise: a bad record shouldn't crash the connect loop.
|
||||
*/
|
||||
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
||||
val startedAtMs = clock()
|
||||
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Endpoint probe invalid",
|
||||
detail = "Invalid API URL",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
@@ -229,37 +311,106 @@ class EndpointResolver(
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
fastClient.newCall(request).execute().use { resp ->
|
||||
resp.isSuccessful
|
||||
val ok = resp.isSuccessful
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
|
||||
title = if (ok) "Endpoint probe ok" else "Endpoint probe failed",
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from /health",
|
||||
)
|
||||
ok
|
||||
}
|
||||
} ?: false
|
||||
} ?: run {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a probe exception to a short, actionable string for the Routes
|
||||
* card. The TLS case is the headline: a route saved with `https://`
|
||||
* against a plain-HTTP Hermes API server fails its handshake on every
|
||||
* probe and previously surfaced as a silent "never switches" mystery.
|
||||
*/
|
||||
private fun humanProbeFailure(e: Exception): String = when (e) {
|
||||
is SSLException -> "TLS failed — server may be http://, not https://"
|
||||
is ConnectException -> "Connection refused"
|
||||
is UnknownHostException -> "Host not found"
|
||||
is SocketTimeoutException -> PROBE_TIMEOUT_DETAIL
|
||||
is NoRouteToHostException -> "No route to host"
|
||||
else -> e.javaClass.simpleName
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
* skips the dead endpoint without waiting for its probe to time out.
|
||||
*
|
||||
* The entry is still TTL'd — after 30 seconds it expires and the next
|
||||
* resolve() will re-probe. That matches "ADR 24 — cached for 30 seconds"
|
||||
* and stops a permanently-cached stale result.
|
||||
* The entry is still TTL'd with the short negative TTL so a network-change
|
||||
* transition can skip the known-dead active route without suppressing a
|
||||
* valid fallback for the whole positive cache window.
|
||||
*/
|
||||
fun markUnreachable(candidate: EndpointCandidate) {
|
||||
val key = cacheKey(candidate)
|
||||
probeCache[key] = CacheEntry(
|
||||
expiresAt = clock() + CACHE_TTL_MS,
|
||||
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
|
||||
}
|
||||
|
||||
/** Test-only: wipe the probe cache so a fresh run starts clean. */
|
||||
/**
|
||||
* Wipe the probe cache so the next resolve runs fresh probes. Called on
|
||||
* "the world changed" triggers — NetworkCallback events, manual "Probe
|
||||
* now", and [refreshActiveEndpoint][ConnectionManager.refreshActiveEndpoint]
|
||||
* with `clearProbeCache = true` — where a positive entry for a
|
||||
* just-died route must not outlive the handoff.
|
||||
*/
|
||||
internal fun clearCache() {
|
||||
probeCache.clear()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -62,9 +63,9 @@ enum class ChatMode {
|
||||
* returns an SSE stream, while `/v1/runs` may be an async JSON run-start API.
|
||||
*/
|
||||
data class ServerCapabilities(
|
||||
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
|
||||
/** `/api/sessions` (CRUD) — true on native upstream, fork, OR bootstrap-injected older builds. */
|
||||
val sessionsApi: Boolean,
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true on native upstream or legacy fork builds. */
|
||||
val sessionsChatStream: Boolean,
|
||||
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
|
||||
val runs: Boolean,
|
||||
@@ -99,6 +100,62 @@ data class ServerCapabilities(
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.childObject(key: String): JsonObject? = this[key] as? JsonObject
|
||||
|
||||
private fun JsonObject.booleanFlag(key: String): Boolean =
|
||||
(this[key] as? JsonPrimitive)?.booleanOrNull == true
|
||||
|
||||
private fun JsonObject.hasEndpoint(key: String): Boolean {
|
||||
val path = ((this[key] as? JsonObject)?.get("path") as? JsonPrimitive)?.contentOrNull
|
||||
return !path.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities? {
|
||||
val root = try {
|
||||
json.decodeFromString<JsonObject>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val features = root.childObject("features")
|
||||
val endpoints = root.childObject("endpoints")
|
||||
if (features == null && endpoints == null) return null
|
||||
|
||||
fun feature(name: String): Boolean = features?.booleanFlag(name) == true
|
||||
fun endpoint(name: String): Boolean = endpoints?.hasEndpoint(name) == true
|
||||
|
||||
return ServerCapabilities(
|
||||
sessionsApi = feature("session_resources") ||
|
||||
endpoint("sessions") ||
|
||||
endpoint("session_create"),
|
||||
sessionsChatStream = feature("session_chat_streaming") ||
|
||||
endpoint("session_chat_stream"),
|
||||
runs = feature("run_events_sse") || endpoint("run_events"),
|
||||
portable = feature("chat_completions_streaming") ||
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal val HERMES_SKILL_ENDPOINTS = listOf("/v1/skills", "/api/skills")
|
||||
|
||||
internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items ?: parsed.data
|
||||
if (skills != null) return skills
|
||||
} catch (_: Exception) {
|
||||
// Fall through to direct-array compatibility below.
|
||||
}
|
||||
|
||||
try {
|
||||
return json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -242,7 +299,7 @@ class HermesApiClient(
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.items ?: parsed.sessions ?: emptyList())
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
@@ -331,7 +388,7 @@ class HermesApiClient(
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<MessageListResponse>(body)
|
||||
parsed.items ?: parsed.messages ?: emptyList()
|
||||
parsed.data ?: parsed.items ?: parsed.messages ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get messages: ${e.message}")
|
||||
@@ -342,27 +399,21 @@ class HermesApiClient(
|
||||
// --- Skills ---
|
||||
|
||||
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/skills").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
// Try structured response: { "skills": [...] } or { "items": [...] }
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items
|
||||
for (endpoint in HERMES_SKILL_ENDPOINTS) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl$endpoint").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
val body = response.body?.string() ?: return@use
|
||||
val skills = parseSkillListBody(json, body)
|
||||
if (skills != null) return@withContext skills
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
// Try direct array: [...]
|
||||
try {
|
||||
return@withContext json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills from $endpoint: ${e.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
@@ -1129,14 +1180,15 @@ class HermesApiClient(
|
||||
*
|
||||
* Probe order:
|
||||
* 1. `/health` — if this fails, everything else is moot.
|
||||
* 2. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork OR
|
||||
* bootstrap-injected upstream).
|
||||
* 3. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* 2. `GET /v1/capabilities` — native upstream feature + endpoint map.
|
||||
* 3. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork,
|
||||
* native upstream, OR bootstrap-injected older upstream).
|
||||
* 4. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* presence. The handler only accepts POST, so HEAD returns 405
|
||||
* (Method Not Allowed) when the route is registered. 404 means
|
||||
* the route doesn't exist at all.
|
||||
* 4. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 5. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* 5. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 6. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* when the response explicitly advertises event-stream compatibility.
|
||||
*
|
||||
* **Why HEAD instead of OPTIONS:** The hermes-agent gateway runs CORS
|
||||
@@ -1168,6 +1220,20 @@ class HermesApiClient(
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
null
|
||||
} else {
|
||||
parseCapabilitiesBody(json, response.body.string())
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
// Dispatchers.IO context from the outer withContext, so the
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkAddress
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.Inet4Address
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class HermesLanDiscoveryResult(
|
||||
val host: String,
|
||||
val apiUrl: String,
|
||||
val dashboardUrl: String?,
|
||||
val apiReachable: Boolean,
|
||||
val dashboardReachable: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-triggered local-network discovery for standard Hermes setup.
|
||||
*
|
||||
* This deliberately scans only the active RFC1918/link-local LAN around the
|
||||
* phone, never broad public or Tailscale ranges. Tailscale/public routes still
|
||||
* belong in the explicit advanced fields where the user controls the URL.
|
||||
*/
|
||||
object HermesLanDiscovery {
|
||||
private const val TAG = "HermesLanDiscovery"
|
||||
private const val MAX_HOSTS = 254
|
||||
private const val MAX_CONCURRENT_PROBES = 32
|
||||
private const val PROBE_TIMEOUT_MS = 650L
|
||||
private const val IPV4_MASK = 0xFFFF_FFFFL
|
||||
|
||||
suspend fun scan(
|
||||
context: Context,
|
||||
apiPort: Int = 8642,
|
||||
dashboardPort: Int = 9119,
|
||||
): List<HermesLanDiscoveryResult> = withContext(Dispatchers.IO) {
|
||||
val hosts = localLanHosts(context.applicationContext)
|
||||
if (hosts.isEmpty()) return@withContext emptyList()
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
coroutineScope {
|
||||
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
|
||||
hosts.map { host ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
probeHost(client, host, apiPort, dashboardPort)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
.filterNotNull()
|
||||
.sortedWith(
|
||||
compareByDescending<HermesLanDiscoveryResult> { it.dashboardReachable }
|
||||
.thenByDescending { it.apiReachable }
|
||||
.thenBy { it.host },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeHost(
|
||||
client: OkHttpClient,
|
||||
host: String,
|
||||
apiPort: Int,
|
||||
dashboardPort: Int,
|
||||
): HermesLanDiscoveryResult? {
|
||||
val apiUrl = "http://$host:$apiPort"
|
||||
val dashboardUrl = "http://$host:$dashboardPort"
|
||||
val dashboardReachable = probe(
|
||||
client = client,
|
||||
url = "$dashboardUrl/api/status",
|
||||
expectedBody = ::looksLikeDashboardStatus,
|
||||
)
|
||||
val apiReachable = probe(
|
||||
client = client,
|
||||
url = "$apiUrl/health",
|
||||
expectedBody = ::looksLikeApiHealth,
|
||||
)
|
||||
if (!dashboardReachable && !apiReachable) return null
|
||||
return HermesLanDiscoveryResult(
|
||||
host = host,
|
||||
apiUrl = apiUrl,
|
||||
dashboardUrl = dashboardUrl.takeIf { dashboardReachable },
|
||||
apiReachable = apiReachable,
|
||||
dashboardReachable = dashboardReachable,
|
||||
)
|
||||
}
|
||||
|
||||
private fun probe(
|
||||
client: OkHttpClient,
|
||||
url: String,
|
||||
expectedBody: (String, String) -> Boolean,
|
||||
): Boolean {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return false
|
||||
val request = Request.Builder()
|
||||
.url(httpUrl)
|
||||
.get()
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return true
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return false
|
||||
}
|
||||
val contentType = response.header("Content-Type").orEmpty()
|
||||
val body = response.body.string().take(2_048)
|
||||
expectedBody(body, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
|
||||
val lower = body.lowercase()
|
||||
return contentType.contains("json", ignoreCase = true) && (
|
||||
lower.contains("auth_required") ||
|
||||
lower.contains("auth_providers") ||
|
||||
lower.contains("authenticated") ||
|
||||
lower.contains("hermes")
|
||||
)
|
||||
}
|
||||
|
||||
private fun looksLikeApiHealth(body: String, contentType: String): Boolean {
|
||||
if (contentType.contains("json", ignoreCase = true)) return true
|
||||
if (contentType.contains("text/plain", ignoreCase = true)) return true
|
||||
return body.isBlank() || body.trimStart().startsWith("{")
|
||||
}
|
||||
|
||||
private fun localLanHosts(context: Context): List<String> {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
?: return emptyList()
|
||||
val networks = buildList {
|
||||
connectivityManager.activeNetwork?.let(::add)
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
if (!contains(network)) add(network)
|
||||
}
|
||||
}
|
||||
|
||||
val hosts = linkedSetOf<String>()
|
||||
for (network in networks) {
|
||||
val linkProperties = connectivityManager.getLinkProperties(network) ?: continue
|
||||
for (linkAddress in linkProperties.linkAddresses) {
|
||||
addHostsForLink(linkAddress, hosts)
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
return hosts.take(MAX_HOSTS)
|
||||
}
|
||||
|
||||
private fun addHostsForLink(linkAddress: LinkAddress, hosts: MutableSet<String>) {
|
||||
val address = linkAddress.address as? Inet4Address ?: return
|
||||
if (address.isLoopbackAddress || address.isMulticastAddress) return
|
||||
|
||||
val local = ipv4ToLong(address)
|
||||
if (!isScannableLanAddress(local)) return
|
||||
|
||||
val scanPrefix = when (linkAddress.prefixLength) {
|
||||
in 24..30 -> linkAddress.prefixLength
|
||||
else -> 24
|
||||
}
|
||||
val mask = subnetMask(scanPrefix)
|
||||
val network = local and mask
|
||||
val broadcast = network or (mask.inv() and IPV4_MASK)
|
||||
val first = network + 1
|
||||
val last = broadcast - 1
|
||||
if (first > last) return
|
||||
|
||||
for (candidate in first..last) {
|
||||
if (candidate == local) continue
|
||||
hosts.add(longToIpv4(candidate))
|
||||
if (hosts.size >= MAX_HOSTS) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun subnetMask(prefixLength: Int): Long {
|
||||
return (IPV4_MASK shl (32 - prefixLength)) and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun ipv4ToLong(address: Inet4Address): Long {
|
||||
return address.address.fold(0L) { acc, byte ->
|
||||
(acc shl 8) or (byte.toInt() and 0xFF).toLong()
|
||||
} and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun longToIpv4(value: Long): String {
|
||||
return listOf(
|
||||
(value shr 24) and 0xFF,
|
||||
(value shr 16) and 0xFF,
|
||||
(value shr 8) and 0xFF,
|
||||
value and 0xFF,
|
||||
).joinToString(".") { it.toString() }
|
||||
}
|
||||
|
||||
private fun isScannableLanAddress(value: Long): Boolean {
|
||||
val first = ((value shr 24) and 0xFF).toInt()
|
||||
val second = ((value shr 16) and 0xFF).toInt()
|
||||
return when {
|
||||
first == 10 -> true
|
||||
first == 172 && second in 16..31 -> true
|
||||
first == 192 && second == 168 -> true
|
||||
first == 169 && second == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
@@ -579,7 +582,10 @@ class RelayHttpClient(
|
||||
* human-readable message on any failure (network, non-200, bad body,
|
||||
* doesn't-look-like-hermes-relay).
|
||||
*/
|
||||
suspend fun probeHealth(relayUrl: String): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
suspend fun probeHealth(
|
||||
relayUrl: String,
|
||||
logSuccess: Boolean = true,
|
||||
): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
val trimmed = relayUrl.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
@@ -591,10 +597,18 @@ class RelayHttpClient(
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val startedAtMs = System.currentTimeMillis()
|
||||
|
||||
val url = try {
|
||||
"$httpBase/health".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay URL invalid",
|
||||
detail = e.message,
|
||||
url = relayUrl,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
@@ -606,6 +620,7 @@ class RelayHttpClient(
|
||||
.connectTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
@@ -617,12 +632,28 @@ class RelayHttpClient(
|
||||
try {
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "HTTP ${response.code}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay responded HTTP ${response.code}")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Empty response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
@@ -632,18 +663,42 @@ class RelayHttpClient(
|
||||
val parsed: Map<String, kotlinx.serialization.json.JsonElement> = try {
|
||||
sessionsJson.parseToJsonElement(body).jsonObject
|
||||
} catch (e: Exception) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Non-JSON response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
val status = (parsed["status"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (status != "ok") {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "status=${status ?: "missing"}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
|
||||
)
|
||||
}
|
||||
val version = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (version.isNullOrBlank()) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Missing version field",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
|
||||
)
|
||||
@@ -652,19 +707,61 @@ class RelayHttpClient(
|
||||
?.content?.toIntOrNull() ?: 0
|
||||
val sessions = (parsed["sessions"] as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.content?.toIntOrNull() ?: 0
|
||||
if (logSuccess) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay health ok",
|
||||
detail = "version=$version clients=$clients sessions=$sessions",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
}
|
||||
Result.success(RelayHealth(version = version, clients = clients, sessions = sessions))
|
||||
}
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
Log.w(TAG, "probeHealth timeout: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health timeout",
|
||||
detail = "No HTTP response in 3s",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Relay is not responding (3s timeout)"))
|
||||
} catch (e: java.net.ConnectException) {
|
||||
Log.w(TAG, "probeHealth connect refused: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay connection refused",
|
||||
detail = e.message,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "probeHealth IO error: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = e.message ?: "Network error",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "probeHealth unexpected error: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay health failed",
|
||||
detail = e.message ?: e.javaClass.simpleName,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface VoiceAudioClient {
|
||||
val route: VoiceAudioRoute
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
}
|
||||
|
||||
class RelayVoiceAudioClientAdapter(
|
||||
private val relayVoiceClient: RelayVoiceClient,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Relay
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
relayVoiceClient.transcribe(audioFile)
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
relayVoiceClient.synthesize(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes each STT/TTS call to the Standard (dashboard) or Relay voice client.
|
||||
*
|
||||
* Auto preference order is **Relay first, then Standard**: a paired Relay is
|
||||
* the purpose-built mobile facade — profile-aware voice config, no dashboard
|
||||
* sign-in dependency — so users who installed the plugin keep the richer
|
||||
* path. Standard is the zero-plugin route for vanilla Hermes installs and is
|
||||
* used whenever Relay isn't configured/paired (or fails mid-call). Power
|
||||
* users can force either route in Voice Settings.
|
||||
*/
|
||||
class AutoVoiceAudioClient(
|
||||
private val standardClient: VoiceAudioClient,
|
||||
private val relayClient: VoiceAudioClient,
|
||||
private val routeProvider: () -> VoiceAudioRoute,
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
runWithSelectedRoute { it.transcribe(audioFile) }
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
IllegalStateException(
|
||||
"Standard Hermes voice is not available — check dashboard sign-in in Manage",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
block(standardClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Relay -> {
|
||||
if (!relayReadyProvider()) {
|
||||
Result.failure(IllegalStateException("Relay voice is not available"))
|
||||
} else {
|
||||
block(relayClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Auto -> runAuto(block)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> runAuto(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
var relayFailure: Result<T>? = null
|
||||
if (relayReadyProvider()) {
|
||||
val result = block(relayClient)
|
||||
if (result.isSuccess || !standardReadyProvider()) return result
|
||||
relayFailure = result
|
||||
}
|
||||
if (standardReadyProvider()) {
|
||||
val result = block(standardClient)
|
||||
if (result.isSuccess) return result
|
||||
return relayFailure ?: result
|
||||
}
|
||||
return relayFailure ?: Result.failure(
|
||||
IllegalStateException("Voice needs a reachable Hermes dashboard or Relay voice route"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard (no-plugin) voice client — speaks the upstream **dashboard web
|
||||
* server** contract that hermes-desktop's voice mode uses:
|
||||
*
|
||||
* POST {dashboard}/api/audio/transcribe {data_url, mime_type} → {ok, transcript}
|
||||
* POST {dashboard}/api/audio/speak {text} → {ok, data_url, mime_type}
|
||||
*
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md §6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
},
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
|
||||
val dataUrl = buildAudioDataUrl(audioFile)
|
||||
val payload = buildJsonObject {
|
||||
put("data_url", dataUrl)
|
||||
put("mime_type", mediaTypeForAudioFile(audioFile))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/transcribe")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
if (transcript.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio transcribe returned an empty transcript")
|
||||
}
|
||||
transcript
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
}
|
||||
|
||||
val payload = buildJsonObject { put("text", cleanText) }
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/speak")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
}
|
||||
val mimeType = root.stringField("mime_type")
|
||||
?: root.stringField("mimeType")
|
||||
?: mimeTypeFromDataUrl(dataUrl)
|
||||
?: "audio/mpeg"
|
||||
val bytes = decodeDataUrl(dataUrl)
|
||||
if (bytes.isEmpty()) throw IOException("Hermes audio speak returned empty audio")
|
||||
|
||||
val extension = extensionForMimeType(mimeType)
|
||||
File(context.cacheDir, "hermes_voice_${System.currentTimeMillis()}.$extension")
|
||||
.also { it.writeBytes(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return Result.failure(IOException("$operation returned an empty response"))
|
||||
}
|
||||
val root = json.decodeFromString<JsonObject>(body)
|
||||
val ok = (root["ok"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toBooleanStrictOrNull()
|
||||
if (ok == false) {
|
||||
val message = root.stringField("message")
|
||||
?: root.stringField("error")
|
||||
?: "$operation failed"
|
||||
return Result.failure(IOException(message))
|
||||
}
|
||||
Result.success(root)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "network error"}", e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "parse error"}", e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val body = runCatching { response.body.string() }.getOrDefault("")
|
||||
val detail = body.takeIf { it.isNotBlank() } ?: response.message
|
||||
val message = when (response.code) {
|
||||
401, 403 -> "$operation needs dashboard sign-in - open Manage to sign in"
|
||||
404 -> "$operation unavailable on this Hermes build - update hermes-agent or use Relay"
|
||||
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
|
||||
else -> "$operation failed - HTTP ${response.code}: $detail"
|
||||
}
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun buildAudioDataUrl(audioFile: File): String {
|
||||
val mimeType = mediaTypeForAudioFile(audioFile)
|
||||
val encoded = Base64.getEncoder().encodeToString(audioFile.readBytes())
|
||||
return "data:$mimeType;base64,$encoded"
|
||||
}
|
||||
|
||||
private fun mediaTypeForAudioFile(file: File): String =
|
||||
when (file.extension.lowercase()) {
|
||||
"wav" -> "audio/wav"
|
||||
"m4a", "mp4" -> "audio/mp4"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"ogg" -> "audio/ogg"
|
||||
"webm" -> "audio/webm"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
private fun decodeDataUrl(dataUrl: String): ByteArray {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
val payload = if (comma >= 0) dataUrl.substring(comma + 1) else dataUrl
|
||||
return Base64.getDecoder().decode(payload)
|
||||
}
|
||||
|
||||
private fun mimeTypeFromDataUrl(dataUrl: String): String? {
|
||||
if (!dataUrl.startsWith("data:", ignoreCase = true)) return null
|
||||
val semi = dataUrl.indexOf(';')
|
||||
if (semi <= "data:".length) return null
|
||||
return dataUrl.substring("data:".length, semi).takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun extensionForMimeType(mimeType: String): String =
|
||||
when (mimeType.lowercase().substringBefore(';')) {
|
||||
"audio/wav", "audio/wave", "audio/x-wav" -> "wav"
|
||||
"audio/mp4", "audio/aac", "audio/m4a" -> "m4a"
|
||||
"audio/ogg" -> "ogg"
|
||||
"audio/webm" -> "webm"
|
||||
else -> "mp3"
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
val JSON_MEDIA = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
+32
-59
@@ -127,13 +127,16 @@ import kotlinx.serialization.json.contentOrNull
|
||||
* contract documented on the Python `android_tap` / `android_scroll` tools.
|
||||
* A non-resolvable nodeId returns a 404-style error envelope.
|
||||
*
|
||||
* # Master enable gate
|
||||
* # Device Control gate
|
||||
*
|
||||
* Before dispatching any action we check
|
||||
* The Google Play flavor ships Bridge Core without AccessibilityService or
|
||||
* Device Control. It answers harmless bridge liveness/status probes above
|
||||
* the dispatch layer, but any command that reaches Device Control fails closed
|
||||
* before touching [HermesAccessibilityService]. The sideload flavor then checks
|
||||
* [HermesAccessibilityService.instance] — if the user hasn't enabled the
|
||||
* service in Android Settings, we fail fast with status 503. If the
|
||||
* service is running but the soft master toggle is off we fail with 403
|
||||
* and a body explaining that Bridge is disabled in the app.
|
||||
* service in Android Settings, we fail fast with status 503. If the service is
|
||||
* running but the soft master toggle is off we fail with 403 and a body
|
||||
* explaining that Bridge is disabled in the app.
|
||||
*/
|
||||
class BridgeCommandHandler(
|
||||
private val multiplexer: ChannelMultiplexer,
|
||||
@@ -528,6 +531,28 @@ class BridgeCommandHandler(
|
||||
return
|
||||
}
|
||||
|
||||
if (!BuildFlavor.isSideload) {
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put(
|
||||
"error",
|
||||
"Device Control is not included in the Google Play build " +
|
||||
"of Hermes Relay. This build keeps Hermes Bridge Core " +
|
||||
"features such as chat, voice, terminal, media, " +
|
||||
"notifications, and relay status, but it does not " +
|
||||
"ship AccessibilityService, screen reading, taps, " +
|
||||
"typing, screenshots, SMS, calls, or unattended " +
|
||||
"phone control. Install the sideload build for " +
|
||||
"Device Control.",
|
||||
)
|
||||
put("error_code", "device_control_sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val service = HermesAccessibilityService.instance
|
||||
?: return respond(
|
||||
requestId, 503,
|
||||
@@ -676,57 +701,6 @@ class BridgeCommandHandler(
|
||||
}
|
||||
// === END v0.4.1 unattended-access ===
|
||||
|
||||
// === Google Play flavor route gate ===
|
||||
// The googlePlay build's AccessibilityService config declares a
|
||||
// narrow use case ("read notifications, summarize messages") with
|
||||
// NO gesture dispatch (canPerformGestures is absent) and NO
|
||||
// flagRetrieveInteractiveWindows. Only READ-ONLY routes that
|
||||
// match this declared scope are whitelisted; everything else
|
||||
// returns a 403 so reviewers tracing the code see a capability
|
||||
// surface that matches the manifest declaration.
|
||||
//
|
||||
// The whitelist is FAIL-CLOSED: any new route we add to the when
|
||||
// block below defaults to sideload-only on the Play flavor unless
|
||||
// explicitly added here. This prevents future routes from
|
||||
// accidentally widening the Play APK's capability surface.
|
||||
//
|
||||
// Early-return routes (/ping, /events, /setup) are above this
|
||||
// point so they work on both flavors — they're harmless liveness
|
||||
// probes and don't need the a11y service. /return_to_hermes is
|
||||
// whitelisted because it only foregrounds our OWN app (not a
|
||||
// phone-control action). /clipboard is whitelisted for GET
|
||||
// (read-only); POST (write) is gated inside the /clipboard case.
|
||||
if (!BuildFlavor.isSideload) {
|
||||
val playAllowed = setOf(
|
||||
"/current_app",
|
||||
"/screen",
|
||||
"/get_apps",
|
||||
"/apps",
|
||||
"/clipboard",
|
||||
"/return_to_hermes",
|
||||
)
|
||||
if (path !in playAllowed) {
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put(
|
||||
"error",
|
||||
"This bridge route ($path) is only available on the " +
|
||||
"sideload flavor of Hermes Relay. The Google Play " +
|
||||
"build supports read-only bridge operations (screen " +
|
||||
"reading, app status, clipboard read) but not " +
|
||||
"phone-control actions (tap, type, swipe, SMS, call). " +
|
||||
"Install the sideload APK for full phone control.",
|
||||
)
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// === END Google Play flavor route gate ===
|
||||
|
||||
val executor = service.actionExecutor
|
||||
|
||||
when (path) {
|
||||
@@ -843,9 +817,8 @@ class BridgeCommandHandler(
|
||||
// server-side agent as the final step of any multi-app task
|
||||
// (e.g. after driving Messages to send an SMS) so the user
|
||||
// sees the agent's reply in-context without manually switching
|
||||
// apps. The phone knows its own package name via service — no
|
||||
// parameter needed, works transparently on both sideload and
|
||||
// googlePlay flavors.
|
||||
// apps. The sideload phone knows its own package name via the
|
||||
// accessibility service, so no parameter is needed.
|
||||
//
|
||||
// Allowed even when the master toggle is off: returning focus
|
||||
// to our own app isn't a destructive action, and this tool
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
@@ -199,6 +200,18 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceMessageContent(messageId: String, content: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { message ->
|
||||
if (message.id == messageId) {
|
||||
message.copy(content = content)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a local-only voice-intent trace to the chat scroll. Used by
|
||||
* the sideload voice intent flow (`RealVoiceBridgeIntentHandler`) so
|
||||
@@ -371,6 +384,35 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun attachRealtimeTurnTrace(messageId: String, trace: RealtimeTurnTrace) {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
changed = true
|
||||
msg.copy(realtimeTurn = trace)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
if (changed) mapped else messages
|
||||
}
|
||||
}
|
||||
|
||||
fun markRealtimeTurnsSynced() {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
val trace = msg.realtimeTurn
|
||||
if (trace != null && !trace.syncedToServer) {
|
||||
changed = true
|
||||
msg.copy(realtimeTurn = trace.copy(syncedToServer = true))
|
||||
} else msg
|
||||
}
|
||||
if (changed) mapped else messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable lenient JSON parser for tool-result previews. [Json { ... }]
|
||||
* is cheap to construct but we share one instance so per-tool-completion
|
||||
@@ -1557,7 +1599,30 @@ class ChatHandler {
|
||||
return null
|
||||
}
|
||||
|
||||
fun onToolCallStart(messageId: String, toolCallId: String, toolName: String) {
|
||||
fun setMessageBadges(messageId: String, badges: List<String>) {
|
||||
val cleaned = badges
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(4)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = cleaned)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallStart(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
toolName: String,
|
||||
runId: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
_isStreaming.value = true
|
||||
|
||||
val toolCall = ToolCall(
|
||||
@@ -1566,7 +1631,9 @@ class ChatHandler {
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false
|
||||
isComplete = false,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
)
|
||||
|
||||
_messages.update { messages ->
|
||||
@@ -1595,7 +1662,12 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallComplete(messageId: String, toolCallId: String, resultPreview: String? = null) {
|
||||
fun onToolCallComplete(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
resultPreview: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
// Snapshot the matching tool call's name BEFORE mutating — we need it
|
||||
// to decide whether to emit a phone-action result bubble below.
|
||||
val toolName = _messages.value
|
||||
@@ -1613,6 +1685,7 @@ class ChatHandler {
|
||||
success = true,
|
||||
isComplete = true,
|
||||
result = resultPreview ?: call.result,
|
||||
provenance = provenance ?: call.provenance,
|
||||
completedAt = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -81,6 +81,7 @@ object FlexibleIdNonNullSerializer : KSerializer<String> {
|
||||
data class SessionListResponse(
|
||||
val items: List<SessionItem>? = null,
|
||||
val sessions: List<SessionItem>? = null, // alternate key
|
||||
val data: List<SessionItem>? = null, // upstream /api/sessions list envelope
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@@ -127,6 +128,7 @@ data class RenameSessionRequest(
|
||||
data class MessageListResponse(
|
||||
val items: List<MessageItem>? = null,
|
||||
val messages: List<MessageItem>? = null, // alternate key
|
||||
val data: List<MessageItem>? = null, // upstream /api/sessions/{id}/messages list envelope
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@@ -300,5 +302,6 @@ data class SkillInfo(
|
||||
@Serializable
|
||||
data class SkillListResponse(
|
||||
val skills: List<SkillInfo>? = null,
|
||||
val items: List<SkillInfo>? = null
|
||||
val items: List<SkillInfo>? = null,
|
||||
val data: List<SkillInfo>? = null
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+97
-68
@@ -94,30 +94,21 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
|
||||
/**
|
||||
* Three tappable status rows (API / Relay / Session), always visible on
|
||||
* the active card. Replaces the old "Active Connection" quick-look card
|
||||
* that used to live at the top of `SettingsScreen` — same information
|
||||
* density, same tap-for-info-sheet behavior.
|
||||
*
|
||||
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
|
||||
* reconnect + toast; every other row falls through to the info sheet
|
||||
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
|
||||
* Standard Hermes status rows (API / Dashboard). Dashboard auth is surfaced
|
||||
* here so users do not have to open Manage just to discover sign-in is needed.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardStatusSection(
|
||||
fun ActiveCardStandardStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
@@ -132,44 +123,74 @@ fun ActiveCardStatusSection(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
// ADR 24: relayRowState carries both the phase and the active
|
||||
// endpoint role. statusText appends " · <Role>" when the
|
||||
// resolver has picked one, so the chip reads "Connected · LAN"
|
||||
// etc. without any extra wiring here.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ConnectionStatusRow(
|
||||
label = "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"
|
||||
},
|
||||
onClick = onOpenDashboard,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "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}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Optional Relay status rows (transport / paired session). Kept separate from
|
||||
* [ActiveCardStandardStatusSection] so API/dashboard setup does not visually
|
||||
* read as incomplete when Relay is not paired.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardRelayStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
// ADR 24: relayRowState carries both the phase and the active endpoint
|
||||
// role. statusText appends " · <Role>" when the resolver has picked one.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "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}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,7 +347,7 @@ private fun ManualUrlSubsection(
|
||||
) { result ->
|
||||
isTestingApi = false
|
||||
apiVoiceSetupResult = result
|
||||
if (!result.voiceConfigReachable && result.relayAutoDerived) {
|
||||
if (!result.voiceConfigReachable && result.voiceRoute == "relay" && result.relayAutoDerived) {
|
||||
relayOverrideVisible = true
|
||||
result.relayUrl?.let { relayUrlInput = it }
|
||||
}
|
||||
@@ -334,9 +355,13 @@ private fun ManualUrlSubsection(
|
||||
context,
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
"API and voice relay reachable"
|
||||
if (result.voiceRoute == "standard") {
|
||||
"API and standard voice reachable"
|
||||
} else {
|
||||
"API and relay voice reachable"
|
||||
}
|
||||
result.apiReachable ->
|
||||
"API reachable; relay URL needs review"
|
||||
"API reachable; voice route needs review"
|
||||
else -> "Cannot reach API server"
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
@@ -373,7 +398,7 @@ private fun ManualUrlSubsection(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Voice uses the relay's /voice routes. The app derives this from the API host unless a custom route is needed.",
|
||||
text = "Relay is optional for voice. Standard voice uses the Hermes API; Relay voice uses this route when selected or needed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -403,7 +428,7 @@ private fun ManualUrlSubsection(
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text("Only needed when Auto cannot reach /voice/config")
|
||||
Text("Only needed when the optional Relay route cannot be auto-derived")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -417,9 +442,13 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
Text(
|
||||
text = if (result.voiceConfigReachable) {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
if (result.voiceRoute == "standard") {
|
||||
"Voice ready via standard Hermes API"
|
||||
} else {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
}
|
||||
} else {
|
||||
"Relay URL required: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
@@ -574,7 +603,7 @@ private fun InsecureToggleSubsection(
|
||||
* the QR scanner isn't usable (no camera, headless host, bad lighting).
|
||||
*
|
||||
* 1. Copy the phone-generated code (with Refresh to regenerate)
|
||||
* 2. Run `hermes-pair --register-code <code>` on the host
|
||||
* 2. Run `hermes pair --register-code <code>` on the host
|
||||
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
|
||||
* failure through the global snackbar host
|
||||
*
|
||||
@@ -696,7 +725,7 @@ private fun ManualPairingCodeSubsection(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "hermes-pair --register-code $pairingCode",
|
||||
text = "hermes pair --register-code $pairingCode",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
@@ -705,10 +734,10 @@ private fun ManualPairingCodeSubsection(
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val cmd = "hermes-pair --register-code $pairingCode"
|
||||
val cmd = "hermes pair --register-code $pairingCode"
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
|
||||
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
|
||||
)
|
||||
snackbarHost.showSnackbar("Command copied")
|
||||
}
|
||||
@@ -717,7 +746,7 @@ private fun ManualPairingCodeSubsection(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy hermes-pair command",
|
||||
contentDescription = "Copy hermes pair command",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -783,9 +812,9 @@ private fun ManualPairingCodeSubsection(
|
||||
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" +
|
||||
"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` " +
|
||||
"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" +
|
||||
|
||||
@@ -44,7 +44,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Visual style mirrors the status
|
||||
* cards in `PairedDevicesScreen`: surfaceVariant background, 16dp padding,
|
||||
* 10dp row spacing. Uses [ConnectionStatusBadge] for the pulsing status dot
|
||||
* so the Bridge tab looks visually consistent with the Settings → Connection
|
||||
* so the Bridge tab looks visually consistent with the Settings → Connections
|
||||
* section.
|
||||
*
|
||||
* The headline switch is `enabled = allowEnable` so users can't flip it on
|
||||
|
||||
@@ -28,7 +28,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Kept distinct from
|
||||
* [BridgeMasterToggle] so that Agent safety-rails in Wave 2 can relocate the master
|
||||
* toggle without losing the status surface (and so we can reuse this card
|
||||
* in the Settings → Connection section later if desired).
|
||||
* in the Settings → Connections section later if desired).
|
||||
*/
|
||||
@Composable
|
||||
fun BridgeStatusCard(
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusSnapshot
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
|
||||
import com.hermesandroid.relay.viewmodel.asConnectionStatusSnapshot
|
||||
|
||||
@Composable
|
||||
fun ConnectionHandoffBanner(
|
||||
status: ConnectionHandoffStatus?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
) {
|
||||
ConnectionStatusBanner(
|
||||
status = status?.asConnectionStatusSnapshot(),
|
||||
modifier = modifier,
|
||||
includeStatusBarPadding = includeStatusBarPadding,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectionStatusBanner(
|
||||
status: ConnectionStatusSnapshot?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.86f)
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.62f)
|
||||
current.success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
|
||||
current.active -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f)
|
||||
}
|
||||
val contentColor = when {
|
||||
current.tone == ConnectionStatusTone.Error ||
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
current.success -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
current.active -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val insetModifier = if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f))
|
||||
.then(insetModifier)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.animateContentSize(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 34.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
current.active -> PulsingSyncIcon(contentColor)
|
||||
current.success -> Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
current.tone == ConnectionStatusTone.Warning ||
|
||||
current.tone == ConnectionStatusTone.Error -> Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = current.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
current.route?.takeIf { it.isNotBlank() }?.let { route ->
|
||||
Text(
|
||||
text = route,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.86f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val outputLines = current.entries
|
||||
.takeLast(2)
|
||||
.mapNotNull { entry ->
|
||||
val label = entry.label.trim().takeIf { it.isNotBlank() }
|
||||
val detail = entry.detail?.trim()?.takeIf { it.isNotBlank() }
|
||||
when {
|
||||
label != null && detail != null -> "$label: $detail"
|
||||
label != null -> label
|
||||
detail != null -> detail
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
outputLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current.active) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 2.dp, max = 2.dp),
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
trackColor = contentColor.copy(alpha = 0.16f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulsingSyncIcon(color: androidx.compose.ui.graphics.Color) {
|
||||
val infinite = rememberInfiniteTransition(label = "connection-handoff-pulse")
|
||||
val alpha by infinite.animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 900),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "connection-handoff-alpha",
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.alpha(alpha),
|
||||
)
|
||||
}
|
||||
@@ -64,13 +64,13 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.network.ChatMode
|
||||
import com.hermesandroid.relay.network.ConnectionState
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
@@ -218,6 +218,7 @@ fun SessionInfoSheet(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
@@ -349,6 +350,17 @@ fun SessionInfoSheet(
|
||||
Text("Regenerate Code")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
DiagnosticsLogPanel(
|
||||
categories = setOf(
|
||||
DiagnosticCategory.Session,
|
||||
DiagnosticCategory.Auth,
|
||||
DiagnosticCategory.Relay,
|
||||
),
|
||||
limit = 6,
|
||||
showCategory = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,6 +395,7 @@ fun ApiServerInfoSheet(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
@@ -455,6 +468,17 @@ fun ApiServerInfoSheet(
|
||||
) {
|
||||
Text(if (testing) "Testing\u2026" else "Test connection")
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
DiagnosticsLogPanel(
|
||||
categories = setOf(
|
||||
DiagnosticCategory.Api,
|
||||
DiagnosticCategory.Auth,
|
||||
DiagnosticCategory.Endpoint,
|
||||
),
|
||||
limit = 6,
|
||||
showCategory = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,6 +509,7 @@ fun RelayInfoSheet(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
@@ -563,6 +588,18 @@ fun RelayInfoSheet(
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
DiagnosticsLogPanel(
|
||||
categories = setOf(
|
||||
DiagnosticCategory.Relay,
|
||||
DiagnosticCategory.Endpoint,
|
||||
DiagnosticCategory.Session,
|
||||
DiagnosticCategory.Voice,
|
||||
),
|
||||
limit = 8,
|
||||
showCategory = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,6 +627,7 @@ fun AgentInfoSheet(
|
||||
chatViewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
@@ -704,7 +742,7 @@ fun AgentInfoSheet(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SectionLabel(
|
||||
title = "Profile",
|
||||
hint = "Overlay an agent's model + SOUL",
|
||||
hint = "Host-side Hermes contexts",
|
||||
)
|
||||
|
||||
val defaultDotColor = serverDefaultProfile?.let { profile ->
|
||||
@@ -831,6 +869,13 @@ fun AgentInfoSheet(
|
||||
append("profile: ")
|
||||
append(profile.name)
|
||||
}
|
||||
if (profile.hasIsolatedApi) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("isolated API")
|
||||
} else {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("compatibility overlay")
|
||||
}
|
||||
if (isApparentActive && selectedProfile == null) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("This is the server's active profile")
|
||||
@@ -847,6 +892,19 @@ fun AgentInfoSheet(
|
||||
leadingDotContentDescription = dotA11y,
|
||||
secondaryTrailing = if (profile.hasSoul || profile.skillCount > 0) {
|
||||
{
|
||||
ProfileMetadataBadge(
|
||||
text = if (profile.hasIsolatedApi) "API" else "Overlay",
|
||||
background = if (profile.hasIsolatedApi) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (profile.hasIsolatedApi) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (profile.skillCount > 0) {
|
||||
ProfileMetadataBadge(
|
||||
text = "${profile.skillCount} skills",
|
||||
@@ -867,7 +925,9 @@ fun AgentInfoSheet(
|
||||
if (selectedProfile?.name != profile.name) {
|
||||
connectionViewModel.selectProfile(profile)
|
||||
val display = primaryLabel
|
||||
val suffix = if (profile.systemMessage?.isNotBlank() == true) {
|
||||
val suffix = if (profile.hasIsolatedApi) {
|
||||
" — profile API active"
|
||||
} else if (profile.systemMessage?.isNotBlank() == true) {
|
||||
" — model + SOUL applied"
|
||||
} else {
|
||||
" — model applied"
|
||||
@@ -878,6 +938,21 @@ fun AgentInfoSheet(
|
||||
)
|
||||
}
|
||||
|
||||
val inspectorTarget = selectedProfile
|
||||
?: serverDefaultProfile
|
||||
?: selectableProfiles.firstOrNull()
|
||||
inspectorTarget?.let { profile ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNavigateToProfileInspector(profile.name)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Inspect ${AgentDisplay.profileDisplayName(profile) ?: profile.name}")
|
||||
}
|
||||
}
|
||||
|
||||
if (profileOverridesPersonality) {
|
||||
Text(
|
||||
text = "This profile's system message overrides the personality below.",
|
||||
@@ -1028,7 +1103,7 @@ fun AgentInfoSheet(
|
||||
val hostname = com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = when {
|
||||
connection.pairedAt == null -> "$hostname • Not paired"
|
||||
connection.pairedAt == null -> "$hostname • Standard"
|
||||
else -> "$hostname • Paired"
|
||||
}
|
||||
ProfileRadioRow(
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ private fun ConnectionRow(
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = if (connection.pairedAt == null) {
|
||||
"$hostname • Not paired"
|
||||
"$hostname • Standard"
|
||||
} else {
|
||||
"$hostname • Paired"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.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.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
|
||||
@Composable
|
||||
fun DiagnosticsLogPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Recent activity",
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 8,
|
||||
showCategory: Boolean = false,
|
||||
showClear: Boolean = false,
|
||||
) {
|
||||
val entries by DiagnosticsLog.entries.collectAsState()
|
||||
val visible = entries
|
||||
.asReversed()
|
||||
.filter { categories == null || it.category in categories }
|
||||
.take(limit.coerceAtLeast(0))
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (showClear && entries.isNotEmpty()) {
|
||||
TextButton(onClick = { DiagnosticsLog.clear() }) {
|
||||
Text("Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
Text(
|
||||
text = "No recent activity",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
visible.forEachIndexed { index, entry ->
|
||||
DiagnosticLogRow(
|
||||
entry = entry,
|
||||
showCategory = showCategory,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
)
|
||||
if (index != visible.lastIndex) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticLogRow(
|
||||
entry: DiagnosticLogEntry,
|
||||
showCategory: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(severityColor(entry.severity)),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (showCategory) {
|
||||
"${entry.category.label} - ${entry.title}"
|
||||
} else {
|
||||
entry.title
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = DateFormat.format("HH:mm:ss", entry.timestampMs).toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
val detail = entry.detailLine()
|
||||
if (detail != null) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun severityColor(severity: DiagnosticSeverity): Color = when (severity) {
|
||||
DiagnosticSeverity.Info -> MaterialTheme.colorScheme.primary
|
||||
DiagnosticSeverity.Warning -> MaterialTheme.colorScheme.tertiary
|
||||
DiagnosticSeverity.Error -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
private fun DiagnosticLogEntry.detailLine(): String? {
|
||||
val pieces = listOfNotNull(
|
||||
detail,
|
||||
endpointRole?.let { "route=$it" },
|
||||
url,
|
||||
elapsedMs?.let { "${it}ms" },
|
||||
)
|
||||
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
|
||||
}
|
||||
@@ -19,15 +19,19 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -42,9 +46,11 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.network.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -52,8 +58,14 @@ import kotlinx.coroutines.launch
|
||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
||||
* settings screen. Renders one row per [EndpointCandidate] stored for the
|
||||
* active device, with a health chip, a 3-dot menu (Prefer / Probe now /
|
||||
* View pin), and a bottom "Clear manual override" action when a preferred
|
||||
* role is set.
|
||||
* View pin), and bottom clear actions when a switch/preference is set.
|
||||
*
|
||||
* Two distinct route actions, deliberately separated:
|
||||
* - "Use now" (row button) — one-time switch. Holds until the next
|
||||
* disconnect, never persisted, cancelled by [onCancelUseNow].
|
||||
* - "Prefer this route" (3-dot menu) — sticky policy. Persisted, restored
|
||||
* on app start, tried first on every resolve; cleared by
|
||||
* [onClearPreferred].
|
||||
*
|
||||
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
|
||||
* do NOT collapse into a master row. The card itself is wrapped in a
|
||||
@@ -67,26 +79,70 @@ import kotlinx.coroutines.launch
|
||||
fun EndpointsCard(
|
||||
endpoints: List<EndpointCandidate>,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
/** Persisted sticky preference ([Connection.preferredRouteRole]). */
|
||||
preferredRole: String?,
|
||||
/**
|
||||
* Live transient override installed by "Use now" (or by preference
|
||||
* restoration — equal to [preferredRole] in that case). Drives the
|
||||
* automatic / preferred / manual annotation on the Current line.
|
||||
*/
|
||||
manualOverrideRole: String?,
|
||||
onUseNow: (EndpointCandidate) -> Unit,
|
||||
onCancelUseNow: () -> Unit,
|
||||
onPreferEndpoint: (EndpointCandidate) -> Unit,
|
||||
onClearOverride: () -> Unit,
|
||||
onClearPreferred: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
/** True while a user-triggered route probe is in flight. */
|
||||
isProbing: Boolean = false,
|
||||
/**
|
||||
* Last probe verdict for a route, or null when it has never been
|
||||
* probed. Lambda (not a map) so the card stays decoupled from the
|
||||
* resolver's cache-key scheme.
|
||||
*/
|
||||
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
|
||||
/**
|
||||
* Route management — the standard path's manual equivalent of a v3 QR's
|
||||
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
||||
* Edit/Remove only appear on fallback rows (priority > 0); the primary
|
||||
* row mirrors the connection's API URL and is edited there.
|
||||
*/
|
||||
onAddRoute: (() -> Unit)? = null,
|
||||
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
) {
|
||||
if (endpoints.isEmpty()) {
|
||||
Text(
|
||||
text = "No route candidates stored for this device yet. " +
|
||||
"Scan a v3 pairing QR (Hermes 0.4.2+) to enable multi-route " +
|
||||
"switching — LAN + Tailscale + public URLs.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (onAddRoute != null) {
|
||||
TextButton(onClick = onAddRoute) {
|
||||
Text("Add route")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A manual "Use now" switch is in effect when the live override differs
|
||||
// from the sticky preference (preference restoration writes the same
|
||||
// role into both, so equality means "preferred", not "manual").
|
||||
val manualSwitchActive = manualOverrideRole != null &&
|
||||
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}",
|
||||
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}" + when {
|
||||
manualSwitchActive -> " · manual (until disconnect)"
|
||||
manualOverrideRole != null -> " · preferred"
|
||||
else -> " · automatic"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -99,16 +155,39 @@ fun EndpointsCard(
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port,
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
isProbing = isProbing,
|
||||
outcome = outcomeFor(candidate),
|
||||
onUseNow = { onUseNow(candidate) },
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onClearPrefer = onClearPreferred,
|
||||
onProbeNow = onProbeNow,
|
||||
onViewPin = onViewPin,
|
||||
onEdit = onEditRoute?.takeIf { candidate.priority > 0 }
|
||||
?.let { edit -> { edit(candidate) } },
|
||||
onRemove = onRemoveRoute?.takeIf { candidate.priority > 0 }
|
||||
?.let { remove -> { remove(candidate) } },
|
||||
)
|
||||
}
|
||||
|
||||
if (onAddRoute != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Add route")
|
||||
}
|
||||
}
|
||||
|
||||
if (manualSwitchActive) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onCancelUseNow, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Cancel manual switch (back to ${preferredRole ?: "automatic"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (preferredRole != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onClearOverride, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Clear manual override (preferring $preferredRole)")
|
||||
TextButton(onClick = onClearPreferred, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Stop preferring $preferredRole (back to automatic)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,12 +201,19 @@ private fun EndpointRow(
|
||||
candidate: EndpointCandidate,
|
||||
isActive: Boolean,
|
||||
isPreferred: Boolean,
|
||||
isProbing: Boolean = false,
|
||||
outcome: RouteProbeOutcome? = null,
|
||||
onUseNow: () -> Unit,
|
||||
onPrefer: () -> Unit,
|
||||
onClearPrefer: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -174,21 +260,46 @@ private fun EndpointRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Full URL, scheme included: http vs https decides whether
|
||||
// the health probe TLS-handshakes, so two rows that both
|
||||
// read "host:8642" can behave completely differently. The
|
||||
// scheme must be visible to be debuggable.
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port}" +
|
||||
text = candidate.api.url +
|
||||
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
when {
|
||||
isProbing -> Text(
|
||||
text = "Checking…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
outcome == null -> Unit // never probed — say nothing
|
||||
outcome.reachable -> Text(
|
||||
text = "Reachable",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
else -> Text(
|
||||
text = "Unreachable — ${outcome.detail ?: "no detail"}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
// without needing to expand into a detail sheet. "View pin"
|
||||
// suspends to read CertPinStore, so we resolve it into a dialog
|
||||
// when the user taps it.
|
||||
//
|
||||
// "Use now" is the TRANSIENT switch (until disconnect); the
|
||||
// sticky "Prefer this route" lives in the menu below.
|
||||
if (!isActive) {
|
||||
TextButton(onClick = onPrefer) {
|
||||
TextButton(onClick = onUseNow) {
|
||||
Text("Use now")
|
||||
}
|
||||
}
|
||||
@@ -204,10 +315,25 @@ private fun EndpointRow(
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Prefer this route") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (isPreferred) "Stop preferring" else "Prefer this route",
|
||||
)
|
||||
Text(
|
||||
text = if (isPreferred) {
|
||||
"Back to automatic choice"
|
||||
} else {
|
||||
"Always try this route first"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onPrefer()
|
||||
if (isPreferred) onClearPrefer() else onPrefer()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
@@ -228,11 +354,54 @@ private fun EndpointRow(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (onEdit != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onEdit()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onRemove != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
confirmRemove = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRemove && onRemove != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRemove = false },
|
||||
title = { Text("Remove ${candidate.displayLabel()} route?") },
|
||||
text = {
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
|
||||
"probed as a fallback. You can add it back any time.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmRemove = false
|
||||
onRemove()
|
||||
},
|
||||
) { Text("Remove") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
@@ -328,3 +497,154 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
"public" -> Icons.Filled.Public
|
||||
else -> Icons.Filled.Shield
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit dialog for an extra fallback route — the manual counterpart of a
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
* set up LAN ↔ Tailscale roaming without the plugin.
|
||||
*
|
||||
* The relay URL is derived from the API URL (same `:8767` convention the
|
||||
* wizard uses); routes that need a custom relay URL still come from a QR.
|
||||
*
|
||||
* @param original null = add a new route; non-null = edit (pre-fills role +
|
||||
* URL, keeps the stored priority).
|
||||
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
|
||||
* receives a user-facing error string to render inline, or null on
|
||||
* success (the dialog then closes itself).
|
||||
*/
|
||||
@Composable
|
||||
fun RouteEditorDialog(
|
||||
original: EndpointCandidate?,
|
||||
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val knownRoles = listOf("tailscale", "public")
|
||||
var selectedRole by remember {
|
||||
mutableStateOf(
|
||||
when (original?.role?.lowercase()) {
|
||||
null -> "tailscale"
|
||||
in knownRoles -> original.role.lowercase()
|
||||
else -> CUSTOM_ROLE
|
||||
},
|
||||
)
|
||||
}
|
||||
var customRole by remember {
|
||||
mutableStateOf(
|
||||
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
|
||||
)
|
||||
}
|
||||
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
val effectiveRole = if (selectedRole == CUSTOM_ROLE) customRole else selectedRole
|
||||
val saveEnabled = !saving &&
|
||||
url.isNotBlank() &&
|
||||
(selectedRole != CUSTOM_ROLE || customRole.isNotBlank())
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(if (original == null) "Add route" else "Edit route") },
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = selectedRole == "tailscale",
|
||||
onClick = { selectedRole = "tailscale" },
|
||||
label = { Text("Tailscale") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == "public",
|
||||
onClick = { selectedRole = "public" },
|
||||
label = { Text("Public") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == CUSTOM_ROLE,
|
||||
onClick = { selectedRole = CUSTOM_ROLE },
|
||||
label = { Text("Custom") },
|
||||
)
|
||||
}
|
||||
if (selectedRole == CUSTOM_ROLE) {
|
||||
OutlinedTextField(
|
||||
value = customRole,
|
||||
onValueChange = { customRole = it },
|
||||
label = { Text("Route name") },
|
||||
placeholder = { Text("wireguard-home") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
// Live preview of what will actually be saved — scheme and
|
||||
// port defaults applied — so "what, which port, http or
|
||||
// https?" is answered before Save, not after a failed probe.
|
||||
val previewCandidate = remember(url, effectiveRole) {
|
||||
url.takeIf { it.isNotBlank() }?.let {
|
||||
Connection.endpointCandidateFromApiUrl(
|
||||
role = effectiveRole.ifBlank { "custom" },
|
||||
priority = original?.priority ?: 1,
|
||||
apiServerUrl = Connection.normalizeApiUrlInput(it),
|
||||
relayUrl = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
url = it
|
||||
errorText = null
|
||||
},
|
||||
label = { Text("API server URL or host") },
|
||||
placeholder = { Text("100.71.8.56 or http://host:8642") },
|
||||
singleLine = true,
|
||||
isError = errorText != null,
|
||||
supportingText = {
|
||||
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://."
|
||||
previewCandidate != null ->
|
||||
"Will save: ${previewCandidate.api.url} — relay " +
|
||||
"and dashboard URLs are derived from the host"
|
||||
else ->
|
||||
"Enter a host/IP or an http(s):// URL " +
|
||||
"(API port 8642, not dashboard 9119)"
|
||||
},
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = saveEnabled,
|
||||
onClick = {
|
||||
saving = true
|
||||
onSave(effectiveRole, url) { error ->
|
||||
saving = false
|
||||
if (error == null) {
|
||||
onDismiss()
|
||||
} else {
|
||||
errorText = error
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val CUSTOM_ROLE = "__custom__"
|
||||
|
||||
@@ -27,13 +27,19 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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.graphics.Color
|
||||
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 androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -55,6 +61,11 @@ fun MessageBubble(
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
/**
|
||||
* Quote this message into the input field. Null hides the Quote entry in
|
||||
* the long-press menu, so legacy call sites keep the copy-only behavior.
|
||||
*/
|
||||
onQuoteMessage: ((String) -> Unit)? = null,
|
||||
/**
|
||||
* Invoked when the user taps a FAILED inbound attachment card.
|
||||
* `attachmentIndex` is the position in [ChatMessage.attachments] so the
|
||||
@@ -144,6 +155,20 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp, start = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
message.badges.take(4).forEach { badge ->
|
||||
MessagePathBadge(text = badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking block (above the bubble, only for assistant messages)
|
||||
if (!isUser && showThinking && message.thinkingContent.isNotEmpty()) {
|
||||
ThinkingBlock(
|
||||
@@ -176,6 +201,31 @@ fun MessageBubble(
|
||||
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f))
|
||||
)
|
||||
}
|
||||
// Long-press opens a compact action menu when a quote handler is
|
||||
// wired; with copy as the only action it stays a direct copy so the
|
||||
// one-action case doesn't pay a menu tap.
|
||||
var showMessageActions by remember { mutableStateOf(false) }
|
||||
if (onQuoteMessage != null) {
|
||||
DropdownMenu(
|
||||
expanded = showMessageActions,
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(message.content)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Quote in reply") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.content)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
@@ -191,7 +241,13 @@ fun MessageBubble(
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { onCopyMessage(message.content) }
|
||||
onLongClick = {
|
||||
if (onQuoteMessage != null) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(message.content)
|
||||
}
|
||||
}
|
||||
)
|
||||
.semantics { contentDescription = a11yDescription }
|
||||
) {
|
||||
@@ -287,6 +343,23 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessagePathBadge(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Three dots that animate opacity in sequence to indicate streaming is in progress.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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 com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
enum class PowerFeatureGateStatus(
|
||||
val label: String,
|
||||
val actionLabel: String,
|
||||
val explanation: String,
|
||||
) {
|
||||
RequiresPairing(
|
||||
label = "Requires pairing",
|
||||
actionLabel = "Pair to unlock",
|
||||
explanation = "This feature uses relay grants and requires a paired device session.",
|
||||
),
|
||||
PairingExpired(
|
||||
label = "Pairing expired",
|
||||
actionLabel = "Pair again",
|
||||
explanation = "Your relay session is no longer accepted. Pair again to get a fresh grant.",
|
||||
),
|
||||
Unavailable(
|
||||
label = "Unavailable on this server",
|
||||
actionLabel = "View connection",
|
||||
explanation = "This server does not currently expose the relay capability this feature needs.",
|
||||
),
|
||||
DashboardSignInRequired(
|
||||
label = "Dashboard sign-in required",
|
||||
actionLabel = "Open sign-in",
|
||||
explanation = "This standard dashboard feature needs a dashboard session before it can load.",
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromRelayAuth(authState: AuthState): PowerFeatureGateStatus {
|
||||
return when (authState) {
|
||||
is AuthState.Failed -> {
|
||||
val reason = authState.reason.lowercase()
|
||||
if ("expired" in reason || "token" in reason || "session" in reason) {
|
||||
PairingExpired
|
||||
} else {
|
||||
RequiresPairing
|
||||
}
|
||||
}
|
||||
else -> RequiresPairing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PowerFeatureGateScreen(
|
||||
title: String,
|
||||
summary: String,
|
||||
status: PowerFeatureGateStatus,
|
||||
onPrimaryAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PowerFeatureGateCard(
|
||||
title = title,
|
||||
summary = summary,
|
||||
status = status,
|
||||
onPrimaryAction = onPrimaryAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PowerFeatureGateCard(
|
||||
title: String,
|
||||
summary: String,
|
||||
status: PowerFeatureGateStatus,
|
||||
onPrimaryAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = status.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = status.explanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Button(
|
||||
onClick = onPrimaryAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(status.actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PowerFeatureGatePreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PowerFeatureGateExpiredPreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,10 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -132,7 +134,7 @@ import kotlin.math.max
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The top-level fields configure the direct-chat Hermes API server. The
|
||||
* The top-level fields configure the direct Hermes API server. The
|
||||
* optional [relay] block configures the Hermes-Relay WSS connection used by
|
||||
* the terminal and bridge channels. The [endpoints] list (v3+) carries an
|
||||
* ordered array of candidate endpoints; the phone picks the highest-priority
|
||||
@@ -217,12 +219,17 @@ private val json = Json {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a scanned string as a Hermes pairing QR payload.
|
||||
* Try to parse a scanned string as a Hermes connection QR payload.
|
||||
*
|
||||
* Accepts v1, v2, and v3 (or anything without a `hermes` field — we default
|
||||
* to `1`). Returns null when the payload is not valid JSON, has no `host`
|
||||
* field, or fails strict decoding.
|
||||
*
|
||||
* For standard Hermes setup, also accepts generic API-only QRs:
|
||||
* - a plain `http://host:8642` or `https://host:8642` URL
|
||||
* - JSON with `api_url`, `apiUrl`, `server_url`, `serverUrl`, or `url`, plus
|
||||
* optional `api_key`, `apiKey`, or `key`
|
||||
*
|
||||
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
|
||||
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
|
||||
* from the top-level fields so downstream code can always iterate
|
||||
@@ -233,6 +240,13 @@ private val json = Json {
|
||||
* role case, priority order, and unknown roles are all preserved.
|
||||
*/
|
||||
fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
val trimmed = raw.trim()
|
||||
return parseHermesRelayQr(trimmed)
|
||||
?: parseGenericApiJsonQr(trimmed)
|
||||
?: parseGenericApiUrlQr(trimmed)
|
||||
}
|
||||
|
||||
private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
// Quick check: must contain a `host` field and be valid JSON. We no
|
||||
// longer reject based on the `hermes` version int — future v4+ QRs
|
||||
@@ -263,6 +277,85 @@ fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGenericApiJsonQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
val obj = json.decodeFromString<JsonObject>(raw)
|
||||
val apiUrl = firstString(
|
||||
obj,
|
||||
"api_url",
|
||||
"apiUrl",
|
||||
"server_url",
|
||||
"serverUrl",
|
||||
"url",
|
||||
) ?: return null
|
||||
val apiKey = firstString(obj, "api_key", "apiKey", "key").orEmpty()
|
||||
payloadFromApiUrl(apiUrl, apiKey)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGenericApiUrlQr(raw: String): HermesPairingPayload? {
|
||||
return payloadFromApiUrl(raw, apiKey = "")
|
||||
}
|
||||
|
||||
private fun firstString(obj: JsonObject, vararg names: String): String? {
|
||||
return names.firstNotNullOfOrNull { name ->
|
||||
obj[name]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun payloadFromApiUrl(apiUrl: String, apiKey: String): HermesPairingPayload? {
|
||||
val uri = runCatching { URI(apiUrl.trim().trimEnd('/')) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val payload = HermesPairingPayload(
|
||||
host = host,
|
||||
port = if (uri.port > 0) uri.port else 8642,
|
||||
key = apiKey.trim(),
|
||||
tls = tls,
|
||||
relay = null,
|
||||
)
|
||||
return payload.copy(endpoints = listOf(synthesizeGenericEndpoint(payload)))
|
||||
}
|
||||
|
||||
private fun synthesizeGenericEndpoint(payload: HermesPairingPayload): EndpointCandidate {
|
||||
val host = payload.host.lowercase()
|
||||
val role = when {
|
||||
host.endsWith(".ts.net") || host.startsWith("100.") -> "tailscale"
|
||||
isPrivateLanHost(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role,
|
||||
priority = 0,
|
||||
api = ApiEndpoint(
|
||||
host = payload.host,
|
||||
port = payload.port,
|
||||
tls = payload.tls,
|
||||
),
|
||||
relay = RelayEndpoint(url = "", transportHint = null),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isPrivateLanHost(host: String): Boolean {
|
||||
if (host == "localhost" || host == "127.0.0.1" || host == "::1") return true
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single priority-0 [EndpointCandidate] from a v1/v2 pairing payload
|
||||
* that lacked an `endpoints` array. Preserves the top-level API coordinates
|
||||
@@ -635,7 +728,7 @@ fun QrPairingScanner(
|
||||
// Instructions
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Icon(
|
||||
@@ -645,14 +738,14 @@ fun QrPairingScanner(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Point at a Hermes pairing QR code",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
text = "Scan a Hermes setup QR",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Generate one on your server with: hermes-pair",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Hermes-Relay plugin.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayDottedOverlay
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import com.hermesandroid.relay.ui.theme.relayPanel
|
||||
import com.hermesandroid.relay.ui.theme.relaySelectedPanel
|
||||
|
||||
enum class RelayPrimaryMode(val label: String) {
|
||||
Chat("Chat"),
|
||||
Manage("Manage"),
|
||||
Bridge("Bridge"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayModeStrip(
|
||||
selected: RelayPrimaryMode,
|
||||
onModeSelected: (RelayPrimaryMode) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RelayPrimaryMode.entries.forEach { mode ->
|
||||
val active = mode == selected
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp)
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.then(
|
||||
if (active) {
|
||||
Modifier.relaySelectedPanel()
|
||||
} else {
|
||||
Modifier.relayPanel(
|
||||
background = RelayRefresh.Background.copy(alpha = 0.45f),
|
||||
)
|
||||
},
|
||||
)
|
||||
.clickable { onModeSelected(mode) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = mode.label,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (active) RelayRefresh.Paper else RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayStatusStrip(
|
||||
leading: String,
|
||||
trailing: String,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingColor: Color = RelayRefresh.Green,
|
||||
) {
|
||||
// Floating capsule, not an edge-to-edge bar: a full-width bordered
|
||||
// rectangle clashes with rounded display corners and reads as a hard
|
||||
// shelf. Insets are applied BEFORE the margins so the pill floats above
|
||||
// the gesture area with the app background showing around it.
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
|
||||
),
|
||||
)
|
||||
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.relayPanel(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
|
||||
borderColor = RelayRefresh.Line,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = leading,
|
||||
style = relayMetadataStyle(),
|
||||
color = leadingColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = trailing,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayReturnStrip(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "Back",
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.relaySelectedPanel()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.58f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Paper,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Navy3.copy(alpha = 0.72f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Line),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Relay,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Relay,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayHeroPanel(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
accent: Color = RelayRefresh.Relay,
|
||||
action: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.relaySelectedPanel(),
|
||||
) {
|
||||
RelayDottedOverlay(alpha = 0.18f)
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = RelayRefresh.Ink.copy(alpha = 0.86f),
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayMetricCard(
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
valueColor: Color = RelayRefresh.Paper,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.relayPanel()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayNavTile(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val base = if (selected) {
|
||||
Modifier.relaySelectedPanel()
|
||||
} else {
|
||||
Modifier.relayPanel(background = RelayRefresh.Navy2.copy(alpha = 0.72f))
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.then(base)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Navy3.copy(alpha = if (enabled) 0.86f else 0.38f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Line),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) RelayRefresh.Relay else RelayRefresh.Dim,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (enabled) RelayRefresh.Paper else RelayRefresh.Dim,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (trailing != null) {
|
||||
trailing()
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelaySectionCaption(
|
||||
title: String,
|
||||
meta: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
)
|
||||
meta?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayStatusPill(
|
||||
text: String,
|
||||
active: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.relayPanel(
|
||||
background = if (active) RelayRefresh.Green.copy(alpha = 0.12f) else RelayRefresh.Navy3.copy(alpha = 0.7f),
|
||||
borderColor = if (active) RelayRefresh.Green.copy(alpha = 0.36f) else RelayRefresh.Line,
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (active) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = null,
|
||||
tint = if (active) RelayRefresh.Green else RelayRefresh.Muted,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = relayMetadataStyle(),
|
||||
color = if (active) RelayRefresh.Green else RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayChromeIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(38.dp),
|
||||
shape = RoundedCornerShape(RelayRefresh.CardRadius),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.52f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = RelayRefresh.Paper,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,14 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -36,10 +40,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private enum class SessionDrawerFilter(val label: String) {
|
||||
All("All"),
|
||||
Pinned("Pinned"),
|
||||
Archive("Archive"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: List<ChatSession>,
|
||||
@@ -53,8 +65,35 @@ fun SessionDrawerContent(
|
||||
) {
|
||||
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
|
||||
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val visibleSessions = sessions
|
||||
.asSequence()
|
||||
.filter { session ->
|
||||
when (filter) {
|
||||
SessionDrawerFilter.All -> session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Pinned ->
|
||||
session.sessionId in pinnedSessionIds &&
|
||||
session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Archive -> session.sessionId in archivedSessionIds
|
||||
}
|
||||
}
|
||||
.filter { session ->
|
||||
val needle = query.trim()
|
||||
needle.isBlank() ||
|
||||
session.sessionId.contains(needle, ignoreCase = true) ||
|
||||
session.title.orEmpty().contains(needle, ignoreCase = true) ||
|
||||
session.model.orEmpty().contains(needle, ignoreCase = true)
|
||||
}
|
||||
.toList()
|
||||
|
||||
ModalDrawerSheet(modifier = Modifier.width(300.dp)) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.width(320.dp),
|
||||
drawerContainerColor = RelayRefresh.Background,
|
||||
drawerContentColor = RelayRefresh.Ink,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header
|
||||
Text(
|
||||
@@ -84,12 +123,41 @@ fun SessionDrawerContent(
|
||||
Text("New Chat")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text("Search sessions or id...") },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SessionDrawerFilter.entries.forEach { item ->
|
||||
FilterChip(
|
||||
selected = filter == item,
|
||||
onClick = { filter = item },
|
||||
label = {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = relayMetadataStyle(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
if (visibleSessions.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -97,7 +165,7 @@ fun SessionDrawerContent(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "No sessions yet",
|
||||
text = if (sessions.isEmpty()) "No sessions yet" else "No matching sessions",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -109,11 +177,27 @@ fun SessionDrawerContent(
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(sessions, key = { it.sessionId }) { session ->
|
||||
items(visibleSessions, key = { it.sessionId }) { session ->
|
||||
SessionItem(
|
||||
session = session,
|
||||
isActive = session.sessionId == currentSessionId,
|
||||
pinned = session.sessionId in pinnedSessionIds,
|
||||
archived = session.sessionId in archivedSessionIds,
|
||||
onClick = { onSelectSession(session.sessionId) },
|
||||
onTogglePinned = {
|
||||
pinnedSessionIds = if (session.sessionId in pinnedSessionIds) {
|
||||
pinnedSessionIds - session.sessionId
|
||||
} else {
|
||||
pinnedSessionIds + session.sessionId
|
||||
}
|
||||
},
|
||||
onToggleArchived = {
|
||||
archivedSessionIds = if (session.sessionId in archivedSessionIds) {
|
||||
archivedSessionIds - session.sessionId
|
||||
} else {
|
||||
archivedSessionIds + session.sessionId
|
||||
}
|
||||
},
|
||||
onRename = { renameDialogSession = session },
|
||||
onDelete = { deleteDialogSession = session }
|
||||
)
|
||||
@@ -184,7 +268,11 @@ fun SessionDrawerContent(
|
||||
private fun SessionItem(
|
||||
session: ChatSession,
|
||||
isActive: Boolean,
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
@@ -234,6 +322,20 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onTogglePinned, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = if (pinned) "Unpin session" else "Pin session",
|
||||
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggleArchived, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Archive,
|
||||
contentDescription = if (archived) "Restore session" else "Archive session",
|
||||
tint = if (archived) RelayRefresh.Relay else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRename, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
|
||||
@@ -209,7 +209,7 @@ private fun defaultOptionIndex(options: List<TtlOption>): Int =
|
||||
|
||||
/**
|
||||
* Compute the default TTL for a new pair based on:
|
||||
* - QR payload's `ttlSeconds` (operator intent via `hermes-pair --ttl`)
|
||||
* - QR payload's `ttlSeconds` (operator intent via `hermes pair --ttl`)
|
||||
* - Transport hint (`"wss"` → 30d, `"ws"` → 7d)
|
||||
* - Tailscale detected → 30d
|
||||
* - Fallback → 30d
|
||||
|
||||
@@ -166,8 +166,27 @@ fun ToolProgressCard(
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
toolCall.runId?.takeIf { it.isNotBlank() }?.let { runId ->
|
||||
Text(
|
||||
text = "Run: $runId",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
toolCall.provenance?.takeIf { it.isNotBlank() }?.let { provenance ->
|
||||
if (toolCall.runId != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = provenance,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Arguments
|
||||
toolCall.args?.let { args ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Arguments:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -182,6 +201,7 @@ fun ToolProgressCard(
|
||||
|
||||
// Error (tool.failed)
|
||||
toolCall.error?.let { error ->
|
||||
val preview = compactToolDetail(error)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Error:",
|
||||
@@ -189,7 +209,7 @@ fun ToolProgressCard(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = error,
|
||||
text = preview,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
@@ -198,6 +218,7 @@ fun ToolProgressCard(
|
||||
|
||||
// Result
|
||||
toolCall.result?.let { result ->
|
||||
val preview = compactToolDetail(result)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Result:",
|
||||
@@ -205,7 +226,7 @@ fun ToolProgressCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = result,
|
||||
text = preview,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
@@ -226,3 +247,11 @@ internal fun toolIcon(toolName: String): ImageVector = when {
|
||||
toolName.contains("search") || toolName.contains("web") -> Icons.Filled.Search
|
||||
else -> Icons.Filled.Build
|
||||
}
|
||||
|
||||
internal fun compactToolDetail(value: String, maxChars: Int = 700): String {
|
||||
val compact = value
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
if (compact.length <= maxChars) return compact
|
||||
return compact.take(maxChars.coerceAtLeast(80)).trimEnd() + "..."
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.GraphicEq
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -48,6 +50,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -67,8 +70,10 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
import com.hermesandroid.relay.viewmodel.DestructiveCountdownState
|
||||
import com.hermesandroid.relay.viewmodel.HermesConfirmationState
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.PermissionDeniedCallout
|
||||
import com.hermesandroid.relay.viewmodel.VoiceHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -109,6 +114,7 @@ fun VoiceModeOverlay(
|
||||
// is empty.
|
||||
transcriptMessages: List<ChatMessage> = emptyList(),
|
||||
showThinking: Boolean = true,
|
||||
voiceEngineMode: String? = null,
|
||||
voiceOutputProvider: String? = null,
|
||||
voiceOutputModel: String? = null,
|
||||
voiceOutputVoice: String? = null,
|
||||
@@ -125,6 +131,7 @@ fun VoiceModeOverlay(
|
||||
// visible if not wired (the chip itself is also gated on
|
||||
// `uiState.permissionDeniedCallout` being non-null).
|
||||
onPermissionDeniedChipTap: (PermissionDeniedCallout) -> Unit = {},
|
||||
onHermesConfirmationAnswer: (String) -> Unit = {},
|
||||
// === END v0.4.1 ===
|
||||
) {
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
@@ -163,6 +170,7 @@ fun VoiceModeOverlay(
|
||||
onExpandedChange = { controlsExpanded = it },
|
||||
focusMode = focusMode,
|
||||
onFocusModeChange = setFocusMode,
|
||||
engineMode = voiceEngineMode,
|
||||
provider = voiceOutputProvider,
|
||||
model = voiceOutputModel,
|
||||
voice = voiceOutputVoice,
|
||||
@@ -280,6 +288,41 @@ fun VoiceModeOverlay(
|
||||
.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = uiState.handoffStatus != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
) {
|
||||
VoiceHandoffStrip(
|
||||
status = uiState.handoffStatus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = uiState.backgroundRun != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = uiState.backgroundRun?.message
|
||||
?: "Working on it in the background…",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
DestructiveCountdownRow(
|
||||
@@ -297,6 +340,14 @@ fun VoiceModeOverlay(
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
HermesConfirmationCard(
|
||||
confirmation = uiState.hermesConfirmation,
|
||||
onAnswer = onHermesConfirmationAnswer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Box(
|
||||
@@ -411,7 +462,7 @@ fun VoiceModeOverlay(
|
||||
// Listening → tap stops recording (feeds the audio to STT)
|
||||
// Speaking → tap interrupts TTS and starts fresh recording
|
||||
// Idle/Error → tap starts recording
|
||||
// Transcribing/Thinking → tap is a no-op; the state machine is busy
|
||||
// Transcribing/Thinking → tap cancels the in-flight realtime turn
|
||||
//
|
||||
// The bug before was that Listening fell through to the else branch
|
||||
// which called onMicTap (= startListening) a second time instead of
|
||||
@@ -420,44 +471,43 @@ fun VoiceModeOverlay(
|
||||
VoiceMicButton(
|
||||
uiState = uiState,
|
||||
onTap = {
|
||||
when (uiState.state) {
|
||||
VoiceState.Listening -> {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onMicRelease()
|
||||
}
|
||||
}
|
||||
VoiceState.Speaking -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onInterrupt()
|
||||
}
|
||||
}
|
||||
VoiceState.Transcribing, VoiceState.Thinking -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
}
|
||||
}
|
||||
VoiceState.Idle, VoiceState.Error -> {
|
||||
dispatchVoiceMicTap(
|
||||
uiState = uiState,
|
||||
onStartListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicTap()
|
||||
}
|
||||
}
|
||||
},
|
||||
onStopListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicRelease()
|
||||
},
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
},
|
||||
onPress = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicTap()
|
||||
onHoldPress = {
|
||||
dispatchVoiceMicHoldPress(
|
||||
uiState = uiState,
|
||||
onStartListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicTap()
|
||||
},
|
||||
onInterruptAndStart = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onInterrupt()
|
||||
onMicTap()
|
||||
},
|
||||
)
|
||||
},
|
||||
onRelease = {
|
||||
onHoldRelease = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
@@ -475,8 +525,8 @@ fun VoiceModeOverlay(
|
||||
private fun VoiceMicButton(
|
||||
uiState: VoiceUiState,
|
||||
onTap: () -> Unit,
|
||||
onPress: () -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
onHoldPress: () -> Unit,
|
||||
onHoldRelease: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
baseSize: Int = 72,
|
||||
@@ -504,34 +554,34 @@ private fun VoiceMicButton(
|
||||
val containerColor = when (uiState.state) {
|
||||
VoiceState.Listening -> Color(0xFFE53935)
|
||||
VoiceState.Speaking -> Color(0xFFE53935)
|
||||
VoiceState.Transcribing, VoiceState.Thinking ->
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) Color(0xFFE53935)
|
||||
else MaterialTheme.colorScheme.primary
|
||||
VoiceState.Transcribing, VoiceState.Thinking -> Color(0xFFE53935)
|
||||
VoiceState.Error -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
val icon = when (uiState.state) {
|
||||
VoiceState.Listening -> Icons.Filled.Stop
|
||||
VoiceState.Transcribing, VoiceState.Thinking ->
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) Icons.Filled.Stop
|
||||
else Icons.Filled.GraphicEq
|
||||
VoiceState.Transcribing, VoiceState.Thinking -> Icons.Filled.Stop
|
||||
// Stop icon makes the "tap to interrupt TTS" affordance obvious;
|
||||
// VolumeUp looked decorative and users didn't try tapping it.
|
||||
VoiceState.Speaking -> Icons.Filled.Stop
|
||||
else -> Icons.Filled.Mic
|
||||
}
|
||||
|
||||
val currentOnTap by rememberUpdatedState(onTap)
|
||||
val currentOnHoldPress by rememberUpdatedState(onHoldPress)
|
||||
val currentOnHoldRelease by rememberUpdatedState(onHoldRelease)
|
||||
|
||||
val gestureModifier = when (uiState.interactionMode) {
|
||||
InteractionMode.HoldToTalk -> Modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
onPress()
|
||||
currentOnHoldPress()
|
||||
waitForUpOrCancellation()
|
||||
onRelease()
|
||||
currentOnHoldRelease()
|
||||
}
|
||||
}
|
||||
else -> Modifier.clickable { onTap() }
|
||||
else -> Modifier.clickable { currentOnTap() }
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -610,6 +660,7 @@ private fun VoiceSessionPill(
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
focusMode: Boolean,
|
||||
onFocusModeChange: (Boolean) -> Unit,
|
||||
engineMode: String?,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
voice: String?,
|
||||
@@ -626,6 +677,7 @@ private fun VoiceSessionPill(
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val engineText = voiceEngineLabel(engineMode)
|
||||
val providerText = voiceProviderLabel(provider, model, voice, outputEnabled)
|
||||
val profileText = profileName?.takeIf { it.isNotBlank() } ?: "default profile"
|
||||
val scopeText = when (configScope) {
|
||||
@@ -635,9 +687,9 @@ private fun VoiceSessionPill(
|
||||
else -> null
|
||||
}
|
||||
val headlineText = if (focusMode) {
|
||||
"$profileText / $providerText"
|
||||
"$engineText / $profileText / $providerText"
|
||||
} else {
|
||||
"${stateHint(uiState.state).ifBlank { "Voice ready" }} / $profileText / $providerText"
|
||||
"${stateHint(uiState.state).ifBlank { "Voice ready" }} / $engineText / $providerText"
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
@@ -668,7 +720,7 @@ private fun VoiceSessionPill(
|
||||
if (focusMode) {
|
||||
StatusPill(uiState.interactionMode.label())
|
||||
} else {
|
||||
StatusPill("Compact", emphasized = true)
|
||||
StatusPill(engineText, emphasized = true)
|
||||
}
|
||||
Text(
|
||||
text = headlineText,
|
||||
@@ -693,8 +745,27 @@ private fun VoiceSessionPill(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onExit,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Exit voice mode",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VoiceHandoffStrip(
|
||||
status = uiState.handoffStatus,
|
||||
compact = !expanded,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -720,6 +791,7 @@ private fun VoiceSessionPill(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
StatusPill(engineText, emphasized = true, modifier = Modifier.weight(1f))
|
||||
StatusPill(profileText, modifier = Modifier.weight(1f))
|
||||
scopeText?.let {
|
||||
StatusPill(it, modifier = Modifier.weight(1f))
|
||||
@@ -770,31 +842,75 @@ private fun CompactVoiceMicButton(
|
||||
VoiceMicButton(
|
||||
uiState = uiState,
|
||||
onTap = {
|
||||
when (uiState.state) {
|
||||
VoiceState.Listening -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onMicRelease()
|
||||
}
|
||||
}
|
||||
VoiceState.Speaking, VoiceState.Transcribing, VoiceState.Thinking -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onInterrupt()
|
||||
}
|
||||
}
|
||||
VoiceState.Idle, VoiceState.Error -> onMicTap()
|
||||
}
|
||||
dispatchVoiceMicTap(
|
||||
uiState = uiState,
|
||||
onStartListening = onMicTap,
|
||||
onStopListening = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
},
|
||||
onPress = onMicTap,
|
||||
onRelease = onMicRelease,
|
||||
onHoldPress = {
|
||||
dispatchVoiceMicHoldPress(
|
||||
uiState = uiState,
|
||||
onStartListening = onMicTap,
|
||||
onInterruptAndStart = {
|
||||
onInterrupt()
|
||||
onMicTap()
|
||||
},
|
||||
)
|
||||
},
|
||||
onHoldRelease = onMicRelease,
|
||||
baseSize = 40,
|
||||
iconSize = 20,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun dispatchVoiceMicTap(
|
||||
uiState: VoiceUiState,
|
||||
onStartListening: () -> Unit,
|
||||
onStopListening: () -> Unit,
|
||||
onInterrupt: () -> Unit,
|
||||
onPauseAutoMode: () -> Unit,
|
||||
) {
|
||||
when (uiState.state) {
|
||||
VoiceState.Listening -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onStopListening()
|
||||
}
|
||||
}
|
||||
VoiceState.Speaking -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onInterrupt()
|
||||
}
|
||||
}
|
||||
VoiceState.Transcribing, VoiceState.Thinking -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onInterrupt()
|
||||
}
|
||||
}
|
||||
VoiceState.Idle, VoiceState.Error -> onStartListening()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun dispatchVoiceMicHoldPress(
|
||||
uiState: VoiceUiState,
|
||||
onStartListening: () -> Unit,
|
||||
onInterruptAndStart: () -> Unit,
|
||||
) {
|
||||
when (uiState.state) {
|
||||
VoiceState.Idle, VoiceState.Error -> onStartListening()
|
||||
VoiceState.Speaking -> onInterruptAndStart()
|
||||
VoiceState.Listening, VoiceState.Transcribing, VoiceState.Thinking -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceControlChip(
|
||||
text: String,
|
||||
@@ -864,6 +980,69 @@ private fun StatusPill(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHandoffStrip(
|
||||
status: VoiceHandoffStatus?,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
current.success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.42f)
|
||||
current.active -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.42f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f)
|
||||
}
|
||||
val contentColor = when {
|
||||
current.success -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
current.active -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(containerColor)
|
||||
.padding(horizontal = 10.dp, vertical = if (compact) 6.dp else 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 2.dp else 4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = current.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
current.route?.takeIf { it.isNotBlank() }?.let { route ->
|
||||
Text(
|
||||
text = route,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!compact) {
|
||||
current.entries.takeLast(3).forEach { entry ->
|
||||
val detail = entry.detail?.takeIf { it.isNotBlank() }
|
||||
Text(
|
||||
text = if (detail == null) entry.label else "${entry.label} / $detail",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun voiceProviderLabel(
|
||||
provider: String?,
|
||||
model: String?,
|
||||
@@ -877,6 +1056,15 @@ private fun voiceProviderLabel(
|
||||
return listOfNotNull(providerPart, modelPart, voicePart).joinToString(" / ")
|
||||
}
|
||||
|
||||
private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
|
||||
"realtime_agent" -> "Realtime Agent"
|
||||
"hermes_voice_output" -> "Hermes voice"
|
||||
null, "" -> "Voice engine ..."
|
||||
else -> engineMode
|
||||
.replace('_', ' ')
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
private fun InteractionMode.shortLabel(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> "Tap"
|
||||
InteractionMode.HoldToTalk -> "Hold"
|
||||
@@ -999,8 +1187,11 @@ private fun VoiceToolStatusRow(toolCall: ToolCall) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val preview = toolCall.error?.takeIf { it.isNotBlank() }
|
||||
val rawPreview = toolCall.error?.takeIf { it.isNotBlank() }
|
||||
?: toolCall.result?.takeIf { it.isNotBlank() }
|
||||
val preview = remember(rawPreview) {
|
||||
rawPreview?.let { compactToolDetail(it, maxChars = 280) }
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1054,6 +1245,48 @@ private fun VoiceToolStatusRow(toolCall: ToolCall) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HermesConfirmationCard(
|
||||
confirmation: HermesConfirmationState?,
|
||||
onAnswer: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = confirmation != null,
|
||||
enter = fadeIn(tween(150)),
|
||||
exit = fadeOut(tween(150)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
confirmation?.let { state ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = state.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = { onAnswer("allow") }) { Text("Allow") }
|
||||
TextButton(onClick = { onAnswer("deny") }) { Text("Deny") }
|
||||
TextButton(onClick = { onAnswer("cancel") }) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the 5-second destructive-intent confirmation countdown as a
|
||||
* thin horizontal progress bar with a short label. The bar fills from
|
||||
|
||||
@@ -27,6 +27,7 @@ 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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -40,6 +41,7 @@ fun OnboardingPage(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
transparentHero: Boolean = false,
|
||||
heroContent: @Composable BoxScope.() -> Unit = {
|
||||
FeatureHero(
|
||||
icon = icon,
|
||||
@@ -82,14 +84,14 @@ fun OnboardingPage(
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
.background(heroBrush),
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
|
||||
@@ -24,8 +24,8 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.PhonelinkSetup
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -48,13 +48,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
@@ -63,18 +62,16 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Page identifiers for dynamic onboarding flow. */
|
||||
private enum class OnboardingPage { Welcome, Chat, Terminal, Bridge, Connect }
|
||||
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
|
||||
|
||||
/**
|
||||
* Three-stage onboarding:
|
||||
* Standard-first onboarding:
|
||||
*
|
||||
* 1. **Feature pages** (Welcome / Chat / Terminal / Bridge) — informational
|
||||
* swipe-able introduction. Bridge / Terminal pages only show when the
|
||||
* relay feature is enabled (Developer Options).
|
||||
* 1. **Feature pages** (Welcome / Chat / Manage / Power tools) — standard
|
||||
* Hermes API/dashboard features first, Relay-only power tools second.
|
||||
* 2. **Connect page** — embeds the shared [ConnectionWizard] so onboarding
|
||||
* uses the exact same scan → confirm → verify flow as Settings → Connection.
|
||||
* The wizard owns credential application; on success / skip it calls back
|
||||
* into [onComplete] to finish onboarding and navigate to chat.
|
||||
* uses the exact same Standard API/dashboard and optional Relay pairing
|
||||
* flow as Settings → Connections.
|
||||
*
|
||||
* The previous separate "ConnectPage" + "RelayPage" pair has been removed —
|
||||
* it discarded the QR's relay block, never applied per-channel grants, never
|
||||
@@ -102,19 +99,14 @@ fun OnboardingScreen(
|
||||
// lands on the right VM and survives the Onboarding→Chat transition.
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onManageSignIn: () -> Unit = onComplete,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Build page list dynamically based on feature flags
|
||||
val pages = remember(relayEnabled) {
|
||||
val pages = remember {
|
||||
buildList {
|
||||
add(OnboardingPage.Welcome)
|
||||
add(OnboardingPage.Chat)
|
||||
if (relayEnabled) {
|
||||
add(OnboardingPage.Terminal)
|
||||
add(OnboardingPage.Bridge)
|
||||
}
|
||||
add(OnboardingPage.Manage)
|
||||
add(OnboardingPage.Power)
|
||||
add(OnboardingPage.Connect)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +126,11 @@ fun OnboardingScreen(
|
||||
onDismissRequest = { showSkipConfirm = false },
|
||||
title = { Text("Skip setup?") },
|
||||
text = {
|
||||
Text("You can configure your server connection later in Settings → Connection. Without pairing, chat and voice features won't work yet.")
|
||||
Text(
|
||||
"You can configure your Hermes connection later in Settings → Connections. " +
|
||||
"Without a connection, Chat and Manage won't load. Relay pairing can " +
|
||||
"be added later for power tools."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
@@ -185,11 +181,12 @@ fun OnboardingScreen(
|
||||
when (pages[pageIndex]) {
|
||||
OnboardingPage.Welcome -> WelcomePage()
|
||||
OnboardingPage.Chat -> ChatPage()
|
||||
OnboardingPage.Terminal -> TerminalPage()
|
||||
OnboardingPage.Bridge -> BridgePage()
|
||||
OnboardingPage.Manage -> ManagePage()
|
||||
OnboardingPage.Power -> PowerToolsPage()
|
||||
OnboardingPage.Connect -> ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onManageSignIn = onManageSignIn,
|
||||
onSkip = { showSkipConfirm = true },
|
||||
)
|
||||
}
|
||||
@@ -260,14 +257,15 @@ private fun WelcomePage() {
|
||||
val context = LocalContext.current
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
title = "Hermes-Relay",
|
||||
description = "Your Hermes agent, in your pocket.",
|
||||
title = "Hermes-Relay for Android",
|
||||
description = "Chat with Hermes and manage your dashboard from your phone.",
|
||||
transparentHero = true,
|
||||
heroContent = {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
MorphingSphere(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
state = SphereState.Idle,
|
||||
intensity = 0.12f,
|
||||
)
|
||||
@@ -287,27 +285,48 @@ private fun WelcomePage() {
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 18.dp)
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)),
|
||||
contentAlignment = Alignment.Center
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
|
||||
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "Hermes-Relay logo",
|
||||
modifier = Modifier.size(42.dp)
|
||||
contentDescription = "Hermes logo",
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Hermes-Relay",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Standard",
|
||||
description = "Connect to your running Hermes dashboard and API. No Relay install or pairing required.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Advanced",
|
||||
description = "Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.",
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Read the app guide, browse the repo, or jump to Hermes Agent docs while you finish server setup.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = "The setup guide has copy/paste commands when you need to start Hermes on a computer or server.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -318,7 +337,7 @@ private fun WelcomePage() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -329,71 +348,158 @@ private fun WelcomePage() {
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("User Guide")
|
||||
Text("Setup Guide")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_github),
|
||||
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("GitHub")
|
||||
Text("Hermes Docs")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "hermes-agent.nousresearch.com",
|
||||
text = "Hermes API server docs",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com")))
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPathSummary(
|
||||
label: String,
|
||||
description: String,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(78.dp),
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Forum,
|
||||
title = "Chat",
|
||||
description = "Talk to any Hermes agent profile with real-time streaming responses, tool progress, and full markdown."
|
||||
)
|
||||
description = "Your Hermes agent, streaming in real time.",
|
||||
) {
|
||||
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.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Profiles",
|
||||
description = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Voice",
|
||||
description = "Tap the mic to talk. Speech runs through your Hermes server — no extra install.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalPage() {
|
||||
private fun ManagePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Settings,
|
||||
title = "Manage",
|
||||
description = "Your Hermes dashboard, pocket-sized.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Control",
|
||||
description = "Profiles, skills, automations, MCP servers, models, and provider keys.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Skills hub",
|
||||
description = "Search the hub, read a skill before installing, and install from your phone.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "One sign-in",
|
||||
description = "Signing into the dashboard once also unlocks voice for this connection.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerToolsPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Terminal,
|
||||
title = "Terminal",
|
||||
description = "Secure remote shell access to your server via tmux. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BridgePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.PhonelinkSetup,
|
||||
title = "Bridge",
|
||||
description = "Let your agent control your device — taps, typing, screenshots, and automation. Coming soon."
|
||||
)
|
||||
title = "Power tools",
|
||||
description = "Pair the optional Relay when you want more than standard.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Terminal",
|
||||
description = "A real tmux session on your server, in your pocket.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Bridge",
|
||||
description = "Let the agent operate this phone — with safety rails (sideload builds).",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Realtime",
|
||||
description = "Provider-native realtime voice agent and profile-aware voice providers.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onManageSignIn: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -406,6 +512,7 @@ private fun ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onCancel = onSkip,
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -251,6 +251,18 @@ fun AppearanceSettingsScreen(
|
||||
enabled = animEnabled
|
||||
)
|
||||
}
|
||||
|
||||
// The ambient-mode entry is a gesture with no visible
|
||||
// control — this line is its discoverable documentation
|
||||
// (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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayNavTile
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelaySectionCaption
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
|
||||
/**
|
||||
* Google Play Bridge Core surface.
|
||||
*
|
||||
* The Play build still presents "Hermes Bridge" as the umbrella for relay
|
||||
* integrations, but it deliberately excludes AccessibilityService-backed
|
||||
* Device Control. The sideload flavor keeps using [BridgeScreen].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BridgeCoreScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToVoiceSettings: () -> Unit,
|
||||
onNavigateToNotificationCompanion: () -> Unit,
|
||||
onNavigateToMediaSettings: () -> Unit,
|
||||
onNavigateToRelaySessions: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
returnTitle: String? = null,
|
||||
returnSubtitle: String = "",
|
||||
returnLabel: String = "Back",
|
||||
onReturn: (() -> Unit)? = null,
|
||||
) {
|
||||
val relayState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayConnected = relayState == RelayUiState.Connected
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = "Terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.12f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp),
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
title = returnTitle,
|
||||
subtitle = returnSubtitle,
|
||||
label = returnLabel,
|
||||
onClick = onReturn,
|
||||
)
|
||||
}
|
||||
RelayHeroPanel(
|
||||
title = if (relayConnected) "Phone bridge is paired" else "Bridge Core is waiting",
|
||||
subtitle = if (relayConnected) {
|
||||
"Terminal, voice, notification, media, and relay-session controls share this grant."
|
||||
} else {
|
||||
"Pair Relay to use Terminal and phone bridge tools. Chat and Manage continue over standard Hermes API."
|
||||
},
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RelayStatusPill(
|
||||
text = relayState.statusText("connected").lowercase(),
|
||||
active = relayConnected,
|
||||
)
|
||||
RelayStatusPill(
|
||||
text = "safety on",
|
||||
active = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Hermes Bridge Core",
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = "This build does not include AccessibilityService, " +
|
||||
"screen reading, taps, typing, screenshots, SMS, calls, " +
|
||||
"or unattended phone control.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Relay",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = relayState.statusText("Connected"),
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RelaySectionCaption(
|
||||
title = "Bridge Surface",
|
||||
meta = "not hidden in settings",
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Connections",
|
||||
subtitle = "Pair, switch, and verify relay routes",
|
||||
onClick = onNavigateToConnections,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Code,
|
||||
title = "Terminal",
|
||||
subtitle = "Attach to your Hermes relay terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
selected = relayConnected,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
title = "Voice",
|
||||
subtitle = "Provider, model, output voice",
|
||||
onClick = onNavigateToVoiceSettings,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notifications",
|
||||
subtitle = "Shared app notifications",
|
||||
onClick = onNavigateToNotificationCompanion,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Image,
|
||||
title = "Media",
|
||||
subtitle = "Inbound attachments and cache behavior",
|
||||
onClick = onNavigateToMediaSettings,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Devices,
|
||||
title = "Relay sessions",
|
||||
subtitle = "Review active grants for this server",
|
||||
onClick = onNavigateToRelaySessions,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BridgeCoreRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,6 +25,8 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -62,9 +65,17 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.BridgeActivityLog
|
||||
import com.hermesandroid.relay.ui.components.BridgeMasterToggle
|
||||
import com.hermesandroid.relay.ui.components.BridgePermissionChecklist
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
// === v0.4.1 unattended-access ===
|
||||
import com.hermesandroid.relay.ui.components.UnattendedAccessRow
|
||||
// === END v0.4.1 unattended-access ===
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.BridgeViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -105,6 +116,13 @@ fun BridgeScreen(
|
||||
// === PHASE3-safety-rails: safety summary card ===
|
||||
onNavigateToBridgeSafety: () -> Unit = {},
|
||||
// === END PHASE3-safety-rails ===
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
returnTitle: String? = null,
|
||||
returnSubtitle: String = "",
|
||||
returnLabel: String = "Back",
|
||||
onReturn: (() -> Unit)? = null,
|
||||
) {
|
||||
val masterToggle by viewModel.masterToggle.collectAsState()
|
||||
val permissionStatus by viewModel.permissionStatus.collectAsState()
|
||||
@@ -197,8 +215,16 @@ fun BridgeScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -207,10 +233,45 @@ fun BridgeScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.12f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
title = returnTitle,
|
||||
subtitle = returnSubtitle,
|
||||
label = returnLabel,
|
||||
onClick = onReturn,
|
||||
)
|
||||
}
|
||||
RelayHeroPanel(
|
||||
title = if (relayReady) "Phone bridge is paired" else "Bridge controls are staged",
|
||||
subtitle = if (relayReady) {
|
||||
"Terminal, voice, notification, media, and advanced phone controls share this grant."
|
||||
} else {
|
||||
"Pair Relay to receive bridge commands. You can still configure permissions and safety before pairing."
|
||||
},
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RelayStatusPill("relay", relayReady)
|
||||
RelayStatusPill("safety", true)
|
||||
}
|
||||
},
|
||||
)
|
||||
// Relay-not-connected banner. Bridge commands arrive over the
|
||||
// relay's WSS — when relay is Unpaired / Disconnected / URL
|
||||
// blank, the AccessibilityService + foreground service will
|
||||
@@ -251,7 +312,7 @@ fun BridgeScreen(
|
||||
)
|
||||
Text(
|
||||
text = "Bridge commands travel over the relay. " +
|
||||
"Pair a relay in Settings → Connection for " +
|
||||
"Pair a relay in Settings → Connections for " +
|
||||
"the bridge to actually do anything.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
|
||||
@@ -30,18 +30,23 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -89,8 +94,8 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
||||
import com.hermesandroid.relay.network.ChatMode
|
||||
import com.hermesandroid.relay.network.ConnectivityObserver
|
||||
import com.hermesandroid.relay.network.RelayVoiceClient
|
||||
import com.hermesandroid.relay.network.RealtimeVoiceConfig
|
||||
import com.hermesandroid.relay.network.VoiceOutputConfig
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@@ -129,6 +134,9 @@ import com.hermesandroid.relay.ui.components.CompactToolCall
|
||||
import com.hermesandroid.relay.ui.components.InlineAutocomplete
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
||||
import com.hermesandroid.relay.ui.components.SlashCommand
|
||||
@@ -137,6 +145,8 @@ import com.hermesandroid.relay.ui.components.ToolProgressCard
|
||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
@@ -188,6 +198,12 @@ fun ChatScreen(
|
||||
// screen. Default no-op preserves existing test/preview call sites that
|
||||
// don't wire navigation.
|
||||
onNavigateToConnections: () -> Unit = {},
|
||||
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToBridge: () -> Unit = {},
|
||||
onNavigateToTerminal: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
||||
@@ -246,13 +262,17 @@ fun ChatScreen(
|
||||
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
val voiceStats by voiceViewModel.voiceStats.collectAsState()
|
||||
var voiceOutputConfig by remember { mutableStateOf<VoiceOutputConfig?>(null) }
|
||||
var realtimeAgentConfig by remember { mutableStateOf<RealtimeVoiceConfig?>(null) }
|
||||
val chatReady by connectionViewModel.chatReady.collectAsState()
|
||||
// Voice mode's /voice/transcribe and /voice/synthesize calls both go
|
||||
// over the relay, but voice can authenticate with the saved Hermes API
|
||||
// key or a paired Relay session. Gate the Mic button on voiceReady
|
||||
// so chat+voice-only setups don't need the full pairing flow.
|
||||
// Stable voice can use the standard Hermes dashboard audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable;
|
||||
// availability picks the actionable toast when neither is.
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
@@ -273,7 +293,6 @@ fun ChatScreen(
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val networkStatus by connectionViewModel.networkStatus.collectAsState()
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
@@ -348,6 +367,32 @@ fun ChatScreen(
|
||||
val clipboard = LocalClipboard.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val realtimeAgentActive = voiceStats.voiceEngineMode == "realtime_agent"
|
||||
val activeVoiceProvider = if (realtimeAgentActive) {
|
||||
realtimeAgentConfig?.default_provider
|
||||
} else {
|
||||
voiceOutputConfig?.default_provider
|
||||
}
|
||||
val activeVoiceModel = if (realtimeAgentActive) {
|
||||
realtimeAgentConfig?.default_model
|
||||
} else {
|
||||
voiceOutputConfig?.default_model
|
||||
}
|
||||
val activeVoiceName = if (realtimeAgentActive) {
|
||||
realtimeAgentConfig?.default_voice
|
||||
} else {
|
||||
voiceOutputConfig?.default_voice
|
||||
}
|
||||
val activeVoiceScope = if (realtimeAgentActive) {
|
||||
realtimeAgentConfig?.configScope
|
||||
} else {
|
||||
voiceOutputConfig?.configScope
|
||||
}
|
||||
val activeVoiceEnabled = if (realtimeAgentActive) {
|
||||
realtimeAgentConfig?.enabled
|
||||
} else {
|
||||
voiceOutputConfig?.enabled
|
||||
}
|
||||
|
||||
val showVoiceSystemOverlay: () -> Unit = {
|
||||
if (!voiceOverlayHost.hasOverlayPermission()) {
|
||||
@@ -370,13 +415,14 @@ fun ChatScreen(
|
||||
val shown = voiceOverlayHost.show(
|
||||
VoiceOverlaySession(
|
||||
uiState = voiceViewModel.uiState,
|
||||
provider = voiceOutputConfig?.default_provider,
|
||||
model = voiceOutputConfig?.default_model,
|
||||
voice = voiceOutputConfig?.default_voice,
|
||||
engineMode = voiceStats.voiceEngineMode,
|
||||
provider = activeVoiceProvider,
|
||||
model = activeVoiceModel,
|
||||
voice = activeVoiceName,
|
||||
profileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
|
||||
?: selectedProfile?.name,
|
||||
configScope = voiceOutputConfig?.configScope,
|
||||
outputEnabled = voiceOutputConfig?.enabled,
|
||||
configScope = activeVoiceScope,
|
||||
outputEnabled = activeVoiceEnabled,
|
||||
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
onStartListening = { voiceViewModel.startListening() },
|
||||
onStopListening = { voiceViewModel.stopListening() },
|
||||
@@ -417,6 +463,8 @@ fun ChatScreen(
|
||||
pendingVoiceOverlayPermission,
|
||||
voiceUiState.voiceMode,
|
||||
voiceOutputConfig,
|
||||
realtimeAgentConfig,
|
||||
voiceStats.voiceEngineMode,
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && pendingVoiceOverlayPermission) {
|
||||
@@ -448,6 +496,10 @@ fun ChatScreen(
|
||||
if (result.isSuccess) {
|
||||
voiceOutputConfig = result.getOrNull()
|
||||
}
|
||||
val realtimeResult = client.getRealtimeAgentConfig()
|
||||
if (realtimeResult.isSuccess) {
|
||||
realtimeAgentConfig = realtimeResult.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
// File picker for attachments (any file type)
|
||||
@@ -802,7 +854,8 @@ fun ChatScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.radialNavyBackground(isDarkTheme = isDarkTheme)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.14f)
|
||||
.imePadding()
|
||||
.alpha(chatAlpha)
|
||||
) {
|
||||
@@ -959,7 +1012,7 @@ fun ChatScreen(
|
||||
activeEndpoint?.let { ep ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
color = RelayRefresh.Navy3.copy(alpha = 0.78f),
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp)
|
||||
.clickable { onNavigateToConnections() },
|
||||
@@ -967,7 +1020,7 @@ fun ChatScreen(
|
||||
Text(
|
||||
text = ep.displayLabel(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
color = RelayRefresh.Relay,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 4.dp,
|
||||
@@ -975,53 +1028,45 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Ambient mode toggle (show/hide sphere visualization).
|
||||
// Profile + personality pickers moved into the agent sheet
|
||||
// that opens on title tap — the top bar no longer owns
|
||||
// those chips, reclaiming the horizontal space for a
|
||||
// cleaner title block.
|
||||
if (animationEnabled) {
|
||||
IconButton(onClick = { ambientMode = !ambientMode }) {
|
||||
Icon(
|
||||
imageVector = if (ambientMode) Icons.Filled.ChatBubble else Icons.Filled.AutoAwesome,
|
||||
contentDescription = if (ambientMode) "Show chat" else "Ambient mode",
|
||||
tint = if (ambientMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Share,
|
||||
contentDescription = "Share conversation",
|
||||
onClick = { shareConversation(context, messages) },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = "Terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
// Ambient mode (fullscreen sphere) has no top-bar toggle —
|
||||
// it's a quiet gesture: long-press the conversation
|
||||
// background to enter, tap anywhere to return. A hint pill
|
||||
// on entry teaches the way back.
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
|
||||
)
|
||||
)
|
||||
|
||||
// Offline banner
|
||||
AnimatedVisibility(
|
||||
visible = networkStatus is ConnectivityObserver.Status.Lost ||
|
||||
networkStatus is ConnectivityObserver.Status.Unavailable
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.WifiOff,
|
||||
contentDescription = "No internet",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"No internet connection",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Chat,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> Unit
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> onNavigateToBridge()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Error banner with retry
|
||||
AnimatedVisibility(visible = error != null) {
|
||||
@@ -1068,12 +1113,25 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient mode: fullscreen sphere visualization
|
||||
// Ambient mode: fullscreen sphere visualization. Tap (or
|
||||
// long-press) anywhere to return; a transient hint pill teaches
|
||||
// the exit on every entry.
|
||||
if (ambientMode && animationEnabled) {
|
||||
var showAmbientHint by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(2_800)
|
||||
showAmbientHint = false
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { ambientMode = false },
|
||||
onLongPress = { ambientMode = false },
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MorphingSphere(
|
||||
@@ -1082,6 +1140,24 @@ fun ChatScreen(
|
||||
intensity = streamingIntensity,
|
||||
toolCallBurst = toolCallBurst
|
||||
)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = showAmbientHint,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 18.dp),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "tap to return to chat",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.78f))
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Message list or empty state
|
||||
@@ -1124,42 +1200,61 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Start a conversation",
|
||||
text = if (chatReady) "Start a conversation" else "Connect to Hermes",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
if (!chatReady) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Configure API server in Settings",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Suggestion chips
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
AssistChip(
|
||||
onClick = { inputText = suggestion },
|
||||
label = {
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Chat needs a Standard Hermes API connection.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
)
|
||||
Button(
|
||||
onClick = onNavigateToConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Connect Standard Hermes")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Suggestion chips
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
AssistChip(
|
||||
onClick = { inputText = suggestion },
|
||||
label = {
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1171,6 +1266,17 @@ fun ChatScreen(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
// Quiet entry to ambient mode: long-press the
|
||||
// conversation background. Bubbles keep their own
|
||||
// long-press (copy) — they consume the gesture first,
|
||||
// so only presses on empty space land here.
|
||||
.pointerInput(animationEnabled) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
if (animationEnabled) ambientMode = true
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
// Ambient sphere behind messages
|
||||
if (animationEnabled && animationBehindChat && !ambientMode) {
|
||||
@@ -1242,6 +1348,18 @@ fun ChatScreen(
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onQuoteMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val quoted = text.take(600)
|
||||
.trim()
|
||||
.lines()
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
inputText = if (inputText.isBlank()) {
|
||||
"$quoted\n\n"
|
||||
} else {
|
||||
"$inputText\n$quoted\n\n"
|
||||
}
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
@@ -1345,6 +1463,7 @@ fun ChatScreen(
|
||||
inputText = if (cmd.command.contains(" ")) cmd.command + " " else "$base "
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1551,10 +1670,6 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Gate on voiceReady — voice mode needs a relay
|
||||
// route, but the app can derive it from the API URL
|
||||
// and authenticate with the saved Hermes API key or
|
||||
// a paired Relay session.
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (voiceReady) {
|
||||
@@ -1562,7 +1677,16 @@ fun ChatScreen(
|
||||
} else {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
"Voice needs API key or pairing, plus a reachable relay route",
|
||||
when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
} ?: "Voice needs dashboard sign-in — open Manage to sign in"
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
"Voice needs a reachable Hermes dashboard or Relay voice route"
|
||||
},
|
||||
android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
@@ -1664,13 +1788,14 @@ fun ChatScreen(
|
||||
// preserving enough recent tool/context rows for voice turns.
|
||||
transcriptMessages = messages.takeLast(12),
|
||||
showThinking = showThinking,
|
||||
voiceOutputProvider = voiceOutputConfig?.default_provider,
|
||||
voiceOutputModel = voiceOutputConfig?.default_model,
|
||||
voiceOutputVoice = voiceOutputConfig?.default_voice,
|
||||
voiceEngineMode = voiceStats.voiceEngineMode,
|
||||
voiceOutputProvider = activeVoiceProvider,
|
||||
voiceOutputModel = activeVoiceModel,
|
||||
voiceOutputVoice = activeVoiceName,
|
||||
voiceProfileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
|
||||
?: selectedProfile?.name,
|
||||
voiceConfigScope = voiceOutputConfig?.configScope,
|
||||
voiceOutputEnabled = voiceOutputConfig?.enabled,
|
||||
voiceConfigScope = activeVoiceScope,
|
||||
voiceOutputEnabled = activeVoiceEnabled,
|
||||
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
onOverlayRequest = showVoiceSystemOverlay,
|
||||
onCompactModeChange = { compact ->
|
||||
@@ -1696,6 +1821,9 @@ fun ChatScreen(
|
||||
}
|
||||
voiceViewModel.clearPermissionDeniedCallout()
|
||||
},
|
||||
onHermesConfirmationAnswer = { answer ->
|
||||
voiceViewModel.answerHermesConfirmation(answer)
|
||||
},
|
||||
// === END v0.4.1 ===
|
||||
)
|
||||
}
|
||||
@@ -1725,6 +1853,7 @@ fun ChatScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
onDismiss = { showAgentInfo = false },
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
onNavigateToProfileInspector = onNavigateToProfileInspector,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1780,3 +1909,37 @@ private fun DateSeparator(timestamp: Long) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the visible conversation as Markdown via the system share sheet.
|
||||
* Role names are matched as strings so this helper stays decoupled from the
|
||||
* MessageRole enum's package.
|
||||
*/
|
||||
private fun shareConversation(
|
||||
context: android.content.Context,
|
||||
messages: List<com.hermesandroid.relay.data.ChatMessage>,
|
||||
) {
|
||||
val body = buildString {
|
||||
appendLine("# Hermes conversation")
|
||||
appendLine()
|
||||
messages.forEach { message ->
|
||||
if (message.content.isBlank()) return@forEach
|
||||
val speaker = when {
|
||||
message.role.name.equals("user", ignoreCase = true) -> "**You:**"
|
||||
message.role.name.equals("assistant", ignoreCase = true) -> "**Hermes:**"
|
||||
else -> "**System:**"
|
||||
}
|
||||
appendLine(speaker)
|
||||
appendLine(message.content.trim())
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, body)
|
||||
putExtra(android.content.Intent.EXTRA_SUBJECT, "Hermes conversation")
|
||||
}
|
||||
context.startActivity(
|
||||
android.content.Intent.createChooser(intent, "Share conversation"),
|
||||
)
|
||||
}
|
||||
|
||||
+389
-33
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -21,6 +22,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -53,12 +55,16 @@ import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardSecurityPosture
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardStatusSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardRelayStatusSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardStandardStatusSection
|
||||
import com.hermesandroid.relay.ui.components.ApiServerInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.EndpointsCard
|
||||
import com.hermesandroid.relay.ui.components.RouteEditorDialog
|
||||
import com.hermesandroid.relay.ui.components.InsecureConnectionAckDialog
|
||||
import com.hermesandroid.relay.ui.components.RelayInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.SessionInfoSheet
|
||||
import com.hermesandroid.relay.network.RelayUrlDeriver
|
||||
import com.hermesandroid.relay.network.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
@@ -108,6 +114,7 @@ fun ConnectionsSettingsScreen(
|
||||
onRemoveConnection: (id: String) -> Unit,
|
||||
onAddConnection: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToManage: () -> Unit,
|
||||
// Opens `PairedDevicesScreen` for the server-side session list. Wired
|
||||
// via the "Relay sessions" row inside the active card's security
|
||||
// posture strip. Must not be null — the row is always rendered.
|
||||
@@ -126,6 +133,12 @@ fun ConnectionsSettingsScreen(
|
||||
// (HTTP-only) is unaffected.
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val activeRelayConfigured: Boolean = if (connectionViewModel != null) {
|
||||
val configured by connectionViewModel.relayConfigured.collectAsState()
|
||||
configured
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
// Kick a WSS reconnect on screen entry in case the user landed here
|
||||
// from a Stale chip. Moved here from the deleted singular
|
||||
@@ -190,7 +203,7 @@ fun ConnectionsSettingsScreen(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Tap Add connection to pair with a Hermes server.",
|
||||
text = "Tap Add connection to connect to Standard Hermes.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -219,6 +232,11 @@ fun ConnectionsSettingsScreen(
|
||||
// + action row) and don't collect any VM flows.
|
||||
activeConnectionViewModel = if (isActive) connectionViewModel else null,
|
||||
relayEnabled = relayEnabled,
|
||||
relayConfigured = if (isActive) {
|
||||
activeRelayConfigured
|
||||
} else {
|
||||
connection.hasConfiguredRelay()
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
onReconnect = onReconnectActive,
|
||||
onRename = { newLabel -> onRenameConnection(connection.id, newLabel) },
|
||||
@@ -226,6 +244,7 @@ fun ConnectionsSettingsScreen(
|
||||
onRevoke = { onRevokeConnection(connection.id) },
|
||||
onRemove = { onRemoveConnection(connection.id) },
|
||||
onOpenApiInfo = { showApiInfoSheet = true },
|
||||
onOpenDashboard = onNavigateToManage,
|
||||
onOpenRelayInfo = { showRelayInfoSheet = true },
|
||||
onOpenSessionInfo = { showSessionInfoSheet = true },
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
@@ -297,6 +316,7 @@ private fun ConnectionCard(
|
||||
liveState: RelayUiState?,
|
||||
activeConnectionViewModel: ConnectionViewModel?,
|
||||
relayEnabled: Boolean,
|
||||
relayConfigured: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
@@ -304,6 +324,7 @@ private fun ConnectionCard(
|
||||
onRevoke: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -315,8 +336,11 @@ private fun ConnectionCard(
|
||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||
var endpointsExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Active card: muted indigo wash instead of the full-strength Electric
|
||||
// primaryContainer — a card-sized fill of the brand blue overwhelmed the
|
||||
// body text (2026-06-10 feedback); small accents keep the vivid blue.
|
||||
val containerColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
@@ -344,6 +368,20 @@ private fun ConnectionCard(
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val routeProbeStatus: ConnectionViewModel.RouteProbeStatus =
|
||||
if (activeConnectionViewModel != null) {
|
||||
val status by activeConnectionViewModel.routeProbeStatus.collectAsState()
|
||||
status
|
||||
} else {
|
||||
ConnectionViewModel.RouteProbeStatus.Idle
|
||||
}
|
||||
val routeProbeOutcomes: Map<String, RouteProbeOutcome> =
|
||||
if (activeConnectionViewModel != null) {
|
||||
val outcomes by activeConnectionViewModel.routeProbeOutcomes.collectAsState()
|
||||
outcomes
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -378,9 +416,13 @@ private fun ConnectionCard(
|
||||
|
||||
// ── Subtitle: hostname + status + endpoints roles ──────────
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hasStandardApi = connection.apiServerUrl.isNotBlank()
|
||||
val pairedStatus = when {
|
||||
liveState != null -> liveState.statusText(connectedLabel = "Connected")
|
||||
liveState != null &&
|
||||
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
|
||||
liveState.statusText(connectedLabel = "Connected")
|
||||
connection.pairedAt != null -> formatPairedRelative(connection.pairedAt)
|
||||
hasStandardApi -> "Standard · Relay not paired"
|
||||
else -> "Not paired"
|
||||
}
|
||||
// ADR 24 — active-only endpoint role summary.
|
||||
@@ -406,8 +448,16 @@ private fun ConnectionCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
ConnectionSurfaceSummary(
|
||||
connection = connection,
|
||||
isActive = isActive,
|
||||
liveState = liveState,
|
||||
activeConnectionViewModel = activeConnectionViewModel,
|
||||
relayConfigured = relayConfigured,
|
||||
)
|
||||
|
||||
// ── Single-endpoint nudge (active only) ──────────────────────
|
||||
if (isActive && endpoints.size == 1) {
|
||||
if (isActive && connection.pairedAt != null && endpoints.size == 1) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -449,8 +499,12 @@ private fun ConnectionCard(
|
||||
TextButton(onClick = onReconnect) { Text("Reconnect") }
|
||||
}
|
||||
TextButton(onClick = { showRenameDialog = true }) { Text("Rename") }
|
||||
TextButton(onClick = onRepair) { Text("Re-pair") }
|
||||
TextButton(onClick = { showRevokeConfirm = true }) { Text("Revoke") }
|
||||
TextButton(onClick = onRepair) {
|
||||
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
|
||||
}
|
||||
if (connection.pairedAt != null) {
|
||||
TextButton(onClick = { showRevokeConfirm = true }) { Text("Revoke") }
|
||||
}
|
||||
TextButton(onClick = { showRemoveConfirm = true }) {
|
||||
Text(text = "Remove", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
@@ -463,21 +517,29 @@ private fun ConnectionCard(
|
||||
if (isActive && activeConnectionViewModel != null) {
|
||||
HorizontalDivider()
|
||||
|
||||
// ── Connection health section ────────────────────────────
|
||||
SectionHeader(text = "Connection health")
|
||||
SectionCaption(text = "Tap any row for details.")
|
||||
// ── Standard section ─────────────────────────────────────
|
||||
SectionHeader(text = "Standard")
|
||||
SectionCaption(text = "API and dashboard setup for Chat and Manage.")
|
||||
|
||||
// Status section (3 tappable rows → info sheets). Always
|
||||
// visible on the active card — the "health dashboard"
|
||||
// replacing the old Settings-top quick-look card.
|
||||
ActiveCardStatusSection(
|
||||
ActiveCardStandardStatusSection(
|
||||
connectionViewModel = activeConnectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
onOpenApiInfo = onOpenApiInfo,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
onOpenSessionInfo = onOpenSessionInfo,
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
HorizontalDivider()
|
||||
SectionHeader(text = "Relay")
|
||||
SectionCaption(
|
||||
text = "Optional power tools: Terminal, Bridge, relay sessions, and grants.",
|
||||
)
|
||||
ActiveCardRelayStatusSection(
|
||||
connectionViewModel = activeConnectionViewModel,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
onOpenSessionInfo = onOpenSessionInfo,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Routes section (conditional on having endpoints) ─────
|
||||
// ADR 24 behavior preserved verbatim from pre-refactor;
|
||||
// user-facing copy now reads "Routes" instead of
|
||||
@@ -493,6 +555,17 @@ private fun ConnectionCard(
|
||||
var preferredRole by remember(connection.id) {
|
||||
mutableStateOf(activeConnectionViewModel.getPreferredEndpointRole())
|
||||
}
|
||||
// Live transient override — set by "Use now" (or by
|
||||
// preference restoration, in which case it equals
|
||||
// preferredRole). A manual switch is the differing case.
|
||||
val manualOverrideRole by
|
||||
activeConnectionViewModel.manualRouteOverride.collectAsState()
|
||||
val manualSwitchActive = manualOverrideRole != null &&
|
||||
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
|
||||
var routeEditorOpen by remember(connection.id) { mutableStateOf(false) }
|
||||
var routeEditorOriginal by remember(connection.id) {
|
||||
mutableStateOf<EndpointCandidate?>(null)
|
||||
}
|
||||
val hasTailscaleRoute = endpoints.any {
|
||||
it.role.equals("tailscale", ignoreCase = true)
|
||||
}
|
||||
@@ -507,16 +580,50 @@ private fun ConnectionCard(
|
||||
val tailscaleLaunchIntent = remember(context) {
|
||||
context.packageManager.getLaunchIntentForPackage("com.tailscale.ipn")
|
||||
}
|
||||
val activeRouteLabel = activeEndpoint?.displayLabel() ?: "Resolving"
|
||||
val activeRouteHost = activeEndpoint?.let {
|
||||
"${it.api.host}:${it.api.port}"
|
||||
} ?: connection.relayUrl
|
||||
val isRouteProbing =
|
||||
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Probing
|
||||
// Last user-triggered probe finished with NO winner: say
|
||||
// so explicitly. The old UI sat on "Resolving" forever
|
||||
// and showed the (internal) relay URL underneath, which
|
||||
// read as "stuck on the internal route".
|
||||
val probeCameUpEmpty = activeEndpoint == null &&
|
||||
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Done &&
|
||||
routeProbeStatus.winner == null
|
||||
val activeRouteLabel = when {
|
||||
activeEndpoint != null -> activeEndpoint.displayLabel()
|
||||
isRouteProbing -> "Checking routes…"
|
||||
probeCameUpEmpty -> "No route reachable"
|
||||
else -> "Resolving"
|
||||
}
|
||||
// Full URL (scheme included) — http vs https is the
|
||||
// difference between a working route and a TLS-failing
|
||||
// one, so never hide it. With no resolved route, show the
|
||||
// saved API URL the app is actually falling back to.
|
||||
val activeRouteHost = activeEndpoint?.api?.url
|
||||
?: "Using saved URL: ${
|
||||
connection.apiServerUrl.ifBlank { connection.relayUrl }
|
||||
}"
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = "Current: $activeRouteLabel",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Current: $activeRouteLabel",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (probeCameUpEmpty) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
if (isRouteProbing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = activeRouteHost,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -524,6 +631,15 @@ private fun ConnectionCard(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (probeCameUpEmpty) {
|
||||
Text(
|
||||
text = "None of the saved routes answered a health " +
|
||||
"probe. Expand the routes below for per-route " +
|
||||
"reasons.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showTailscaleUnavailableHint) {
|
||||
Surface(
|
||||
@@ -566,27 +682,75 @@ private fun ConnectionCard(
|
||||
}
|
||||
TextButton(
|
||||
onClick = { activeConnectionViewModel.probeNow() },
|
||||
enabled = !isRouteProbing,
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 0.dp,
|
||||
),
|
||||
) {
|
||||
Text("Re-check")
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isTailscaleDetected && !hasTailscaleRoute) {
|
||||
// Inverse of the hint above: the phone is on Tailscale
|
||||
// but this connection has nothing to roam to. This is
|
||||
// the strongest signal a user wants remote access and
|
||||
// simply never configured it — offer the route editor
|
||||
// directly instead of hoping they find Show routes.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Phone is on Tailscale — no Tailscale route yet",
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
routeEditorOriginal = null
|
||||
routeEditorOpen = true
|
||||
},
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 0.dp,
|
||||
),
|
||||
) {
|
||||
Text("Add Tailscale route")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = { activeConnectionViewModel.probeNow() }) {
|
||||
Text("Re-check")
|
||||
TextButton(
|
||||
onClick = { activeConnectionViewModel.probeNow() },
|
||||
enabled = !isRouteProbing,
|
||||
) {
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
}
|
||||
if (preferredRole != null) {
|
||||
if (preferredRole != null || manualSwitchActive) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Clears both layers: the persisted
|
||||
// preference and any live manual switch.
|
||||
activeConnectionViewModel.setPreferredEndpointRole(null)
|
||||
preferredRole = null
|
||||
},
|
||||
@@ -621,12 +785,24 @@ private fun ConnectionCard(
|
||||
EndpointsCard(
|
||||
endpoints = endpoints,
|
||||
activeEndpoint = activeEndpoint,
|
||||
isProbing = isRouteProbing,
|
||||
outcomeFor = { candidate ->
|
||||
routeProbeOutcomes[activeConnectionViewModel.routeOutcomeKey(candidate)]
|
||||
},
|
||||
preferredRole = preferredRole,
|
||||
manualOverrideRole = manualOverrideRole,
|
||||
onUseNow = { candidate ->
|
||||
// Transient switch — no preference write.
|
||||
activeConnectionViewModel.useRouteNow(candidate.role)
|
||||
},
|
||||
onCancelUseNow = {
|
||||
activeConnectionViewModel.useRouteNow(null)
|
||||
},
|
||||
onPreferEndpoint = { candidate ->
|
||||
activeConnectionViewModel.setPreferredEndpointRole(candidate.role)
|
||||
preferredRole = candidate.role
|
||||
},
|
||||
onClearOverride = {
|
||||
onClearPreferred = {
|
||||
activeConnectionViewModel.setPreferredEndpointRole(null)
|
||||
preferredRole = null
|
||||
},
|
||||
@@ -634,6 +810,34 @@ private fun ConnectionCard(
|
||||
onViewPin = { candidate ->
|
||||
activeConnectionViewModel.lookupEndpointPin(candidate)
|
||||
},
|
||||
onAddRoute = {
|
||||
routeEditorOriginal = null
|
||||
routeEditorOpen = true
|
||||
},
|
||||
onEditRoute = { candidate ->
|
||||
routeEditorOriginal = candidate
|
||||
routeEditorOpen = true
|
||||
},
|
||||
onRemoveRoute = { candidate ->
|
||||
activeConnectionViewModel.removeExtraRoute(candidate)
|
||||
},
|
||||
)
|
||||
}
|
||||
// Rendered outside the routes expander so the "Add
|
||||
// Tailscale route" nudge above can open it while the
|
||||
// routes list is collapsed.
|
||||
if (routeEditorOpen) {
|
||||
RouteEditorDialog(
|
||||
original = routeEditorOriginal,
|
||||
onSave = { role, apiUrl, onResult ->
|
||||
activeConnectionViewModel.saveExtraRoute(
|
||||
role = role,
|
||||
apiUrl = apiUrl,
|
||||
original = routeEditorOriginal,
|
||||
onResult = onResult,
|
||||
)
|
||||
},
|
||||
onDismiss = { routeEditorOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -643,11 +847,11 @@ private fun ConnectionCard(
|
||||
// ── Advanced section ─────────────────────────────────────
|
||||
// Header + caption above the collapsed Advanced card so
|
||||
// users understand this branch is a power-user surface,
|
||||
// not something they're expected to touch after QR pairing.
|
||||
// not something they're expected to touch after Standard setup.
|
||||
SectionHeader(text = "Advanced")
|
||||
SectionCaption(
|
||||
text = "Manual setup — most people don't need this " +
|
||||
"after QR pairing.",
|
||||
"after Standard Hermes setup.",
|
||||
)
|
||||
|
||||
// Advanced expander: manual URL config + insecure toggle
|
||||
@@ -739,6 +943,158 @@ private fun ConnectionCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionSurfaceSummary(
|
||||
connection: Connection,
|
||||
isActive: Boolean,
|
||||
liveState: RelayUiState?,
|
||||
activeConnectionViewModel: ConnectionViewModel?,
|
||||
relayConfigured: Boolean,
|
||||
) {
|
||||
val activeApiReachable: Boolean? = if (activeConnectionViewModel != null) {
|
||||
val reachable by activeConnectionViewModel.apiServerReachable.collectAsState()
|
||||
reachable
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val activeApiHealth: ConnectionViewModel.HealthStatus? = if (activeConnectionViewModel != null) {
|
||||
val health by activeConnectionViewModel.apiServerHealth.collectAsState()
|
||||
health
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val activeConnection: Connection? = if (activeConnectionViewModel != null) {
|
||||
val current by activeConnectionViewModel.activeConnection.collectAsState()
|
||||
current
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val dashboardStatus = (activeConnection ?: connection).dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
|
||||
val apiText = when {
|
||||
connection.apiServerUrl.isBlank() -> "Missing"
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
|
||||
activeApiReachable == true -> "Ready"
|
||||
isActive && activeApiReachable == false -> "Offline"
|
||||
else -> "Configured"
|
||||
}
|
||||
val apiTone = when (apiText) {
|
||||
"Ready" -> SummaryTone.Good
|
||||
"Offline", "Missing" -> SummaryTone.Warning
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
val dashboardText = when {
|
||||
connection.resolvedDashboardUrl.isBlank() -> "Missing"
|
||||
dashboardStatus == null -> "Unchecked"
|
||||
!dashboardStatus.reachable -> "Offline"
|
||||
dashboardSignInRequired -> "Sign in"
|
||||
dashboardStatus.authenticated == true -> "Signed in"
|
||||
else -> "Available"
|
||||
}
|
||||
val dashboardTone = when (dashboardText) {
|
||||
"Signed in", "Available" -> SummaryTone.Good
|
||||
"Sign in" -> SummaryTone.Info
|
||||
"Offline", "Missing" -> SummaryTone.Warning
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
val relayText = when {
|
||||
!relayConfigured -> "Optional"
|
||||
liveState != null -> liveState.statusText(connectedLabel = "Ready")
|
||||
connection.pairedAt != null -> "Paired"
|
||||
connection.relayUrl.isNotBlank() -> "Configured"
|
||||
else -> "Configure"
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayConfigured -> SummaryTone.Neutral
|
||||
liveState == RelayUiState.Connected -> SummaryTone.Good
|
||||
liveState == RelayUiState.Stale || liveState == RelayUiState.Disconnected -> SummaryTone.Warning
|
||||
else -> SummaryTone.Info
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConnectionSurfacePill(
|
||||
label = "API",
|
||||
value = apiText,
|
||||
tone = apiTone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ConnectionSurfacePill(
|
||||
label = "Dashboard",
|
||||
value = dashboardText,
|
||||
tone = dashboardTone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ConnectionSurfacePill(
|
||||
label = "Relay",
|
||||
value = relayText,
|
||||
tone = relayTone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SummaryTone { Neutral, Good, Info, Warning }
|
||||
|
||||
@Composable
|
||||
private fun ConnectionSurfacePill(
|
||||
label: String,
|
||||
value: String,
|
||||
tone: SummaryTone,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = when (tone) {
|
||||
SummaryTone.Good -> MaterialTheme.colorScheme.primaryContainer
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.errorContainer
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val content = when (tone) {
|
||||
SummaryTone.Good -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = container,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = content,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = content,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.hasConfiguredRelay(): Boolean {
|
||||
val trimmedRelayUrl = relayUrl.trim()
|
||||
return pairedAt != null ||
|
||||
trimmedRelayUrl.isNotBlank() &&
|
||||
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameConnectionDialog(
|
||||
initialLabel: String,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.network.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.DashboardStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Summary models for the Manage tab's section payloads. These used to be
|
||||
* private to DashboardManagementScreen.kt; they moved here (internal +
|
||||
* @Serializable) so [DashboardManageDiskCache] can persist Loaded cache
|
||||
* entries across process death without a parallel DTO layer.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class DashboardSummaryItem(
|
||||
val id: String = "",
|
||||
val title: String,
|
||||
val subtitle: String? = null,
|
||||
val meta: String? = null,
|
||||
val profile: String? = null,
|
||||
val actions: List<DashboardItemAction> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class DashboardItemAction(
|
||||
val label: String,
|
||||
val kind: DashboardActionKind,
|
||||
val destructive: Boolean = false,
|
||||
)
|
||||
|
||||
internal enum class DashboardActionKind {
|
||||
EnableSkill,
|
||||
DisableSkill,
|
||||
ViewCronRuns,
|
||||
PauseCron,
|
||||
ResumeCron,
|
||||
TriggerCron,
|
||||
DeleteCron,
|
||||
EnableMcp,
|
||||
DisableMcp,
|
||||
TestMcp,
|
||||
RemoveMcp,
|
||||
InstallMcpCatalog,
|
||||
ViewProfileSoul,
|
||||
ActivateProfile,
|
||||
DeleteProfile,
|
||||
|
||||
// Input-backed kinds — intercepted before runAction and routed to a
|
||||
// text-input or model-picker dialog instead of firing immediately.
|
||||
SetEnvKey,
|
||||
EditProfileDescription,
|
||||
SetProfileModel,
|
||||
EditProfileSoul,
|
||||
|
||||
// Direct env actions.
|
||||
RevealEnvKey,
|
||||
ClearEnvKey,
|
||||
}
|
||||
|
||||
/**
|
||||
* Disk mirror of one Loaded cache entry. `fetchedAtMillis` survives the
|
||||
* round trip on purpose: hydrated entries are older than the
|
||||
* stale-while-revalidate window, so the existing refresh path treats them
|
||||
* as "render now, re-fetch quietly" with zero extra logic.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class PersistedDashboardPayload(
|
||||
val status: DashboardStatus? = null,
|
||||
val session: DashboardAuthSession? = null,
|
||||
val items: List<DashboardSummaryItem> = emptyList(),
|
||||
val rawSummary: String = "",
|
||||
val fetchedAtMillis: Long = 0L,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class DashboardManageCacheFile(
|
||||
val version: Int = DashboardManageDiskCache.SCHEMA_VERSION,
|
||||
val entries: Map<String, PersistedDashboardPayload> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Plain-JSON disk persistence for the Manage tab's payload cache, so a cold
|
||||
* app start (new process) renders the last-seen dashboard data instantly
|
||||
* instead of skeletons.
|
||||
*
|
||||
* Deliberately a flat file under `cacheDir` and NOT EncryptedSharedPrefs:
|
||||
* `cacheDir` is already app-private, the payload carries no credentials
|
||||
* (dashboard cookies live in their own encrypted store), and every
|
||||
* EncryptedSharedPreferences construction pays a multi-second process-global
|
||||
* Keystore lock on StrongBox devices — the exact mechanism behind the
|
||||
* frozen-sphere startup incident. The OS may evict `cacheDir` under disk
|
||||
* pressure; that simply degrades back to the skeleton-then-fetch path.
|
||||
*
|
||||
* Keys are the in-memory cache's `"connectionId|dashboardUrl|sectionPath"`
|
||||
* strings, so LAN- and Tailscale-keyed entries coexist and a remote cold
|
||||
* start hydrates the route it will actually use.
|
||||
*/
|
||||
internal object DashboardManageDiskCache {
|
||||
const val SCHEMA_VERSION = 1
|
||||
private const val FILE_NAME = "dashboard-manage-cache.json"
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/** Serializes writers; reads are lock-free (rename makes writes atomic). */
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
fun cacheFileFor(directory: File): File = File(directory, FILE_NAME)
|
||||
|
||||
fun encode(entries: Map<String, PersistedDashboardPayload>): String =
|
||||
json.encodeToString(
|
||||
DashboardManageCacheFile.serializer(),
|
||||
DashboardManageCacheFile(version = SCHEMA_VERSION, entries = entries),
|
||||
)
|
||||
|
||||
/** Corrupt, unreadable, or version-mismatched content decodes to empty. */
|
||||
fun decode(text: String): Map<String, PersistedDashboardPayload> = try {
|
||||
val parsed = json.decodeFromString(DashboardManageCacheFile.serializer(), text)
|
||||
if (parsed.version == SCHEMA_VERSION) parsed.entries else emptyMap()
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
suspend fun read(directory: File): Map<String, PersistedDashboardPayload> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = cacheFileFor(directory)
|
||||
if (!file.isFile) return@withContext emptyMap()
|
||||
try {
|
||||
decode(file.readText())
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun write(directory: File, entries: Map<String, PersistedDashboardPayload>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
val file = cacheFileFor(directory)
|
||||
val tmp = File(directory, "$FILE_NAME.tmp")
|
||||
tmp.writeText(encode(entries))
|
||||
if (!tmp.renameTo(file)) {
|
||||
// Windows-style rename-over-existing failure — fall
|
||||
// back to delete + rename (still app-private dir).
|
||||
file.delete()
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort cache — never let a disk hiccup surface.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear(directory: File) {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
cacheFileFor(directory).delete()
|
||||
} catch (_: Exception) {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,8 @@ fun DeveloperSettingsScreen(
|
||||
// Data management local state — unfolded from the private
|
||||
// DataManagementSection helper in the old SettingsScreen.
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var backupJson by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// SAF file picker for export
|
||||
@@ -196,17 +198,12 @@ fun DeveloperSettingsScreen(
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Save settings to a file (no tokens or API keys)",
|
||||
text = "Full backup with API keys, tokens, and dashboard cookies",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-backup.json")
|
||||
}
|
||||
}) {
|
||||
IconButton(onClick = { showExportDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FileDownload,
|
||||
contentDescription = "Export settings"
|
||||
@@ -226,14 +223,12 @@ fun DeveloperSettingsScreen(
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Restore settings from a backup file",
|
||||
text = "Restore full backup and replace saved connections",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}) {
|
||||
IconButton(onClick = { showImportDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FileUpload,
|
||||
contentDescription = "Import settings"
|
||||
@@ -400,12 +395,73 @@ fun DeveloperSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExportDialog = false },
|
||||
title = { Text("Export sensitive backup?") },
|
||||
text = {
|
||||
Text(
|
||||
"This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Export")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExportDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = { Text("Import backup?") },
|
||||
text = {
|
||||
Text(
|
||||
"Importing a backup can restore API keys, relay tokens, device IDs, and dashboard cookies. It replaces the saved connection list on this device."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showImportDialog = false
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}
|
||||
) {
|
||||
Text("Choose file")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImportDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation dialog for data reset
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text("Reset All Data?") },
|
||||
text = { Text("This will clear all settings, API keys, authentication tokens, and cached data. You'll need to reconfigure your API server and re-pair with your relay. This cannot be undone.") },
|
||||
title = { Text("Reset all app data?") },
|
||||
text = {
|
||||
Text(
|
||||
"This clears saved connections, API keys, Relay tokens, dashboard cookies, device IDs, settings, and cached data. Use dashboard sign out or Relay pairing controls when you only need to clear one connection path. This cannot be undone."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
||||
@@ -122,10 +122,17 @@ fun MediaSettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Controls how the app handles files sent by tool results (screenshots, PDFs, etc.) over the relay.",
|
||||
text = "Controls how the app handles files sent by tool results " +
|
||||
"(screenshots, PDFs, etc.) over the relay.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Relay only — these settings don't affect images you attach " +
|
||||
"in chat or anything on a standard (no-Relay) connection.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Max inbound attachment size — 5..100 MB in 5 MB steps
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
|
||||
@@ -21,19 +21,17 @@ import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
/**
|
||||
* Full-screen pairing route. Wraps [ConnectionWizard] in a real Scaffold so
|
||||
* Full-screen connection route. Wraps [ConnectionWizard] in a real Scaffold so
|
||||
* the chooser tiles, manual-entry forms, and camera viewport all get the
|
||||
* actual window — not a Compose Dialog that leaked the Settings cards
|
||||
* underneath. Reached via Settings → Connection → Pair (or any "Re-pair"
|
||||
* underneath. Reached via Settings → Connections → Add/Pair Relay (or any "Re-pair"
|
||||
* button), and pops back to wherever it came from on complete or cancel.
|
||||
*
|
||||
* [autoStart] lets the caller deep-link into a specific pair method. When
|
||||
* set to `"scan"`, the wizard jumps straight to camera-permission-request
|
||||
* → scanner on first composition. Null (default) shows the full Method
|
||||
* chooser so users can pick Scan / Enter code / Show code. The "Add
|
||||
* connection" FAB sets this to `"scan"` because there's exactly one
|
||||
* obvious next step after "I want a new connection"; re-pair flows
|
||||
* intentionally leave it null.
|
||||
* chooser so users can pick Standard API/dashboard setup or a Relay pairing
|
||||
* method.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -41,6 +39,7 @@ fun PairScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onManageSignIn: (() -> Unit)? = null,
|
||||
autoStart: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -56,7 +55,7 @@ fun PairScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Pair with your server") },
|
||||
title = { Text("Connect to Hermes") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
@@ -77,10 +76,11 @@ fun PairScreen(
|
||||
ConnectionWizard(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {
|
||||
Toast.makeText(context, "Paired successfully", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Connection updated", Toast.LENGTH_SHORT).show()
|
||||
onComplete()
|
||||
},
|
||||
onCancel = onCancel,
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = false,
|
||||
autoStart = autoStart,
|
||||
)
|
||||
|
||||
+204
-78
@@ -41,6 +41,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.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -56,10 +57,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.audio.RealtimePcmPlayer
|
||||
import com.hermesandroid.relay.audio.RealtimePcmRecorder
|
||||
import com.hermesandroid.relay.network.RealtimeAgentSessionControl
|
||||
import com.hermesandroid.relay.network.RealtimeVoiceConfig
|
||||
import com.hermesandroid.relay.network.RealtimeVoiceEvent
|
||||
import com.hermesandroid.relay.network.RealtimeVoiceSummary
|
||||
import com.hermesandroid.relay.network.RelayVoiceClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Base64
|
||||
|
||||
@@ -75,7 +79,7 @@ fun RealtimeVoiceTestScreen(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
val player = remember { RealtimePcmPlayer() }
|
||||
val player = remember { RealtimePcmPlayer(context) }
|
||||
val recorder = remember { RealtimePcmRecorder() }
|
||||
val waveform = remember { mutableStateListOf<Float>() }
|
||||
val events = remember { mutableStateListOf<String>() }
|
||||
@@ -87,28 +91,49 @@ fun RealtimeVoiceTestScreen(
|
||||
var status by remember { mutableStateOf("Idle") }
|
||||
var summary by remember { mutableStateOf<RealtimeVoiceSummary?>(null) }
|
||||
var running by remember { mutableStateOf(false) }
|
||||
var recording by remember { mutableStateOf(false) }
|
||||
var micLevel by remember { mutableFloatStateOf(0f) }
|
||||
var transcript by remember { mutableStateOf("") }
|
||||
|
||||
// Mic demo state machine: tap to start recording, tap again to stop & send.
|
||||
// The captured speech runs through the agent path (STT + Hermes + speech).
|
||||
val startMicDemo: () -> Unit = {
|
||||
transcript = ""
|
||||
waveform.clear()
|
||||
recording = true
|
||||
status = "Recording — tap Stop & send"
|
||||
scope.launch {
|
||||
val pcm = try {
|
||||
recorder.captureUntilStopped(onLevel = { micLevel = it })
|
||||
} catch (e: Exception) {
|
||||
status = "Mic capture failed: ${e.message}"
|
||||
ByteArray(0)
|
||||
}
|
||||
recording = false
|
||||
micLevel = 0f
|
||||
if (pcm.size < 3_200) { // < ~100ms @ 16kHz → nothing useful captured
|
||||
status = "Recording too short"
|
||||
return@launch
|
||||
}
|
||||
runRealtimeAgentMic(
|
||||
voiceClient = voiceClient,
|
||||
player = player,
|
||||
pcm = pcm,
|
||||
mainHandler = mainHandler,
|
||||
events = events,
|
||||
scope = scope,
|
||||
onStatus = { status = it },
|
||||
onTranscript = { transcript = it },
|
||||
onSummary = { summary = it },
|
||||
onRunning = { running = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
scope.launch {
|
||||
runRealtimeDemo(
|
||||
voiceClient = voiceClient,
|
||||
recorder = recorder,
|
||||
player = player,
|
||||
prompt = prompt,
|
||||
mainHandler = mainHandler,
|
||||
waveform = waveform,
|
||||
events = events,
|
||||
onStatus = { status = it },
|
||||
onSummary = { summary = it },
|
||||
onRunning = { running = it },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
status = "Microphone permission denied"
|
||||
}
|
||||
if (granted) startMicDemo() else status = "Microphone permission denied"
|
||||
}
|
||||
|
||||
LaunchedEffect(voiceClient) {
|
||||
@@ -122,6 +147,29 @@ fun RealtimeVoiceTestScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Drive the waveform from the playback cursor (~30Hz) so each bar reflects
|
||||
// the audio at the hardware head right now — matching what is audible — and
|
||||
// continues through the post-stream drain. This replaces the old arrival-time
|
||||
// RMS feed, which led the audio by the prebuffer + AudioTrack buffer depth
|
||||
// and looked laggy.
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
when {
|
||||
// While recording, show the live mic input level.
|
||||
recording -> {
|
||||
waveform.add(micLevel)
|
||||
while (waveform.size > 64) waveform.removeAt(0)
|
||||
}
|
||||
// During playback, show the audio at the hardware cursor.
|
||||
player.isActive -> {
|
||||
waveform.add(player.playbackAmplitude())
|
||||
while (waveform.size > 64) waveform.removeAt(0)
|
||||
}
|
||||
}
|
||||
delay(33)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { player.stop() }
|
||||
}
|
||||
@@ -182,7 +230,16 @@ fun RealtimeVoiceTestScreen(
|
||||
onValueChange = { prompt = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
label = { Text("Test prompt") },
|
||||
label = { Text("Text demo prompt (spoken back via provider)") },
|
||||
enabled = !running && !recording,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Mic demo speaks to the agent: it transcribes what you say, " +
|
||||
"Hermes responds, and the reply is spoken back. Text demo just " +
|
||||
"synthesizes the prompt above (no mic, no agent).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
@@ -191,27 +248,18 @@ fun RealtimeVoiceTestScreen(
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
scope.launch {
|
||||
runRealtimeDemo(
|
||||
voiceClient = voiceClient,
|
||||
recorder = recorder,
|
||||
player = player,
|
||||
prompt = prompt,
|
||||
mainHandler = mainHandler,
|
||||
waveform = waveform,
|
||||
events = events,
|
||||
onStatus = { status = it },
|
||||
onSummary = { summary = it },
|
||||
onRunning = { running = it },
|
||||
)
|
||||
}
|
||||
if (recording) {
|
||||
recorder.requestStop()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
startMicDemo()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !running && voiceClient != null,
|
||||
@@ -219,26 +267,27 @@ fun RealtimeVoiceTestScreen(
|
||||
) {
|
||||
Icon(Icons.Filled.Mic, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Mic demo")
|
||||
Text(if (recording) "Stop & send" else "Mic demo")
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
transcript = ""
|
||||
waveform.clear()
|
||||
scope.launch {
|
||||
runRealtimeDemo(
|
||||
voiceClient = voiceClient,
|
||||
recorder = null,
|
||||
player = player,
|
||||
prompt = prompt,
|
||||
mainHandler = mainHandler,
|
||||
waveform = waveform,
|
||||
events = events,
|
||||
onStatus = { status = it },
|
||||
onTranscript = { transcript = it },
|
||||
onSummary = { summary = it },
|
||||
onRunning = { running = it },
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !running && voiceClient != null,
|
||||
enabled = !running && !recording && voiceClient != null,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||
@@ -248,6 +297,15 @@ fun RealtimeVoiceTestScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (transcript.isNotBlank()) {
|
||||
SectionCard(title = "Transcript") {
|
||||
Text(
|
||||
text = transcript,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "Waveform") {
|
||||
RealtimeWaveform(values = waveform.toList())
|
||||
summary?.let { item ->
|
||||
@@ -281,35 +339,65 @@ fun RealtimeVoiceTestScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Audio for both demos arrives as voice.audio.delta (provider) or
|
||||
// voice.output_audio.delta (agent); isAudioDelta covers both. The waveform is
|
||||
// NOT fed here — a ticker in the composable samples the playback cursor so it
|
||||
// tracks what is audible, not what just arrived over the socket.
|
||||
private fun writeEventAudio(event: RealtimeVoiceEvent, player: RealtimePcmPlayer) {
|
||||
if (!event.isAudioDelta) return
|
||||
val audio = event.audioBase64?.let {
|
||||
runCatching { Base64.getDecoder().decode(it) }.getOrNull()
|
||||
} ?: return
|
||||
if (audio.isNotEmpty()) {
|
||||
player.write(audio, event.sampleRate ?: 24_000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logLabEvent(
|
||||
event: RealtimeVoiceEvent,
|
||||
mainHandler: Handler,
|
||||
events: MutableList<String>,
|
||||
) {
|
||||
mainHandler.post {
|
||||
val suffix = when {
|
||||
event.byteCount != null -> " ${event.byteCount}b"
|
||||
event.message != null -> " ${event.message}"
|
||||
else -> ""
|
||||
}
|
||||
events.add("${event.type}$suffix")
|
||||
while (events.size > 64) events.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text demo — raw provider TTS. Sends the typed prompt to /voice/realtime/ and
|
||||
* plays the synthesized speech. No mic, no STT: this exercises the provider's
|
||||
* audio-output path in isolation.
|
||||
*/
|
||||
private suspend fun runRealtimeDemo(
|
||||
voiceClient: RelayVoiceClient?,
|
||||
recorder: RealtimePcmRecorder?,
|
||||
player: RealtimePcmPlayer,
|
||||
prompt: String,
|
||||
mainHandler: Handler,
|
||||
waveform: MutableList<Float>,
|
||||
events: MutableList<String>,
|
||||
onStatus: (String) -> Unit,
|
||||
onTranscript: (String) -> Unit,
|
||||
onSummary: (RealtimeVoiceSummary?) -> Unit,
|
||||
onRunning: (Boolean) -> Unit,
|
||||
) {
|
||||
val client = voiceClient ?: return
|
||||
onRunning(true)
|
||||
onSummary(null)
|
||||
waveform.clear()
|
||||
events.clear()
|
||||
onStatus(if (recorder == null) "Opening websocket" else "Capturing mic")
|
||||
val pcm = try {
|
||||
recorder?.capture() ?: ByteArray(320)
|
||||
} catch (e: Exception) {
|
||||
onStatus("Mic capture failed: ${e.message}")
|
||||
onRunning(false)
|
||||
return
|
||||
}
|
||||
onStatus("Streaming")
|
||||
val result = client.runRealtimeDemo(prompt, pcm) { event ->
|
||||
handleRealtimeEvent(event, player, mainHandler, waveform, events)
|
||||
val result = client.runRealtimeDemo(prompt, ByteArray(0)) { event ->
|
||||
writeEventAudio(event, player)
|
||||
logLabEvent(event, mainHandler, events)
|
||||
if (event.type == "voice.transcript.final") {
|
||||
event.text?.let { t -> mainHandler.post { onTranscript("Spoken: $t") } }
|
||||
}
|
||||
}
|
||||
player.flushBufferedPlayback()
|
||||
if (result.isSuccess) {
|
||||
onSummary(result.getOrNull())
|
||||
onStatus("Complete")
|
||||
@@ -319,34 +407,72 @@ private suspend fun runRealtimeDemo(
|
||||
onRunning(false)
|
||||
}
|
||||
|
||||
private fun handleRealtimeEvent(
|
||||
event: RealtimeVoiceEvent,
|
||||
/**
|
||||
* Mic demo — full agent path. Sends the captured speech to /voice/realtime-agent/,
|
||||
* which transcribes it (surfaced as voice.input_transcript.*), lets Hermes respond,
|
||||
* and streams the spoken reply back. This is the genuinely conversational test:
|
||||
* what you say drives the response, and the transcript shows what was heard.
|
||||
*/
|
||||
private suspend fun runRealtimeAgentMic(
|
||||
voiceClient: RelayVoiceClient?,
|
||||
player: RealtimePcmPlayer,
|
||||
pcm: ByteArray,
|
||||
mainHandler: Handler,
|
||||
waveform: MutableList<Float>,
|
||||
events: MutableList<String>,
|
||||
scope: CoroutineScope,
|
||||
onStatus: (String) -> Unit,
|
||||
onTranscript: (String) -> Unit,
|
||||
onSummary: (RealtimeVoiceSummary?) -> Unit,
|
||||
onRunning: (Boolean) -> Unit,
|
||||
) {
|
||||
if (event.type == "voice.audio.delta") {
|
||||
val audio = event.audioBase64?.let {
|
||||
runCatching { Base64.getDecoder().decode(it) }.getOrNull()
|
||||
}
|
||||
if (audio != null) {
|
||||
player.write(audio, event.sampleRate ?: 24_000)
|
||||
val client = voiceClient ?: return
|
||||
onRunning(true)
|
||||
onSummary(null)
|
||||
events.clear()
|
||||
onStatus("Transcribing + thinking")
|
||||
val heard = StringBuilder()
|
||||
val result = client.runRealtimeAgent(
|
||||
prompt = "",
|
||||
inputPcm = pcm,
|
||||
inputSampleRate = 16_000,
|
||||
) { event, control ->
|
||||
writeEventAudio(event, player)
|
||||
logLabEvent(event, mainHandler, events)
|
||||
when (event.type) {
|
||||
"voice.input_transcript.delta" -> {
|
||||
event.delta?.let { heard.append(it) }
|
||||
val text = heard.toString()
|
||||
mainHandler.post { onTranscript("You said: $text") }
|
||||
}
|
||||
"voice.input_transcript.final" -> {
|
||||
val text = event.text ?: heard.toString()
|
||||
mainHandler.post {
|
||||
onTranscript("You said: $text")
|
||||
onStatus("Speaking")
|
||||
}
|
||||
}
|
||||
"voice.playback_drain.requested" -> {
|
||||
// The agent gates tool follow-ups on playback draining; ack it so
|
||||
// the turn doesn't stall waiting on us.
|
||||
val drainMs = player.flushBufferedPlayback()
|
||||
scope.launch {
|
||||
if (drainMs > 0) delay(drainMs.coerceAtMost(2_000))
|
||||
control.sendPlaybackDrained(
|
||||
callId = event.toolCallId,
|
||||
playedAudioEventId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mainHandler.post {
|
||||
if (event.rmsLevel != null) {
|
||||
waveform.add(event.rmsLevel)
|
||||
while (waveform.size > 64) waveform.removeAt(0)
|
||||
}
|
||||
val suffix = when {
|
||||
event.byteCount != null -> " ${event.byteCount}b"
|
||||
event.message != null -> " ${event.message}"
|
||||
else -> ""
|
||||
}
|
||||
events.add("${event.type}$suffix")
|
||||
while (events.size > 64) events.removeAt(0)
|
||||
player.flushBufferedPlayback()
|
||||
if (result.isSuccess) {
|
||||
onSummary(result.getOrNull())
|
||||
onStatus("Complete")
|
||||
} else {
|
||||
onStatus(result.exceptionOrNull()?.message ?: "Realtime agent failed")
|
||||
}
|
||||
onRunning(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Analytics
|
||||
@@ -33,17 +35,21 @@ import androidx.compose.material.icons.filled.Security
|
||||
// === END PHASE3-safety-rails ===
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -58,8 +64,10 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
|
||||
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
@@ -83,6 +91,8 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
/** Header back affordance — Settings is a pushed destination, not a tab. */
|
||||
onBack: (() -> Unit)? = null,
|
||||
// Needed by the Active Agent summary card at the top of the screen — it
|
||||
// reads the current personality pick so the subtitle can render
|
||||
// `connection · model · personality` without re-reading ChatViewModel
|
||||
@@ -104,7 +114,10 @@ fun SettingsScreen(
|
||||
// + manual URL + insecure toggle + manual pairing code surface via
|
||||
// expandable sections, so there's nothing left to link to twice.
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToChatSettings: () -> Unit,
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToBridge: () -> Unit,
|
||||
onNavigateToMediaSettings: () -> Unit,
|
||||
onNavigateToAppearanceSettings: () -> Unit,
|
||||
onNavigateToAnalytics: () -> Unit,
|
||||
@@ -155,10 +168,22 @@ fun SettingsScreen(
|
||||
// the sheet renders inline over Settings so closing drops the user
|
||||
// back where they started.
|
||||
var showAgentSheet by remember { mutableStateOf(false) }
|
||||
var showDiagnosticsSheet by remember { mutableStateOf(false) }
|
||||
val diagnosticsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Settings") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
@@ -248,6 +273,14 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Hermes management",
|
||||
subtitle = "Skills, cron, MCP, profiles, models, config",
|
||||
onClick = onNavigateToManage,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
title = "Chat",
|
||||
@@ -274,16 +307,6 @@ fun SettingsScreen(
|
||||
)
|
||||
// === END PHASE3-notif-listener-followup ===
|
||||
|
||||
// === PHASE3-safety-rails: bridge safety entry-point ===
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Bridge safety",
|
||||
subtitle = "Blocklist, destructive-verb confirmation, auto-disable",
|
||||
onClick = onNavigateToBridgeSafety,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
// === END PHASE3-safety-rails ===
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Image,
|
||||
title = "Media",
|
||||
@@ -300,6 +323,24 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Power tools")
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Code,
|
||||
title = "Terminal",
|
||||
subtitle = "Server shell access through a paired relay session",
|
||||
onClick = onNavigateToTerminal,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
title = "Bridge",
|
||||
subtitle = "Relay-granted phone bridge controls",
|
||||
onClick = onNavigateToBridge,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Devices,
|
||||
title = "Relay sessions",
|
||||
@@ -308,6 +349,18 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
if (BuildFlavor.isSideload) {
|
||||
// === PHASE3-safety-rails: bridge safety entry-point ===
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Bridge safety",
|
||||
subtitle = "Blocklist, destructive-verb confirmation, auto-disable",
|
||||
onClick = onNavigateToBridgeSafety,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
// === END PHASE3-safety-rails ===
|
||||
}
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Analytics,
|
||||
title = "Analytics",
|
||||
@@ -316,6 +369,14 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Info,
|
||||
title = "Diagnostics",
|
||||
subtitle = "Recent API, relay, session, and voice activity",
|
||||
onClick = { showDiagnosticsSheet = true },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
if (devOptionsUnlocked) {
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Code,
|
||||
@@ -349,8 +410,39 @@ fun SettingsScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
onDismiss = { showAgentSheet = false },
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
onNavigateToProfileInspector = onNavigateToProfileInspector,
|
||||
)
|
||||
}
|
||||
|
||||
if (showDiagnosticsSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showDiagnosticsSheet = false },
|
||||
sheetState = diagnosticsSheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Diagnostics",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
text = "Recent app-level connection and voice events. Secrets and raw payloads are hidden.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
DiagnosticsLogPanel(
|
||||
limit = 80,
|
||||
showCategory = true,
|
||||
showClear = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,6 +548,18 @@ private fun ActiveAgentCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionHeader(label: String) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the root Settings category list. Matches the visual style of
|
||||
* the existing Voice navigation row that was previously inline in the
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -70,7 +71,9 @@ private val TerminalBackground = Color(0xFF1A1A2E)
|
||||
@Composable
|
||||
fun TerminalScreen(
|
||||
terminalViewModel: TerminalViewModel,
|
||||
connectionViewModel: ConnectionViewModel
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
/** Header back affordance — Terminal is a pushed destination, not a tab. */
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
val connectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
val tabs by terminalViewModel.tabs.collectAsState()
|
||||
@@ -115,6 +118,16 @@ fun TerminalScreen(
|
||||
.background(TerminalBackground)
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
// Title is wrapped in a Row + .clickable so the entire title
|
||||
// area opens the session info sheet — same UX as Chat's
|
||||
|
||||
@@ -60,8 +60,8 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
@@ -76,6 +76,7 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.VoiceSettingsViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -84,13 +85,13 @@ import kotlinx.coroutines.launch
|
||||
* Dedicated voice-mode settings screen. Reachable from Settings → Voice.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Voice Mode — interaction mode, silence threshold, auto-TTS
|
||||
* 2. Voice Output — profile-scoped provider/model/voice from /voice/output/config
|
||||
* 3. Realtime Agent — experimental provider defaults from /voice/realtime-agent/config
|
||||
* 4. Barge-in — interrupt TTS by speaking; sensitivity + resume (V barge-in)
|
||||
* 5. Text-to-Speech — fallback provider label + voice from GET /voice/config
|
||||
* 6. Speech-to-Text — provider/model labels from GET /voice/config
|
||||
* 7. Test Voice — saved profile voice-output synth + playback
|
||||
* 1. Voice Engine — Hermes Chat + Voice Output or Realtime Agent
|
||||
* 2. Global Voice Controls — interaction mode, silence threshold, auto-TTS
|
||||
* 3. Active engine config — voice output or realtime defaults for the selected engine
|
||||
* 4. Barge-in — interrupt TTS by speaking; sensitivity + resume (V barge-in)
|
||||
* 5. Global Fallback TTS — fallback provider label + voice from GET /voice/config
|
||||
* 6. Speech-to-Text — provider/model labels from GET /voice/config
|
||||
* 7. Test Current Engine — voice output playback or realtime agent session playback
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -98,6 +99,15 @@ fun VoiceSettingsScreen(
|
||||
voiceViewModel: VoiceViewModel,
|
||||
voiceClient: RelayVoiceClient?,
|
||||
selectedProfile: Profile? = null,
|
||||
standardVoiceAvailability: StandardVoiceAvailability = StandardVoiceAvailability.Unknown,
|
||||
/**
|
||||
* Non-null endpoint display label (e.g. "Tailscale") when the sign-in
|
||||
* gate is up because the resolver moved the dashboard to a route the
|
||||
* user hasn't signed in on yet — dashboard cookies are per-host.
|
||||
*/
|
||||
standardVoiceSignInRouteHint: String? = null,
|
||||
relayVoiceReady: Boolean = false,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
) {
|
||||
@@ -106,8 +116,8 @@ fun VoiceSettingsScreen(
|
||||
|
||||
val prefsRepo = remember { VoicePreferencesRepository(context) }
|
||||
val voiceSettings by prefsRepo.settings.collectAsState(initial = VoiceSettings())
|
||||
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val currentEngine = VoiceEngineMode.fromStorage(voiceSettings.engineMode)
|
||||
val currentAudioRoute = VoiceAudioRoute.fromStorage(voiceSettings.audioRoute)
|
||||
|
||||
val bargeInPrefs by settingsViewModel.bargeInPrefs.collectAsState()
|
||||
val aecAvailable = settingsViewModel.aecAvailable
|
||||
@@ -145,13 +155,28 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
var realtimeOptionsLoading by remember { mutableStateOf<String?>(null) }
|
||||
var realtimeOptionsStatus by remember { mutableStateOf<String?>(null) }
|
||||
var currentEngineTestRunning by remember { mutableStateOf(false) }
|
||||
var currentEngineTestResult by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Global snackbar host — voice errors routed through the classifier get
|
||||
// shown as snackbars here as well as the inline "unavailable" label below.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
|
||||
LaunchedEffect(voiceClient, selectedProfile?.name) {
|
||||
LaunchedEffect(voiceClient, selectedProfile?.name, relayVoiceReady) {
|
||||
val client = voiceClient ?: return@LaunchedEffect
|
||||
if (!relayVoiceReady) {
|
||||
// Standard-only connection: there is no Relay voice surface to
|
||||
// query. Fetching anyway would only manufacture error snackbars
|
||||
// for a route the user isn't using — stay quiet and let the
|
||||
// relay-backed sections render their "not configured" line.
|
||||
voiceConfig = null
|
||||
voiceConfigError = null
|
||||
voiceOutputConfig = null
|
||||
voiceOutputConfigError = null
|
||||
realtimeConfig = null
|
||||
realtimeConfigError = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val voiceResult = client.getVoiceConfig()
|
||||
if (voiceResult.isSuccess) {
|
||||
voiceConfig = voiceResult.getOrNull()
|
||||
@@ -318,6 +343,11 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentEngine) {
|
||||
currentEngineTestRunning = false
|
||||
currentEngineTestResult = null
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -346,26 +376,27 @@ fun VoiceSettingsScreen(
|
||||
) {
|
||||
VoiceProfileSummaryCard(
|
||||
selectedProfile = selectedProfile,
|
||||
currentEngine = currentEngine,
|
||||
output = voiceOutputConfig,
|
||||
realtime = realtimeConfig,
|
||||
)
|
||||
|
||||
// --- Voice Mode ---
|
||||
SectionCard(title = "Voice Mode") {
|
||||
// --- Voice Engine ---
|
||||
SectionCard(title = "Voice Engine") {
|
||||
Text(
|
||||
text = "Voice engine",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
val currentEngine = VoiceEngineMode.fromStorage(voiceSettings.engineMode)
|
||||
listOf(
|
||||
VoiceEngineMode.HermesVoiceOutput to Triple(
|
||||
"Hermes chat + voice output",
|
||||
"Hermes handles chat, tools, memory, and voice rendering.",
|
||||
"Hermes Chat + Voice Output",
|
||||
"Hermes handles chat, tools, and memory; speech runs over the standard " +
|
||||
"Hermes dashboard or Relay — whichever the STT/TTS route below picks.",
|
||||
false,
|
||||
),
|
||||
VoiceEngineMode.RealtimeAgent to Triple(
|
||||
"Realtime Agent",
|
||||
"Provider-native realtime speech with Hermes-brokered tools.",
|
||||
"Provider-native realtime speech with Hermes-brokered tools. Requires a paired Relay.",
|
||||
true,
|
||||
),
|
||||
).forEach { (engine, copy) ->
|
||||
@@ -403,6 +434,159 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentEngine == VoiceEngineMode.RealtimeAgent && !relayVoiceReady) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "No Relay is configured for this connection, so the Realtime " +
|
||||
"Agent can't start. Pair Relay in Settings → Connections, or " +
|
||||
"switch back to Hermes Chat + Voice Output.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
SectionCard(title = "Stable STT/TTS Route") {
|
||||
val standardStatus = when (standardVoiceAvailability) {
|
||||
StandardVoiceAvailability.Ready -> "Ready"
|
||||
StandardVoiceAvailability.SignInRequired -> "Dashboard sign-in required"
|
||||
StandardVoiceAvailability.Unreachable -> "Dashboard unreachable"
|
||||
StandardVoiceAvailability.Unsupported -> "Not available on this Hermes build"
|
||||
StandardVoiceAvailability.Unknown -> "Checking..."
|
||||
}
|
||||
val standardOk = standardVoiceAvailability == StandardVoiceAvailability.Ready
|
||||
val relayStatus = if (relayVoiceReady) "Ready" else "Relay not configured"
|
||||
val autoStatus = when {
|
||||
relayVoiceReady -> "Ready — using Relay"
|
||||
standardOk -> "Ready — using standard Hermes"
|
||||
else -> "No route available yet"
|
||||
}
|
||||
listOf(
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Auto,
|
||||
label = "Auto",
|
||||
detail = "Relay when paired; otherwise the standard Hermes dashboard. Recommended.",
|
||||
status = autoStatus,
|
||||
statusOk = relayVoiceReady || standardOk,
|
||||
),
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Standard,
|
||||
label = "Standard Hermes",
|
||||
detail = "The dashboard audio path Hermes Desktop uses — works on a " +
|
||||
"vanilla Hermes install, no Relay plugin required.",
|
||||
status = standardStatus,
|
||||
statusOk = standardOk,
|
||||
),
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Relay,
|
||||
label = "Relay",
|
||||
detail = "Relay plugin voice — profile-aware providers and streaming voice output.",
|
||||
status = relayStatus,
|
||||
statusOk = relayVoiceReady,
|
||||
badge = "Optional",
|
||||
),
|
||||
).forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = currentAudioRoute == option.route,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setAudioRoute(option.route) }
|
||||
},
|
||||
)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = currentAudioRoute == option.route,
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(option.label, style = MaterialTheme.typography.bodyMedium)
|
||||
option.badge?.let { ExperimentalBadge(it) }
|
||||
}
|
||||
Text(
|
||||
text = option.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = option.status,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (option.statusOk) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (standardVoiceAvailability) {
|
||||
StandardVoiceAvailability.SignInRequired -> {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = if (standardVoiceSignInRouteHint != null) {
|
||||
"You're connected over the $standardVoiceSignInRouteHint " +
|
||||
"route, and dashboard sign-ins are per-host — a sign-in " +
|
||||
"from your home network doesn't carry over. Sign in once " +
|
||||
"in Manage while on this route to unlock voice here too."
|
||||
} else {
|
||||
"Your Hermes dashboard requires sign-in before standard " +
|
||||
"voice can transcribe or speak. Signing in once in Manage " +
|
||||
"unlocks it for this connection."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (onOpenManage != null) {
|
||||
TextButton(onClick = onOpenManage) {
|
||||
Text("Sign in via Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
StandardVoiceAvailability.Unsupported -> {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "This Hermes server build doesn't expose the dashboard " +
|
||||
"audio routes yet. Update hermes-agent on the server, or " +
|
||||
"pair Relay to use Relay voice.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Global Voice Controls ---
|
||||
SectionCard(title = "Global Voice Controls") {
|
||||
Text(
|
||||
text = "These settings apply to both voice engines.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
@@ -488,8 +672,65 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Voice Output ---
|
||||
SectionCard(title = "Voice Output") {
|
||||
// --- Global Fallback Text-to-Speech ---
|
||||
if (!relayVoiceReady) {
|
||||
SectionCard(title = "Voice Providers") {
|
||||
Text(
|
||||
text = "This connection speaks through your Hermes server's " +
|
||||
"configured TTS and STT (config.yaml on the server, or the " +
|
||||
"dashboard's Audio settings). Pair Relay to pick providers, " +
|
||||
"models, and voices from the phone.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relayVoiceReady) SectionCard(title = "Global Fallback Text-to-Speech") {
|
||||
Text(
|
||||
text = "Always available as the stable speech safety net when provider-native voice is unavailable.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
ProviderRow(
|
||||
label = "Provider",
|
||||
value = voiceConfig?.tts?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
|
||||
)
|
||||
voiceConfig?.tts?.let { tts ->
|
||||
ProviderRow(label = "Enabled", value = if (tts.isEnabled) "yes" else "no")
|
||||
}
|
||||
voiceConfig?.tts?.model?.let { model ->
|
||||
ProviderRow(label = "Model", value = model)
|
||||
}
|
||||
voiceConfig?.tts?.displayVoice?.let { voice ->
|
||||
ProviderRow(label = "Voice", value = voice)
|
||||
}
|
||||
voiceConfig?.let { config ->
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceProfileLabel(config.profile, selectedProfile),
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Scope",
|
||||
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
|
||||
)
|
||||
}
|
||||
voiceConfigError?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput && relayVoiceReady) {
|
||||
// --- Hermes Chat + Voice Output ---
|
||||
// Relay-backed provider editing; standard-only connections get
|
||||
// the quiet "Voice Providers" card above instead.
|
||||
SectionCard(title = "Hermes Chat + Voice Output") {
|
||||
ProviderRow(
|
||||
label = "Status",
|
||||
value = when {
|
||||
@@ -853,19 +1094,61 @@ fun VoiceSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
voiceSettings.engineMode == VoiceEngineMode.RealtimeAgent.storageValue ||
|
||||
devOptionsUnlocked
|
||||
) {
|
||||
if (currentEngine == VoiceEngineMode.RealtimeAgent && relayVoiceReady) {
|
||||
// --- Realtime Agent ---
|
||||
// Relay-only engine; without Relay the engine picker above
|
||||
// already shows the requirement, so skip the config card
|
||||
// rather than rendering permanent "loading..." rows.
|
||||
SectionCard(title = "Realtime Agent", badge = "Experimental") {
|
||||
Text(
|
||||
text = "Hermes still owns tools and confirmations. Realtime mode may fall back to stable voice if the provider disconnects.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Detailed trace", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Show compact Hermes status and result provenance in the timeline",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = voiceSettings.realtimeTraceDetails,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch { prefsRepo.setRealtimeTraceDetails(enabled) }
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Persistent session", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Keep one provider conversation open across turns. Turn off to fall back to a fresh session per utterance.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = voiceSettings.realtimePersistentSession,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch { prefsRepo.setRealtimePersistentSession(enabled) }
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ProviderRow(
|
||||
label = "Status",
|
||||
@@ -900,6 +1183,109 @@ fun VoiceSettingsScreen(
|
||||
ProviderRow(label = "Auth", value = realtimeAuthLabel(config))
|
||||
}
|
||||
|
||||
realtimeConfig?.promotion?.let { promo ->
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text("Background tasks", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = "Long Hermes tasks keep running in the background so the conversation stays responsive; the answer is spoken when it's ready.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Promote long tasks", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Detach a slow run after ${promo.promoteAfterMs} ms instead of waiting silently",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = promo.enabled,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch {
|
||||
val client = voiceClient ?: return@launch
|
||||
val result = client.updateRealtimeAgentPromotion(
|
||||
promotionEnabled = enabled,
|
||||
)
|
||||
if (result.isSuccess) realtimeConfig = result.getOrNull()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (promo.enabled) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Spoken handoff", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Say a short \"I'm on it\" when a task moves to the background",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = promo.spokenHandoff,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch {
|
||||
val client = voiceClient ?: return@launch
|
||||
val result = client.updateRealtimeAgentPromotion(
|
||||
spokenHandoff = enabled,
|
||||
)
|
||||
if (result.isSuccess) realtimeConfig = result.getOrNull()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"When the answer is ready",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val deliveryOptions = listOf(
|
||||
"speak_when_idle",
|
||||
"notify_then_speak",
|
||||
"visual_only",
|
||||
)
|
||||
val deliveryLabels = listOf("Speak", "Notify", "Show only")
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
deliveryOptions.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = deliveryOptions.size,
|
||||
),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val client = voiceClient ?: return@launch
|
||||
val result = client.updateRealtimeAgentPromotion(
|
||||
resultDelivery = option,
|
||||
)
|
||||
if (result.isSuccess) realtimeConfig = result.getOrNull()
|
||||
}
|
||||
},
|
||||
selected = option == promo.resultDelivery,
|
||||
) {
|
||||
Text(
|
||||
deliveryLabels[index],
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Row(
|
||||
@@ -925,7 +1311,7 @@ fun VoiceSettingsScreen(
|
||||
realtimeConfig?.providers.orEmpty(),
|
||||
realtimeProviderOptions,
|
||||
)
|
||||
.filter { it.supports_realtime }
|
||||
.filter { it.supports_realtime_agent_native }
|
||||
val selectedRealtimeProvider = providerFor(
|
||||
realtimeProviders,
|
||||
realtimeProvider,
|
||||
@@ -1236,62 +1622,36 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Text-to-Speech ---
|
||||
SectionCard(title = "Text-to-Speech") {
|
||||
ProviderRow(
|
||||
label = "Provider",
|
||||
value = voiceConfig?.tts?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
|
||||
)
|
||||
voiceConfig?.tts?.let { tts ->
|
||||
ProviderRow(label = "Enabled", value = if (tts.isEnabled) "yes" else "no")
|
||||
}
|
||||
voiceConfig?.tts?.model?.let { model ->
|
||||
ProviderRow(label = "Model", value = model)
|
||||
}
|
||||
voiceConfig?.tts?.displayVoice?.let { voice ->
|
||||
ProviderRow(label = "Voice", value = voice)
|
||||
}
|
||||
voiceConfig?.let { config ->
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceProfileLabel(config.profile, selectedProfile),
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Scope",
|
||||
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
|
||||
)
|
||||
}
|
||||
voiceConfigError?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Speech-to-Text ---
|
||||
SectionCard(title = "Speech-to-Text") {
|
||||
ProviderRow(
|
||||
label = "Provider",
|
||||
value = voiceConfig?.stt?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
|
||||
)
|
||||
voiceConfig?.stt?.let { stt ->
|
||||
ProviderRow(label = "Enabled", value = if (stt.isEnabled) "yes" else "no")
|
||||
}
|
||||
voiceConfig?.stt?.model?.let { model ->
|
||||
ProviderRow(label = "Model", value = model)
|
||||
}
|
||||
voiceConfig?.let { config ->
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceProfileLabel(config.profile, selectedProfile),
|
||||
if (!relayVoiceReady) {
|
||||
Text(
|
||||
text = "Transcription runs on your Hermes server with its " +
|
||||
"configured STT provider.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ProviderRow(
|
||||
label = "Scope",
|
||||
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
|
||||
label = "Provider",
|
||||
value = voiceConfig?.stt?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
|
||||
)
|
||||
voiceConfig?.stt?.let { stt ->
|
||||
ProviderRow(label = "Enabled", value = if (stt.isEnabled) "yes" else "no")
|
||||
}
|
||||
voiceConfig?.stt?.model?.let { model ->
|
||||
ProviderRow(label = "Model", value = model)
|
||||
}
|
||||
voiceConfig?.let { config ->
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceProfileLabel(config.profile, selectedProfile),
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Scope",
|
||||
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
@@ -1337,39 +1697,117 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test Voice ---
|
||||
SectionCard(title = "Test Voice") {
|
||||
// --- Test Current Engine ---
|
||||
SectionCard(title = "Test Current Engine") {
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceOutputConfig?.let { config ->
|
||||
voiceProfileLabel(config.profile, selectedProfile)
|
||||
} ?: voiceProfileLabel(null, selectedProfile),
|
||||
label = "Engine",
|
||||
value = when (currentEngine) {
|
||||
VoiceEngineMode.HermesVoiceOutput -> "Hermes Chat + Voice Output"
|
||||
VoiceEngineMode.RealtimeAgent -> "Realtime Agent"
|
||||
},
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Voice",
|
||||
value = listOfNotNull(
|
||||
voiceOutputConfig?.default_provider,
|
||||
voiceOutputConfig?.default_model,
|
||||
voiceOutputConfig?.default_voice,
|
||||
).joinToString(" / ").ifBlank { "loading..." },
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Play the currently saved profile voice.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { voiceViewModel.testVoice() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
if (relayVoiceReady) {
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceOutputConfig?.let { config ->
|
||||
voiceProfileLabel(config.profile, selectedProfile)
|
||||
} ?: voiceProfileLabel(null, selectedProfile),
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Voice",
|
||||
value = listOfNotNull(
|
||||
voiceOutputConfig?.default_provider,
|
||||
voiceOutputConfig?.default_model,
|
||||
voiceOutputConfig?.default_voice,
|
||||
).joinToString(" / ").ifBlank { "loading..." },
|
||||
)
|
||||
} else {
|
||||
ProviderRow(label = "Route", value = "standard Hermes")
|
||||
ProviderRow(label = "Voice", value = "server-configured TTS")
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Play a short sample through the active voice route.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
currentEngineTestRunning = true
|
||||
currentEngineTestResult = "Playing Voice Output sample..."
|
||||
voiceViewModel.testVoice { result ->
|
||||
currentEngineTestRunning = false
|
||||
currentEngineTestResult = if (result.isSuccess) {
|
||||
"Voice Output test successful."
|
||||
} else {
|
||||
"Voice Output test failed: ${result.exceptionOrNull()?.message ?: "playback error"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !currentEngineTestRunning,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Play Voice Output Test")
|
||||
}
|
||||
} else {
|
||||
ProviderRow(label = "Provider", value = realtimeProvider.ifBlank { "not set" })
|
||||
ProviderRow(label = "Model", value = realtimeModel.ifBlank { "not set" })
|
||||
ProviderRow(label = "Voice", value = realtimeVoice.ifBlank { "not set" })
|
||||
ProviderRow(label = "Sample rate", value = "${realtimeSampleRate.ifBlank { "0" }} Hz")
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Open a provider-native Realtime Agent session and play a short sample through the relay.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
currentEngineTestRunning = true
|
||||
currentEngineTestResult = "Starting Realtime Agent sample..."
|
||||
voiceViewModel.testRealtimeAgent { result ->
|
||||
currentEngineTestRunning = false
|
||||
currentEngineTestResult = if (result.isSuccess) {
|
||||
"Realtime Agent test successful."
|
||||
} else {
|
||||
"Realtime Agent test failed: ${result.exceptionOrNull()?.message ?: "provider error"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !currentEngineTestRunning,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Play Realtime Agent Test")
|
||||
}
|
||||
}
|
||||
currentEngineTestResult?.let { result ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = result,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (
|
||||
result.contains("validated", ignoreCase = true) ||
|
||||
result.contains("Playing", ignoreCase = true) ||
|
||||
result.contains("Starting", ignoreCase = true) ||
|
||||
result.contains("successful", ignoreCase = true)
|
||||
) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Play test")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1391,6 +1829,7 @@ private fun realtimeAuthLabel(config: RealtimeVoiceConfig): String {
|
||||
return when {
|
||||
auth.xai_oauth -> "Hermes xAI OAuth"
|
||||
auth.xai_env -> "xAI env"
|
||||
auth.openai_env -> "OpenAI env"
|
||||
else -> "server managed"
|
||||
}
|
||||
}
|
||||
@@ -1428,6 +1867,15 @@ private fun voiceScopeLabel(scope: String?, fallbackToGlobal: Boolean): String {
|
||||
return if (fallbackToGlobal) "$base fallback" else base
|
||||
}
|
||||
|
||||
private data class RouteOption(
|
||||
val route: VoiceAudioRoute,
|
||||
val label: String,
|
||||
val detail: String,
|
||||
val status: String,
|
||||
val statusOk: Boolean,
|
||||
val badge: String? = null,
|
||||
)
|
||||
|
||||
private data class VoiceChoice(
|
||||
val value: String,
|
||||
val label: String = value,
|
||||
@@ -1697,6 +2145,7 @@ private fun commonLanguages(current: String, provider: RealtimeProviderInfo?): L
|
||||
|
||||
private fun voiceOutputSummary(
|
||||
profile: Profile?,
|
||||
currentEngine: VoiceEngineMode,
|
||||
output: VoiceOutputConfig?,
|
||||
realtime: RealtimeVoiceConfig?,
|
||||
): Pair<String, String> {
|
||||
@@ -1714,16 +2163,25 @@ private fun voiceOutputSummary(
|
||||
val voice = config.default_voice?.takeIf { it.isNotBlank() } ?: "voice ..."
|
||||
"$provider / $voice"
|
||||
} ?: "realtime loading..."
|
||||
return profileLabel to "$outputLabel · $realtimeLabel"
|
||||
return profileLabel to when (currentEngine) {
|
||||
VoiceEngineMode.HermesVoiceOutput -> "Hermes Chat + Voice Output - $outputLabel"
|
||||
VoiceEngineMode.RealtimeAgent -> "Realtime Agent - $realtimeLabel"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceProfileSummaryCard(
|
||||
selectedProfile: Profile?,
|
||||
currentEngine: VoiceEngineMode,
|
||||
output: VoiceOutputConfig?,
|
||||
realtime: RealtimeVoiceConfig?,
|
||||
) {
|
||||
val (title, subtitle) = voiceOutputSummary(selectedProfile, output, realtime)
|
||||
val (title, subtitle) = voiceOutputSummary(
|
||||
profile = selectedProfile,
|
||||
currentEngine = currentEngine,
|
||||
output = output,
|
||||
realtime = realtime,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object RelayRefresh {
|
||||
val Ink = Color(0xFFF7F6F0)
|
||||
val Paper = Color(0xFFF7F3EA)
|
||||
val Muted = Color(0xFFA7A4B7)
|
||||
val Dim = Color(0xFF68647D)
|
||||
val Background = Color(0xFF08090D)
|
||||
val Navy = Color(0xFF121426)
|
||||
val Navy2 = Color(0xFF191B31)
|
||||
val Navy3 = Color(0xFF22243C)
|
||||
val Relay = Color(0xFFAEBFFF)
|
||||
val Purple = Color(0xFF8C5CFF)
|
||||
val Electric = Color(0xFF111DFF)
|
||||
|
||||
/**
|
||||
* Softened Electric for large filled surfaces (e.g. the active
|
||||
* connection card). Full-strength Electric stays for small accents and
|
||||
* the alpha-blended selected panels, where the saturation reads as brand
|
||||
* rather than glare — 2026-06-10/11 feedback: the blue was right
|
||||
* everywhere except as a full-card fill against body text.
|
||||
*/
|
||||
val ElectricMuted = Color(0xFF4F5BD5)
|
||||
val Cyan = Color(0xFF6BDCFF)
|
||||
val Green = Color(0xFF58D36F)
|
||||
val Amber = Color(0xFFF2B14B)
|
||||
val Danger = Color(0xFFFF6B78)
|
||||
val Line = Color(0x24F7F6F0)
|
||||
val LineStrong = Color(0x47F7F6F0)
|
||||
val CardRadius = 8.dp
|
||||
val Mono = FontFamily.Monospace
|
||||
}
|
||||
|
||||
fun Modifier.relayPanel(
|
||||
shape: Shape = RoundedCornerShape(RelayRefresh.CardRadius),
|
||||
background: Color = RelayRefresh.Navy2.copy(alpha = 0.78f),
|
||||
borderColor: Color = RelayRefresh.Line,
|
||||
): Modifier = this
|
||||
.background(background, shape)
|
||||
.border(1.dp, borderColor, shape)
|
||||
|
||||
fun Modifier.relaySelectedPanel(
|
||||
shape: Shape = RoundedCornerShape(RelayRefresh.CardRadius),
|
||||
): Modifier = this
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
RelayRefresh.Electric.copy(alpha = 0.52f),
|
||||
RelayRefresh.Purple.copy(alpha = 0.18f),
|
||||
),
|
||||
),
|
||||
shape,
|
||||
)
|
||||
.border(1.dp, RelayRefresh.Electric.copy(alpha = 0.72f), shape)
|
||||
|
||||
fun Modifier.relayGridTexture(
|
||||
grid: Dp = 42.dp,
|
||||
dot: Dp = 10.dp,
|
||||
alpha: Float = 0.18f,
|
||||
): Modifier = drawBehind {
|
||||
val gridPx = grid.toPx().coerceAtLeast(1f)
|
||||
val dotPx = dot.toPx().coerceAtLeast(1f)
|
||||
var x = 0f
|
||||
while (x <= size.width) {
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = 0.018f * alpha * 5f),
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, size.height),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
x += gridPx
|
||||
}
|
||||
var y = 0f
|
||||
while (y <= size.height) {
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = 0.026f * alpha * 5f),
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
y += gridPx
|
||||
}
|
||||
var dy = 0f
|
||||
while (dy <= size.height) {
|
||||
var dx = 0f
|
||||
while (dx <= size.width) {
|
||||
drawCircle(
|
||||
color = RelayRefresh.Relay.copy(alpha = 0.16f * alpha * 5f),
|
||||
radius = 1.15f,
|
||||
center = Offset(dx + 1f, dy + 1f),
|
||||
)
|
||||
dx += dotPx
|
||||
}
|
||||
dy += dotPx
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayTextureBox(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.18f),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayDottedOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
alpha: Float = 0.16f,
|
||||
) {
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
val step = 10.dp.toPx()
|
||||
var y = 0f
|
||||
while (y <= size.height) {
|
||||
var x = 0f
|
||||
while (x <= size.width) {
|
||||
drawCircle(
|
||||
color = RelayRefresh.Relay.copy(alpha = alpha),
|
||||
radius = 1.1f,
|
||||
center = Offset(x + 1f, y + 1f),
|
||||
)
|
||||
x += step
|
||||
}
|
||||
y += step
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun relayMetadataStyle(): TextStyle =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = RelayRefresh.Mono,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.sp,
|
||||
)
|
||||
@@ -1,81 +1,77 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
|
||||
// Brand palette — derived from assets/logo.svg
|
||||
private val HermesPrimary = Color(0xFF6B35E8) // Logo primary purple
|
||||
private val HermesPrimaryLight = Color(0xFF9B6BF0) // Logo accent purple
|
||||
private val HermesPrimaryDark = Color(0xFF4A1DB8) // Deeper variant for containers
|
||||
private val HermesNavy = Color(0xFF1A1A2E) // Logo background navy
|
||||
private val HermesNavySurface = Color(0xFF1E1E34) // Slightly lifted surface
|
||||
private val HermesNavyVariant = Color(0xFF2A2A44) // Card/surface variant
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = HermesPrimaryLight,
|
||||
onPrimary = Color(0xFF1A0049),
|
||||
primaryContainer = HermesPrimary,
|
||||
onPrimaryContainer = Color(0xFFE8DEFF),
|
||||
secondary = Color(0xFFB8AACC),
|
||||
onSecondary = Color(0xFF2B2040),
|
||||
secondaryContainer = Color(0xFF413558),
|
||||
onSecondaryContainer = Color(0xFFE8DEFF),
|
||||
tertiary = Color(0xFF9B6BF0),
|
||||
onTertiary = Color(0xFF1A0049),
|
||||
tertiaryContainer = Color(0xFF3D1F8C),
|
||||
onTertiaryContainer = Color(0xFFE8DEFF),
|
||||
background = HermesNavy,
|
||||
onBackground = Color(0xFFE4E1E9),
|
||||
surface = HermesNavy,
|
||||
onSurface = Color(0xFFE4E1E9),
|
||||
surfaceVariant = HermesNavyVariant,
|
||||
onSurfaceVariant = Color(0xFFC9C3D4),
|
||||
surfaceContainerLowest = Color(0xFF151524),
|
||||
surfaceContainerLow = Color(0xFF1C1C30),
|
||||
surfaceContainer = HermesNavySurface,
|
||||
surfaceContainerHigh = Color(0xFF24243C),
|
||||
surfaceContainerHighest = Color(0xFF2E2E48),
|
||||
outline = Color(0xFF5A5470),
|
||||
outlineVariant = Color(0xFF3D3854)
|
||||
primary = RelayRefresh.Relay,
|
||||
onPrimary = RelayRefresh.Background,
|
||||
primaryContainer = RelayRefresh.Electric,
|
||||
onPrimaryContainer = RelayRefresh.Paper,
|
||||
secondary = RelayRefresh.Purple,
|
||||
onSecondary = RelayRefresh.Paper,
|
||||
secondaryContainer = RelayRefresh.Navy3,
|
||||
onSecondaryContainer = RelayRefresh.Paper,
|
||||
tertiary = RelayRefresh.Cyan,
|
||||
onTertiary = RelayRefresh.Background,
|
||||
tertiaryContainer = RelayRefresh.Purple.copy(alpha = 0.42f),
|
||||
onTertiaryContainer = RelayRefresh.Paper,
|
||||
background = RelayRefresh.Background,
|
||||
onBackground = RelayRefresh.Ink,
|
||||
surface = RelayRefresh.Background,
|
||||
onSurface = RelayRefresh.Ink,
|
||||
surfaceVariant = RelayRefresh.Navy2,
|
||||
onSurfaceVariant = RelayRefresh.Muted,
|
||||
surfaceContainerLowest = Color(0xFF05060A),
|
||||
surfaceContainerLow = Color(0xFF0B0C12),
|
||||
surfaceContainer = RelayRefresh.Navy,
|
||||
surfaceContainerHigh = RelayRefresh.Navy2,
|
||||
surfaceContainerHighest = RelayRefresh.Navy3,
|
||||
error = RelayRefresh.Danger,
|
||||
onError = RelayRefresh.Background,
|
||||
errorContainer = RelayRefresh.Danger.copy(alpha = 0.18f),
|
||||
onErrorContainer = RelayRefresh.Paper,
|
||||
outline = RelayRefresh.LineStrong,
|
||||
outlineVariant = RelayRefresh.Line,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = HermesPrimary,
|
||||
primary = RelayRefresh.Electric,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE8DEFF),
|
||||
onPrimaryContainer = Color(0xFF1A0049),
|
||||
secondary = Color(0xFF5E5474),
|
||||
primaryContainer = RelayRefresh.Relay,
|
||||
onPrimaryContainer = RelayRefresh.Background,
|
||||
secondary = RelayRefresh.Purple,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE8DEFF),
|
||||
onSecondaryContainer = Color(0xFF1B1030),
|
||||
tertiary = HermesPrimaryDark,
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Color(0xFFE8DEFF),
|
||||
onTertiaryContainer = Color(0xFF1A0049),
|
||||
background = Color(0xFFFCF8FF),
|
||||
onBackground = Color(0xFF1B1B22),
|
||||
surface = Color(0xFFFCF8FF),
|
||||
onSurface = Color(0xFF1B1B22),
|
||||
surfaceVariant = Color(0xFFEAE4F2),
|
||||
onSurfaceVariant = Color(0xFF48444E),
|
||||
secondaryContainer = Color(0xFFE5E7FF),
|
||||
onSecondaryContainer = RelayRefresh.Background,
|
||||
tertiary = RelayRefresh.Cyan,
|
||||
onTertiary = RelayRefresh.Background,
|
||||
tertiaryContainer = Color(0xFFDDF8FF),
|
||||
onTertiaryContainer = RelayRefresh.Background,
|
||||
background = RelayRefresh.Paper,
|
||||
onBackground = RelayRefresh.Background,
|
||||
surface = RelayRefresh.Paper,
|
||||
onSurface = RelayRefresh.Background,
|
||||
surfaceVariant = Color(0xFFE9E8F1),
|
||||
onSurfaceVariant = Color(0xFF38384A),
|
||||
surfaceContainerLowest = Color.White,
|
||||
surfaceContainerLow = Color(0xFFF7F2FC),
|
||||
surfaceContainer = Color(0xFFF1ECF6),
|
||||
surfaceContainerHigh = Color(0xFFEBE6F0),
|
||||
surfaceContainerHighest = Color(0xFFE5E0EA),
|
||||
outline = Color(0xFF79747E),
|
||||
outlineVariant = Color(0xFFCBC4D0)
|
||||
surfaceContainerLow = Color(0xFFF4F2F8),
|
||||
surfaceContainer = Color(0xFFEDEBF4),
|
||||
surfaceContainerHigh = Color(0xFFE3E1EC),
|
||||
surfaceContainerHighest = Color(0xFFDAD7E6),
|
||||
error = RelayRefresh.Danger,
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFD9DE),
|
||||
onErrorContainer = Color(0xFF410006),
|
||||
outline = Color(0xFF777386),
|
||||
outlineVariant = Color(0xFFCAC7D8),
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -90,16 +86,7 @@ fun HermesRelayTheme(
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = when {
|
||||
// Dynamic colors available on Android 12+ (API 31)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (useDarkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
useDarkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
val colorScheme = if (useDarkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
// Compose-wide font scaling. We multiply the user's chosen scale into the
|
||||
// current LocalDensity.fontScale (which already reflects the system font
|
||||
|
||||
@@ -8,50 +8,52 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
lineHeight = 34.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
letterSpacing = 0.sp
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,13 +5,13 @@ package com.hermesandroid.relay.update
|
||||
* pulling in a full SemVer library.
|
||||
*
|
||||
* Rules:
|
||||
* - Strips a leading "v" (e.g. `v0.5.0` → `0.5.0`).
|
||||
* - Strips a leading "android-v" or "v" (`android-v0.5.0` → `0.5.0`).
|
||||
* - Splits on "." and takes the leading digits of each segment; missing
|
||||
* segments are treated as 0 so `0.5` and `0.5.0` compare equal.
|
||||
* - Pre-release suffixes (`0.6.0-rc.1`) are dropped — the bare version
|
||||
* is used. That means we treat `0.6.0-rc.1` and `0.6.0` as equal; we
|
||||
* don't ship prereleases via `/releases/latest` (GitHub excludes them
|
||||
* automatically) so this simplification is safe.
|
||||
* don't surface prereleases in the sideload updater, so this simplification
|
||||
* is safe.
|
||||
*
|
||||
* Returns: negative if [current] < [latest], 0 if equal, positive if >.
|
||||
*/
|
||||
@@ -29,6 +29,7 @@ internal fun compareVersions(current: String, latest: String): Int {
|
||||
|
||||
private fun tokenize(raw: String): List<Int> {
|
||||
return raw.trim()
|
||||
.removePrefix("android-v")
|
||||
.removePrefix("v")
|
||||
.substringBefore('-') // drop pre-release suffix
|
||||
.substringBefore('+') // drop build metadata
|
||||
|
||||
@@ -12,10 +12,10 @@ import java.util.concurrent.TimeUnit
|
||||
/**
|
||||
* Sideload-only update checker.
|
||||
*
|
||||
* Fetches the latest GitHub release for this repo and returns an
|
||||
* Fetches the latest Android GitHub release for this repo and returns an
|
||||
* [UpdateCheckResult]. Callers on `googlePlay` builds should treat this
|
||||
* as a no-op — the Play Store handles update delivery and the repo's
|
||||
* GitHub Releases may expose different artifacts.
|
||||
* GitHub Releases also include desktop and server artifacts.
|
||||
*
|
||||
* Network + JSON runs on [Dispatchers.IO].
|
||||
*
|
||||
@@ -27,8 +27,8 @@ import java.util.concurrent.TimeUnit
|
||||
object UpdateChecker {
|
||||
private const val REPO_OWNER = "Codename-11"
|
||||
private const val REPO_NAME = "hermes-relay"
|
||||
private const val RELEASES_LATEST_URL =
|
||||
"https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
|
||||
private const val RELEASES_URL =
|
||||
"https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases?per_page=100"
|
||||
|
||||
private const val USER_AGENT = "hermes-relay-android"
|
||||
|
||||
@@ -46,7 +46,7 @@ object UpdateChecker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release and compare with the running build.
|
||||
* Fetch the latest Android release and compare with the running build.
|
||||
*
|
||||
* On `googlePlay` flavour this always returns [UpdateCheckResult.UpToDate]
|
||||
* because the Play Store track owns update delivery — we don't want the
|
||||
@@ -58,7 +58,7 @@ object UpdateChecker {
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(RELEASES_LATEST_URL)
|
||||
.url(RELEASES_URL)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "$USER_AGENT/${BuildConfig.VERSION_NAME}")
|
||||
.build()
|
||||
@@ -72,9 +72,16 @@ object UpdateChecker {
|
||||
}
|
||||
val body = resp.body?.string()
|
||||
?: return@withContext UpdateCheckResult.Error("Empty response body")
|
||||
val release = json.decodeFromString<GitHubRelease>(body)
|
||||
val release = json.decodeFromString<List<GitHubRelease>>(body)
|
||||
.asSequence()
|
||||
.filter { !it.prerelease }
|
||||
.filter { isAndroidReleaseTag(it.tagName) }
|
||||
.maxWithOrNull { a, b ->
|
||||
compareVersions(releaseVersion(a.tagName), releaseVersion(b.tagName))
|
||||
}
|
||||
?: return@withContext UpdateCheckResult.UpToDate
|
||||
|
||||
val latest = release.tagName
|
||||
val latest = releaseVersion(release.tagName)
|
||||
val current = BuildConfig.VERSION_NAME
|
||||
val cmp = compareVersions(current, latest)
|
||||
if (cmp >= 0) {
|
||||
@@ -84,7 +91,7 @@ object UpdateChecker {
|
||||
val apkAsset = findSideloadApkAsset(release.assets)
|
||||
return@withContext UpdateCheckResult.Available(
|
||||
AvailableUpdate(
|
||||
latestVersion = latest.removePrefix("v"),
|
||||
latestVersion = latest,
|
||||
currentVersion = current,
|
||||
releasePageUrl = release.htmlUrl,
|
||||
apkUrl = apkAsset?.browserDownloadUrl,
|
||||
@@ -99,6 +106,16 @@ object UpdateChecker {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAndroidReleaseTag(tagName: String): Boolean {
|
||||
return tagName.startsWith("android-v") || LEGACY_ANDROID_TAG.matches(tagName)
|
||||
}
|
||||
|
||||
private fun releaseVersion(tagName: String): String {
|
||||
return tagName
|
||||
.removePrefix("android-v")
|
||||
.removePrefix("v")
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the sideload APK asset. Our release artifacts are named
|
||||
* `hermes-relay-<version>-sideload-release.apk` via `archivesName` in
|
||||
@@ -110,4 +127,6 @@ object UpdateChecker {
|
||||
n.endsWith(".apk") && n.contains("sideload")
|
||||
}
|
||||
}
|
||||
|
||||
private val LEGACY_ANDROID_TAG = Regex("""^v\d+\.\d+\.\d+(?:[-+].*)?$""")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Minimal subset of the GitHub "releases/latest" payload we care about.
|
||||
* Minimal subset of the GitHub releases API payload we care about.
|
||||
*
|
||||
* Full response is much larger; kotlinx.serialization's default behaviour
|
||||
* silently drops unknown keys, so we only declare the fields we read.
|
||||
|
||||
@@ -56,6 +56,22 @@ private fun classifyIoMessage(msg: String, context: String?): HumanError? {
|
||||
// Ordered most-specific-first; callers have already handled the typed
|
||||
// SSL / timeout / connect exceptions so this only runs on generic IOs.
|
||||
return when {
|
||||
"hermes broker auth failed" in msg ||
|
||||
"relay-side hermes credential" in msg -> HumanError(
|
||||
title = "Relay Hermes auth failed",
|
||||
body = "The relay could not authenticate to Hermes. Update the server-side Hermes credential and restart the relay.",
|
||||
retryable = false,
|
||||
)
|
||||
"xai realtime auth" in msg ||
|
||||
"openai realtime auth" in msg ||
|
||||
"realtime rejected the relay auth" in msg ||
|
||||
"realtime oauth refresh failed" in msg ||
|
||||
"realtime provider credentials" in msg -> HumanError(
|
||||
title = "Realtime provider auth unavailable",
|
||||
body = "The realtime voice provider is missing or rejected server-side auth. Refresh provider auth on the relay or choose another provider.",
|
||||
retryable = false,
|
||||
actionLabel = "Voice settings",
|
||||
)
|
||||
(
|
||||
"api key" in msg ||
|
||||
"sessions auth failed" in msg ||
|
||||
@@ -123,21 +139,24 @@ fun classifyError(t: Throwable?, context: String? = null): HumanError {
|
||||
// happen to contain HTTP-ish substrings. Only fall through to the
|
||||
// message scan once we know it's a plain IOException.
|
||||
return when (t) {
|
||||
// Bodies say "server", not "relay" — these exceptions also surface
|
||||
// from the standard API/dashboard routes, where relay wording would
|
||||
// send users debugging the wrong box.
|
||||
is UnknownHostException -> HumanError(
|
||||
title = "Can't reach server",
|
||||
body = "Check your network and the relay URL in Settings",
|
||||
body = "Check your network and the server URL in Settings",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
is ConnectException -> HumanError(
|
||||
title = "Connection refused",
|
||||
body = "The relay isn't accepting connections — make sure it's running",
|
||||
body = "The server isn't accepting connections — make sure it's running",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
is SocketTimeoutException -> HumanError(
|
||||
title = "Network timeout",
|
||||
body = "The relay took too long to respond",
|
||||
body = "The server took too long to respond",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
@@ -156,14 +175,18 @@ fun classifyError(t: Throwable?, context: String? = null): HumanError {
|
||||
)
|
||||
is IllegalStateException -> HumanError(
|
||||
title = titlePrefix(context),
|
||||
body = "Not ready — check that the relay is paired and online",
|
||||
// Voice routing throws IllegalStateException with actionable copy
|
||||
// ("needs dashboard sign-in — open Manage"); preserve it instead
|
||||
// of rewriting every not-ready state into relay advice.
|
||||
body = t.message?.takeIf { it.isNotBlank() }
|
||||
?: "Not ready — check that the relay is paired and online",
|
||||
retryable = true,
|
||||
)
|
||||
is IOException -> {
|
||||
if ("timeout" in msg) {
|
||||
HumanError(
|
||||
title = "Network timeout",
|
||||
body = "The relay took too long to respond",
|
||||
body = "The server took too long to respond",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user