Compare commits
485
Commits
v0.4.0
...
android-v1.1.0
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,23 +1,40 @@
|
||||
# Hermes-Relay — CI Pipeline
|
||||
# Hermes-Relay — Android CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# Android-affecting paths so Python-only changes don't spin up the JVM.
|
||||
#
|
||||
# Runs on every push to main and on pull requests targeting main.
|
||||
# Pipeline: lint -> build + test (parallel) -> upload artifacts
|
||||
#
|
||||
# Android: Kotlin + Jetpack Compose (root Gradle project)
|
||||
# Python: aiohttp relay server (relay_server/)
|
||||
|
||||
name: CI
|
||||
name: CI — Android
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main finish
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -85,11 +102,20 @@ jobs:
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Tests — unit tests + report upload
|
||||
#
|
||||
# Tests run on every branch but are ADVISORY on dev (push or PR) so WIP
|
||||
# commits don't block the merge queue. Strict on main — any PR retargeted
|
||||
# from dev → main will surface the real failures before release-merge.
|
||||
# ──────────────────────────────────────────────
|
||||
test:
|
||||
name: Test (Android)
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
# pushes to main and PRs whose base branch is main; true (= advisory)
|
||||
# for everything else (dev pushes, dev-targeted PRs, feature branches).
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -103,8 +129,16 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
|
||||
# suites tracked by issue #32. Keep CI release-relevant until that suite is
|
||||
# split: pairing URL derivation plus connection switching are the stable
|
||||
# Android regression slice for the active release work.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--console=plain
|
||||
|
||||
# Upload test reports even if tests fail, for debugging
|
||||
- name: Upload test reports
|
||||
@@ -114,35 +148,3 @@ jobs:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — syntax check + future tests
|
||||
# ──────────────────────────────────────────────
|
||||
relay-check:
|
||||
name: Relay Check (Python)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (plugin.relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
python -m py_compile plugin/relay/channels/bridge.py
|
||||
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
# TODO: Add pytest step when relay tests exist
|
||||
# - name: Run tests
|
||||
# run: pytest plugin/relay/tests/
|
||||
@@ -0,0 +1,67 @@
|
||||
name: CI dashboard plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-dashboard-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test dashboard plugin
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: plugin/dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard deps
|
||||
working-directory: plugin/dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard bundle
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify plugin-owned version metadata
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
|
||||
- name: Install dashboard API test deps
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
test -s plugin/dashboard/dist/index.js
|
||||
test -s plugin/dashboard/dist/style.css
|
||||
grep -q "hr-modal-card" plugin/dashboard/dist/style.css
|
||||
@@ -0,0 +1,116 @@
|
||||
name: CI desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck-and-build:
|
||||
name: Type-check + build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build (tsc → dist/)
|
||||
run: npm run build
|
||||
|
||||
- name: Verify bin shim is executable
|
||||
# The published tarball depends on bin/hermes-relay.js having a valid
|
||||
# shebang + importing the freshly built dist/cli.js. Smoke the actual
|
||||
# invocation so we catch broken imports, missing main export, or a
|
||||
# prebuilt dist/ that references a source file that moved.
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: Upload dist/
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-dist
|
||||
path: desktop/dist
|
||||
retention-days: 7
|
||||
|
||||
smoke-help:
|
||||
name: Smoke — --help + --version work on every target OS
|
||||
needs: typecheck-and-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: --version
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: --help
|
||||
run: node bin/hermes-relay.js --help
|
||||
|
||||
tray-shell:
|
||||
name: Tray shell checks
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Cargo check tray shell
|
||||
run: npm run tray:check
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
@@ -0,0 +1,132 @@
|
||||
# Hermes-Relay — Plugin CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# plugin-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check -> focused plugin tests
|
||||
|
||||
name: CI — Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-plugin-version.sh"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-plugin.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-plugin-version.sh"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-plugin.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-plugin-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Plugin — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax check (Python)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (plugin relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
python -m py_compile plugin/relay/channels/bridge.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Validate Plugin version metadata
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Plugin — 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 Plugin tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
# pushes to main and PRs whose base branch is main; true (= advisory)
|
||||
# for everything else (dev pushes, dev-targeted PRs, feature branches).
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
@@ -0,0 +1,49 @@
|
||||
# Required-checks sentinel — always runs on every PR + push to main/dev so
|
||||
# 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-plugin.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
|
||||
# any PR that didn't touch the protected paths was blocked from merging,
|
||||
# even with all the relevant gates green. We were admin-overriding every
|
||||
# desktop-only PR. Same for relay-touching PRs (the protection rule named
|
||||
# `Relay Check (Python)` didn't even match any actual job — broken since
|
||||
# day one).
|
||||
#
|
||||
# This sentinel + claude-review become the only required checks. The
|
||||
# path-filtered workflows still run when relevant and surface their
|
||||
# results on the PR — visible, clickable, but advisory rather than
|
||||
# blocking. Reviewers (human + claude-review) eyeball them. This is the
|
||||
# standard pattern for monorepos with path-filtered CI.
|
||||
#
|
||||
# Trade-off acknowledged: a broken Android build on an Android-touching
|
||||
# PR could merge if the reviewer ignores the failing CI badge. Mitigation:
|
||||
# claude-review reads CI conclusions in its review prompt + the project's
|
||||
# release-merge cadence catches issues before they reach a tag. If a
|
||||
# stricter gate is later wanted, fold it into this workflow as a job that
|
||||
# fans out to the path-filtered work — but the simplest version (just an
|
||||
# `echo`) is what's needed to make branch protection useful again today.
|
||||
|
||||
name: Required checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR. Doesn't matter much for
|
||||
# a 5-second job, but matches every other workflow's concurrency shape.
|
||||
concurrency:
|
||||
group: ci-required-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Required checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: OK
|
||||
run: echo "Required-checks sentinel — see ci-required.yml header for context."
|
||||
@@ -3,22 +3,88 @@ name: Claude Code Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
env:
|
||||
# Any dev -> main PR is, by the branching model, the aggregate release PR
|
||||
# (main only ever receives release merges from dev). Detect it by base+head
|
||||
# alone — a title-format match (e.g. "release:") is fragile and silently
|
||||
# let a "Release v1.0.0 …"-titled PR run the full review and time out.
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' }}
|
||||
# Bot-authored PRs such as Dependabot do not receive the same secret
|
||||
# surface as human-authored PRs, and Claude Code rejects bot actors unless
|
||||
# explicitly allow-listed. Keep the required check green with a no-op and
|
||||
# rely on the dependency CI/status checks for those PRs.
|
||||
IS_BOT_PR: ${{ github.event.pull_request.user.type == 'Bot' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Skip aggregate release PR review
|
||||
if: env.IS_RELEASE_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
|
||||
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
|
||||
|
||||
- name: Skip bot-authored PR review
|
||||
if: env.IS_BOT_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for bot-authored PR."
|
||||
echo "Bot PRs are gated by Required checks plus their path-specific CI jobs."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
# Depth 2 includes the pull_request merge commit's first parent, which
|
||||
# lets the next step detect whether this PR changes the workflow file.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Claude review workflow changes
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
id: changed-workflow
|
||||
shell: bash
|
||||
run: |
|
||||
if git rev-parse --verify HEAD^1 >/dev/null 2>&1 &&
|
||||
git diff --name-only HEAD^1 HEAD | grep -Fxq ".github/workflows/claude-code-review.yml"; then
|
||||
echo "claude_review_workflow=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "claude_review_workflow=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Skip Claude review workflow self-change
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review because this PR changes the review workflow itself."
|
||||
echo "The Claude action requires this workflow file to match the default branch before it can exchange the app token."
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow != 'true'
|
||||
timeout-minutes: 15
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
|
||||
+23
-112
@@ -6,134 +6,45 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned, labeled]
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
auth:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
authorized: ${{ steps.check.outputs.authorized }}
|
||||
steps:
|
||||
- name: Check collaborator status
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'issues' && ['opened', 'labeled'].includes(context.payload.action)) {
|
||||
core.setOutput('authorized', 'true');
|
||||
return;
|
||||
}
|
||||
const sender = context.payload.sender?.login;
|
||||
if (!sender) { core.setOutput('authorized', 'false'); return; }
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, username: sender,
|
||||
});
|
||||
const allowed = ['admin', 'write', 'maintain'].includes(data.permission);
|
||||
core.setOutput('authorized', allowed ? 'true' : 'false');
|
||||
} catch {
|
||||
core.setOutput('authorized', 'false');
|
||||
}
|
||||
|
||||
triage:
|
||||
needs: auth
|
||||
claude:
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude') ||
|
||||
(github.event_name == 'issues' && github.event.action == 'opened')
|
||||
)
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Triage this GitHub issue. Analysis-only — do NOT write code or create PRs.
|
||||
|
||||
1. **Classify** — bug, feature request, question, or docs issue?
|
||||
2. **Priority** — critical, high, medium, low based on impact.
|
||||
3. **Affected area** — which module(s)? Check CLAUDE.md for architecture.
|
||||
(ui/, network/, viewmodel/, auth/, data/, relay_server/, plugin/)
|
||||
4. **Reproduction** — for bugs, is there enough info? Ask for device, Android version, steps.
|
||||
5. **Suggested approach** — brief outline (files, strategy).
|
||||
6. **Labels** — suggest appropriate labels.
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
Keep it concise and actionable.
|
||||
claude_args: "--max-turns 5"
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
fix:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' &&
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'claude-fix'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Implement a fix for this GitHub issue. Read CLAUDE.md for project conventions.
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
1. Understand the issue — read relevant source files.
|
||||
2. Implement the minimal fix.
|
||||
3. Follow conventions: Kotlin + Jetpack Compose, kotlinx.serialization, Conventional Commits.
|
||||
4. Run `./gradlew assembleDebug` and fix any errors.
|
||||
5. Create a PR with Conventional Commits format title.
|
||||
|
||||
Do NOT over-engineer. Only change what is needed.
|
||||
claude_args: "--max-turns 25"
|
||||
|
||||
chat:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'assigned' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Responding to a collaborator comment. Read CLAUDE.md for project context.
|
||||
|
||||
Default mode is analysis — investigate, explain, suggest. Do NOT write code
|
||||
unless explicitly asked ("fix this", "implement", "create a PR").
|
||||
|
||||
If asked to fix: follow conventions (Kotlin, Compose, Conventional Commits),
|
||||
run `./gradlew assembleDebug`, and create a PR.
|
||||
claude_args: "--max-turns 15"
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
@@ -68,4 +68,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
# Hermes-Relay — 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 CI checks, builds a release APK, and creates a GitHub Release.
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
|
||||
|
||||
name: Release
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,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: |
|
||||
@@ -47,6 +48,7 @@ jobs:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -62,13 +64,21 @@ jobs:
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
# Keep the tag release gate aligned with CI — Android's broad Gradle
|
||||
# `test` aggregate currently hangs in deferred JVM suites tracked by
|
||||
# issue #32, so the release gate runs the stable connection/pairing slice.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--console=plain
|
||||
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
needs: [validate, ci]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -123,9 +133,10 @@ jobs:
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
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
|
||||
@@ -140,11 +151,43 @@ jobs:
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Upload to Play Console (production draft)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
# Runs only when the Play service-account secret is configured AND this is
|
||||
# a stable tag (prereleases — versions containing a dash — are skipped so
|
||||
# an `-rc.N` build never lands on the production listing). HERMES_KEYSTORE_PATH
|
||||
# was exported into $GITHUB_ENV by the "Decode release keystore" step above
|
||||
# and persists across steps in this job, so the AAB is release-signed.
|
||||
#
|
||||
# `publishGooglePlayReleaseBundle` is the flavor-scoped task — only the
|
||||
# googlePlay AAB is uploaded (sideload is disabled via playConfigs in
|
||||
# app/build.gradle.kts). The play{} block pins releaseStatus = DRAFT, so the
|
||||
# build lands on the Production track as a DRAFT: CI does the upload, a human
|
||||
# clicks "Start rollout" in Play Console. A bad tag can never auto-go-live.
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON != '' && !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle --track=production
|
||||
rm -f play-service-account.json
|
||||
|
||||
- name: Play upload skipped (no secret)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON == '' }}
|
||||
run: |
|
||||
echo "ℹ️ PLAY_SERVICE_ACCOUNT_JSON not set — skipped Play Console upload." \
|
||||
"GitHub Release artifacts are still published; upload to Play manually" \
|
||||
"(see RELEASE.md §5)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Release summary
|
||||
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"
|
||||
@@ -0,0 +1,222 @@
|
||||
name: Release CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['cli-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-cli-binaries:
|
||||
name: Build cross-platform CLI binaries via Bun compile
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Print Bun version (diagnostics)
|
||||
run: bun --version
|
||||
|
||||
- name: Prepare binary output dir
|
||||
run: mkdir -p dist/bin
|
||||
|
||||
# Keep the package.json scripts as the single source of truth for Bun
|
||||
# compile flags so release and local smoke builds cannot diverge.
|
||||
- name: Build Windows x64
|
||||
run: npm run build:bin:win
|
||||
|
||||
- name: Build Linux x64
|
||||
run: npm run build:bin:linux
|
||||
|
||||
- name: Build macOS x64
|
||||
run: npm run build:bin:mac-x64
|
||||
|
||||
- name: Build macOS arm64
|
||||
run: npm run build:bin:mac-arm
|
||||
|
||||
- name: Size guard (<150 MB each)
|
||||
run: |
|
||||
set -e
|
||||
for f in dist/bin/hermes-relay-*; do
|
||||
sz=$(stat -c%s "$f")
|
||||
mb=$(( sz / 1024 / 1024 ))
|
||||
echo " $f - ${mb} MB"
|
||||
if [ "$sz" -gt 157286400 ]; then
|
||||
echo "FAIL: $f exceeds 150 MB - Bun likely shipped a debug build or we added a large dep."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Smoke-test Linux binary
|
||||
run: |
|
||||
set -e
|
||||
chmod +x dist/bin/hermes-relay-linux-x64
|
||||
for cmd in --version --help doctor; do
|
||||
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
|
||||
exit_code=$?
|
||||
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
|
||||
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
|
||||
echo "Raw output was: [$out]"
|
||||
exit 1
|
||||
fi
|
||||
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
|
||||
done
|
||||
|
||||
- name: Upload CLI release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
retention-days: 7
|
||||
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
|
||||
- name: Build tray installer
|
||||
run: npm run tray:build
|
||||
|
||||
- name: Normalize installer asset name
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist/tray | Out-Null
|
||||
$installer = Get-ChildItem -Path tray/src-tauri/target/release/bundle/nsis -Filter '*_x64-setup.exe' | Select-Object -First 1
|
||||
if (-not $installer) { throw 'NSIS installer was not produced' }
|
||||
Copy-Item -Force $installer.FullName dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
|
||||
- name: Smoke-test tray exe launch
|
||||
shell: pwsh
|
||||
run: |
|
||||
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $home | Out-Null
|
||||
$env:USERPROFILE = $home
|
||||
$env:HOME = $home
|
||||
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 5
|
||||
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
Write-Host "tray launch smoke OK pid=$($proc.Id)"
|
||||
|
||||
- name: Upload Windows tray release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-windows-tray-installer
|
||||
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
retention-days: 7
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-cli-binaries
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
|
||||
# (the other publish-release steps only consume downloaded build artifacts).
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract CLI version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
set -e
|
||||
find release-assets -type f ! -name SHA256SUMS.txt -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum \
|
||||
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
|
||||
cat release-assets/SHA256SUMS.txt
|
||||
|
||||
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
|
||||
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
sed -e "s/__VERSION__/${VERSION}/g" -e "s/__TAG__/${TAG}/g" \
|
||||
CLI_RELEASE_NOTES.md > cli_release_notes_rendered.md
|
||||
echo "=== rendered release body ===" && cat cli_release_notes_rendered.md
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
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_path: cli_release_notes_rendered.md
|
||||
files: |
|
||||
release-assets/cli-binaries/hermes-relay-win-x64.exe
|
||||
release-assets/cli-binaries/hermes-relay-linux-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-tray-installer/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Release Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "plugin-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Plugin release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Plugin version sync
|
||||
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
test:
|
||||
name: Test Plugin package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
python -m py_compile plugin/relay/voice_auth.py
|
||||
python -m py_compile plugin/tools/android_tool.py
|
||||
python -m py_compile plugin/tools/desktop_tool.py
|
||||
python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
|
||||
package:
|
||||
name: Build and publish Plugin package
|
||||
needs: [validate, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd dist
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
# Render PLUGIN_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body, substituting the version token so the Install command stays
|
||||
# accurate without a manual edit. The file is the single source of the notes;
|
||||
# see RELEASE.md "Plugin / Python package release".
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
sed "s/__VERSION__/${VERSION}/g" PLUGIN_RELEASE_NOTES.md > release_notes_rendered.md
|
||||
echo "=== rendered release body ===" && cat release_notes_rendered.md
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
|
||||
tag_name: plugin-v${{ needs.validate.outputs.version }}
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
body_path: release_notes_rendered.md
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
dist/SHA256SUMS.txt
|
||||
+26
-3
@@ -24,6 +24,10 @@ Thumbs.db
|
||||
local.properties
|
||||
/build/
|
||||
/app/build/
|
||||
/relay-core/build/
|
||||
/relay-ui/build/
|
||||
/ui-preview/build/
|
||||
/quest/build/
|
||||
/app/release/
|
||||
*.apk
|
||||
*.aab
|
||||
@@ -43,19 +47,31 @@ certs/
|
||||
|
||||
# Local tools
|
||||
.subframe/
|
||||
voice-lab-runs/
|
||||
realtime-voice-runs/
|
||||
realtime-agent-runs/
|
||||
voice_rec_*.wav
|
||||
|
||||
# Ad-hoc debugging artifacts (logcat dumps, screenshots, UI XMLs)
|
||||
.scratch/
|
||||
|
||||
# VitePress
|
||||
user-docs/.vitepress/cache/
|
||||
user-docs/.vitepress/dist/
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
# Anchor VitePress-only npm manifests to root/user-docs so desktop/package.json is tracked.
|
||||
/package.json
|
||||
/package-lock.json
|
||||
/user-docs/package.json
|
||||
/user-docs/package-lock.json
|
||||
|
||||
# Local upstream reference
|
||||
# Local upstream references (not shipped in this repo)
|
||||
hermes-agent-upstream/
|
||||
hermes-agent-fork/
|
||||
|
||||
# Claude Code internal state (worktrees, image cache, conversation logs)
|
||||
.claude/
|
||||
.claude-launcher/
|
||||
|
||||
# Kotlin compiler cache
|
||||
.kotlin/
|
||||
@@ -63,3 +79,10 @@ hermes-agent-upstream/
|
||||
# Release signing & Play Store credentials — never commit these
|
||||
play-service-account.json
|
||||
keystore.properties
|
||||
|
||||
# Desktop TUI smoke harness runtime artifacts
|
||||
.smoke-relay.pid
|
||||
.smoke-relay.log
|
||||
|
||||
# Generated tray frontend vendor assets copied from desktop/node_modules
|
||||
desktop/tray/ui/vendor/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,386 +1,44 @@
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
# hermes-android - SubFrame Project
|
||||
|
||||
This project is managed with **SubFrame**. AI assistants should follow the rules below to keep documentation up to date.
|
||||
|
||||
> **Note:** This file is named `AGENTS.md` to be AI-tool agnostic. CLAUDE.md and GEMINI.md contain a reference to this file.
|
||||
|
||||
---
|
||||
|
||||
## Core Working Principle
|
||||
|
||||
**Only do what the user asks.** Do not go beyond the scope of the request.
|
||||
|
||||
- Implement exactly what the user requested — nothing more, nothing less.
|
||||
- Do not change business logic, flow, or architecture unless the user explicitly asks for it.
|
||||
- If a user asks for a design change, only change the design. Do not refactor, restructure, or modify functionality alongside it.
|
||||
- If you have additional suggestions or improvements, **present them as suggestions** to the user. Never implement them without approval.
|
||||
- The user's request must be completed first. Additional ideas come after, as proposals.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Native AI Tools
|
||||
|
||||
SubFrame **enhances** native AI coding tools — it does not replace them.
|
||||
|
||||
**Claude Code** works exactly as normal. Built-in features (`/init`, `/commit`, `/review-pr`, `/compact`, `/memory`, CLAUDE.md) are fully supported. CLAUDE.md is Claude Code's native instruction file — users can add their own tool-specific instructions freely. SubFrame adds a small backlink reference pointing to this AGENTS.md file using HTML comment markers (`<!-- SUBFRAME:BEGIN -->` / `<!-- SUBFRAME:END -->`). SubFrame will never overwrite user content in CLAUDE.md.
|
||||
|
||||
**Gemini CLI** works exactly as normal. Built-in features (`/init`, `/model`, `/memory`, `/compress`, `/settings`, GEMINI.md) are fully supported. GEMINI.md is Gemini CLI's native instruction file — same backlink approach as CLAUDE.md. Users can add their own instructions freely and SubFrame won't overwrite them.
|
||||
|
||||
**Codex CLI** gets SubFrame context via a wrapper script at `.subframe/bin/codex` that injects AGENTS.md as an initial prompt.
|
||||
|
||||
**This file (AGENTS.md)** contains SubFrame-specific rules that apply across all tools:
|
||||
- Sub-Task management (`.subframe/tasks/*.md`, index at `.subframe/tasks.json`)
|
||||
- Codebase mapping (`.subframe/STRUCTURE.json`)
|
||||
- Context preservation (`.subframe/PROJECT_NOTES.md`)
|
||||
- Internal docs and changelog (`.subframe/docs-internal/`)
|
||||
- Session notes and decision tracking
|
||||
|
||||
---
|
||||
|
||||
## Session Start
|
||||
|
||||
**Read these files at the start of each session:**
|
||||
|
||||
1. **`.subframe/STRUCTURE.json`** — Module map, file locations, architecture notes
|
||||
2. **`.subframe/PROJECT_NOTES.md`** — Project vision, past decisions, session notes
|
||||
3. **`.subframe/tasks.json`** — Sub-task index (pending, in-progress, completed)
|
||||
|
||||
This gives you full project context before making any changes. The session-start hook (if configured) automatically injects pending/in-progress sub-tasks into your context, but you should still read these files for deeper understanding.
|
||||
|
||||
### Concurrent Work & Worktrees
|
||||
|
||||
Before making changes, check whether other AI sessions or agent teams are already working on this repository. Signs of concurrent work include:
|
||||
- In-progress sub-tasks you didn't start (check `.subframe/tasks.json`)
|
||||
- Recent uncommitted changes in `git status` that aren't yours
|
||||
- Lock files or active worktrees (`git worktree list`)
|
||||
|
||||
**If concurrent work is detected**, ask the user: "Another session appears to be working on this project. Should I use a git worktree to avoid conflicts?"
|
||||
|
||||
**Git worktrees** create an isolated copy of the repo on a separate branch, allowing parallel work without merge conflicts:
|
||||
- Each worktree has its own working directory and branch
|
||||
- Changes in one worktree don't affect others until merged
|
||||
- Use worktrees when multiple agents or sessions work on different features simultaneously
|
||||
|
||||
**When to suggest a worktree:**
|
||||
- Agent teams spawning multiple workers on the same repo
|
||||
- User asks to work on a feature while another is in progress
|
||||
- The session-start hook flags concurrent sessions
|
||||
|
||||
**When worktrees are NOT needed:**
|
||||
- Single-session work with no concurrent agents
|
||||
- Read-only exploration or research tasks
|
||||
- Quick fixes that won't conflict with in-progress work
|
||||
|
||||
---
|
||||
|
||||
## Hooks (Automatic Awareness)
|
||||
|
||||
SubFrame can configure project-level hooks that automate sub-task awareness. These hooks fire automatically — no manual intervention needed.
|
||||
|
||||
| Hook | When it fires | What it does |
|
||||
|------|---------------|--------------|
|
||||
| **SessionStart** | Startup, resume, after compaction | Injects pending/in-progress sub-tasks into context |
|
||||
| **UserPromptSubmit** | Each user prompt | Fuzzy-matches prompt against pending sub-tasks, suggests starting a match |
|
||||
| **Stop** | When AI finishes responding | Reminds about in-progress sub-tasks; flags untracked work if source files changed |
|
||||
| **PreToolUse** | Before tool execution | Project-specific guardrails (if configured) |
|
||||
| **PostToolUse** | After tool execution | Project-specific follow-ups (if configured) |
|
||||
|
||||
These hooks ensure sub-task awareness even after context compaction. Hook configuration lives in `.claude/settings.json`.
|
||||
|
||||
---
|
||||
|
||||
## Skills (Slash Commands)
|
||||
|
||||
SubFrame provides optional slash commands for AI coding tools that support them (e.g., Claude Code):
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `/sub-tasks` | Interactive sub-task management — list, start, complete, add, archive |
|
||||
| `/sub-docs` | Sync all SubFrame documentation after feature work (changelog, CLAUDE.md, PROJECT_NOTES, STRUCTURE) |
|
||||
| `/sub-audit` | Code review + documentation audit on recent changes |
|
||||
| `/onboard` | Bootstrap SubFrame files from existing codebase context |
|
||||
|
||||
Skills are deployed to `.claude/skills/` and enhance the workflow — but direct file editing always works as a fallback. If your AI tool doesn't support skills, follow the manual instructions in each section below.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task Management
|
||||
|
||||
> **Terminology:** "Sub-Tasks" are SubFrame's project task tracking system. The name plays on "Sub" from SubFrame and disambiguates from Claude Code's internal todo tools. When the user says "sub-task", they mean this system.
|
||||
|
||||
### Sub-Task File Format
|
||||
|
||||
Each sub-task lives in its own markdown file at `.subframe/tasks/<id>.md` with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: task-abc12345
|
||||
title: Short and clear title (max 60 characters)
|
||||
status: pending | in_progress | completed
|
||||
priority: high | medium | low
|
||||
category: feature | fix | refactor | docs | test | chore
|
||||
description: AI's detailed explanation — what, how, which files affected
|
||||
userRequest: User's original prompt/request — copy exactly
|
||||
acceptanceCriteria: When is this task done? Concrete testable criteria
|
||||
blockedBy: [] # task IDs this depends on
|
||||
blocks: [] # task IDs that depend on this
|
||||
createdAt: ISO timestamp
|
||||
updatedAt: ISO timestamp
|
||||
completedAt: ISO timestamp | null
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
[YYYY-MM-DD] Session notes, alternatives considered, dependencies.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Completed step
|
||||
- [ ] Pending step
|
||||
```
|
||||
|
||||
A generated index is kept at `.subframe/tasks.json` for hooks and quick lookups. After creating or modifying task `.md` files, regenerate the index by reading all `.subframe/tasks/*.md` files (excluding `archive/`) and building the JSON with tasks grouped by status.
|
||||
|
||||
### Sub-Task Recognition Rules
|
||||
|
||||
**These ARE SUB-TASKS:**
|
||||
- When the user requests a feature or change
|
||||
- Decisions like "Let's do this", "Let's add this", "Improve this"
|
||||
- Deferred work: "We'll do this later", "Let's leave it for now"
|
||||
- Gaps or improvement opportunities discovered while coding
|
||||
- Situations requiring bug fixes
|
||||
|
||||
**These are NOT SUB-TASKS:**
|
||||
- Error messages and debugging sessions
|
||||
- Questions, explanations, information exchange
|
||||
- Temporary experiments and tests
|
||||
- Work already completed and closed
|
||||
- Instant fixes (like typo fixes)
|
||||
|
||||
### Sub-Task Creation Flow
|
||||
|
||||
1. Detect sub-task patterns during conversation
|
||||
2. **Check existing sub-tasks first** — read `.subframe/tasks.json` to avoid duplicates
|
||||
3. Ask the user: "I identified these sub-tasks from our conversation, should I add them?"
|
||||
4. If approved, create `.subframe/tasks/<id>.md` with all required frontmatter fields
|
||||
5. Regenerate the `.subframe/tasks.json` index
|
||||
|
||||
### Sub-Task Content Rules
|
||||
|
||||
**title:** Short, action-oriented
|
||||
- OK: "Add tasks button to terminal toolbar"
|
||||
- Bad: "Tasks"
|
||||
|
||||
**description:** AI's detailed technical explanation
|
||||
- What will be done, how, which files affected
|
||||
- Minimum 2-3 sentences
|
||||
|
||||
**userRequest:** User's original words — copy verbatim for context preservation
|
||||
|
||||
**acceptanceCriteria:** Concrete, testable completion criteria
|
||||
|
||||
### Sub-Task Status Updates
|
||||
|
||||
**Before starting any work**, check `.subframe/tasks.json` for an existing sub-task that matches. If found, set it to `in_progress` — do not create a duplicate.
|
||||
|
||||
- `pending` → `in_progress` — immediately when you begin working (update `updatedAt`)
|
||||
- `in_progress` → `completed` — when done and verified (set `completedAt`, update `updatedAt`)
|
||||
- `completed` → `pending` — when reopening, add a note explaining why
|
||||
- After commit: check and update the status of all related sub-tasks
|
||||
- **Incomplete work:** If partially done at session end, leave as `in_progress` and add a notes entry
|
||||
|
||||
### Sub-Task Lifecycle
|
||||
|
||||
- If a sub-task grows beyond its original scope, split it — create new sub-tasks and reference the parent ID in notes
|
||||
- Cross-reference relevant commit hashes or PR numbers in notes
|
||||
- Update the description if the approach changes significantly
|
||||
|
||||
### Priority Guidelines
|
||||
|
||||
- **high** — Blocking other work or explicitly flagged as urgent by the user
|
||||
- **medium** — Normal feature work and standard bug fixes
|
||||
- **low** — Nice-to-have improvements, deferred items, minor polish
|
||||
|
||||
---
|
||||
|
||||
## .subframe/PROJECT_NOTES.md Rules
|
||||
|
||||
### When to Update?
|
||||
- When an important architectural decision is made
|
||||
- When a technology choice is made
|
||||
- When an important problem is solved and the solution method is noteworthy
|
||||
- When an approach is determined together with the user
|
||||
|
||||
### Format
|
||||
Free format. Date + title is sufficient:
|
||||
```markdown
|
||||
### [YYYY-MM-DD] Topic title
|
||||
Conversation/decision as is, with its context...
|
||||
```
|
||||
|
||||
### Update Flow
|
||||
- Update immediately after a decision is made
|
||||
- You can add without asking the user (for important decisions)
|
||||
- You can accumulate small decisions and add them in bulk
|
||||
|
||||
### Organization Rules
|
||||
- Keep **"Project Vision"** at the top, then **"Session Notes"** in chronological order
|
||||
- Notes should capture the **why** (decisions, trade-offs, alternatives rejected), not the **what** (code structure belongs in STRUCTURE.json)
|
||||
- When the same topic spans multiple sessions, consolidate related notes under the original heading rather than creating duplicates
|
||||
- When notes grow beyond ~500 lines, consider archiving older session notes or grouping by month
|
||||
|
||||
---
|
||||
|
||||
## Context Preservation (Automatic Note Taking)
|
||||
|
||||
SubFrame's core purpose is to prevent context loss. Capture important moments and ask the user.
|
||||
|
||||
### When to Ask?
|
||||
|
||||
Ask the user: **"Should I add this to .subframe/PROJECT_NOTES.md?"** when:
|
||||
|
||||
- A sub-task is successfully completed
|
||||
- An important architectural/technical decision is made
|
||||
- A bug is fixed and the solution method is noteworthy
|
||||
- "Let's do this later" is said (also add as a sub-task)
|
||||
- A new pattern or best practice is discovered
|
||||
|
||||
### Importance Threshold
|
||||
|
||||
**Would it take more than 5 minutes to re-derive or re-explain in a future session?** If yes, capture it.
|
||||
|
||||
**Always capture:** Architecture decisions, technology choices, approach changes, user preferences discovered during work.
|
||||
|
||||
**Never capture:** Routine debugging steps, simple config changes, typo fixes.
|
||||
|
||||
**Note failed approaches too** — a brief "We tried X, it didn't work because Y" prevents future re-exploration of dead ends.
|
||||
|
||||
### Completion Detection
|
||||
|
||||
Pay attention to these signals:
|
||||
- User approval: "okay", "done", "it worked", "nice", "fixed", "yes"
|
||||
- Moving from one topic to another
|
||||
- User continuing after build/run succeeds
|
||||
|
||||
### How to Add?
|
||||
|
||||
1. **DON'T write a summary** — Add the conversation as is, with its context
|
||||
2. **Add date** — In `### [YYYY-MM-DD] Title` format
|
||||
3. **Add to Session Notes section** — At the end of PROJECT_NOTES.md
|
||||
|
||||
### When NOT to Ask
|
||||
|
||||
- For every small change (it becomes spam)
|
||||
- Typo fixes, simple corrections
|
||||
- If the user already said "no" or "not needed", don't ask again for that topic
|
||||
|
||||
### If User Says "No"
|
||||
|
||||
No problem, continue. The user can also say what they consider important themselves: "add this to notes"
|
||||
|
||||
---
|
||||
|
||||
## .subframe/STRUCTURE.json Rules
|
||||
|
||||
**This file is the map of the codebase.**
|
||||
|
||||
### When to Update?
|
||||
- When a new file/folder is created
|
||||
- When a file/folder is deleted or moved
|
||||
- When module dependencies change
|
||||
- When an IPC channel is added or changed
|
||||
- When an important architectural pattern is discovered (architectureNotes)
|
||||
|
||||
### Full Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"modules": {
|
||||
"main/moduleName": {
|
||||
"file": "src/main/moduleName.ts",
|
||||
"description": "What this module does",
|
||||
"exports": ["init", "loadData"],
|
||||
"depends": ["fs", "path", "shared/ipcChannels"],
|
||||
"functions": {
|
||||
"init": { "line": 15 },
|
||||
"loadData": { "line": 42 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipcChannels": {
|
||||
"CHANNEL_NAME": {
|
||||
"direction": "renderer → main",
|
||||
"handler": "main/moduleName"
|
||||
}
|
||||
},
|
||||
"architectureNotes": {
|
||||
"topicName": {
|
||||
"issue": "Description of the pattern or concern",
|
||||
"solution": "How it was resolved"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Rules
|
||||
- The pre-commit hook (if configured) auto-updates STRUCTURE.json when source files in `src/` are committed
|
||||
- When deleting files, remove their entries from `modules` and update any `depends` arrays that referenced them
|
||||
- When adding IPC channels, also add them to the `ipcChannels` section with `direction` and `handler`
|
||||
- `architectureNotes` is for **structural patterns** (e.g., circular dependency workarounds, init ordering). Use PROJECT_NOTES.md for **decisions and session context**
|
||||
- If function line numbers drift significantly after edits, re-run the pre-commit hook or update manually
|
||||
|
||||
---
|
||||
|
||||
## .subframe/docs-internal/ Directory
|
||||
|
||||
This directory holds project documentation that doesn't belong in the root:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `changelog.md` | Track changes under `## [Unreleased]`, grouped by Added/Changed/Fixed/Removed |
|
||||
| `*.md` (ADRs) | Architecture Decision Records for significant design choices |
|
||||
|
||||
**What goes here:** Changelog entries, architecture decision records, internal reference docs.
|
||||
|
||||
**What does NOT go here:** User-facing docs (those go in `docs/` or project root), task files (those go in `.subframe/tasks/`).
|
||||
|
||||
---
|
||||
|
||||
## .subframe/QUICKSTART.md Rules
|
||||
|
||||
### When to Update?
|
||||
- When installation steps change
|
||||
- When new requirements are added
|
||||
- When important commands change
|
||||
|
||||
---
|
||||
|
||||
## Before Ending Work
|
||||
|
||||
After significant work (code changes, architecture decisions), verify SubFrame files are in sync:
|
||||
|
||||
1. **Sub-Tasks** — Was this work tracked? Check `.subframe/tasks.json` → create/complete as needed
|
||||
2. **PROJECT_NOTES.md** — Any decisions worth preserving? Ask the user
|
||||
3. **Changelog** — Does `.subframe/docs-internal/changelog.md` reflect the changes?
|
||||
4. **STRUCTURE.json** — Source files changed? The pre-commit hook handles this automatically if configured; otherwise update manually
|
||||
|
||||
The stop hook (if configured) will flag untracked work automatically.
|
||||
|
||||
---
|
||||
|
||||
## General Rules
|
||||
|
||||
1. **Language:** Write documentation in English (except code examples)
|
||||
2. **Date Format:** ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)
|
||||
3. **After Commit:** Check sub-tasks (`.subframe/tasks/*.md`) and `.subframe/STRUCTURE.json`
|
||||
4. **Session Start:** Read STRUCTURE.json, PROJECT_NOTES.md, and tasks.json before making changes
|
||||
5. **Don't Duplicate:** Always check existing sub-tasks before creating new ones
|
||||
|
||||
---
|
||||
|
||||
*This file was automatically created by SubFrame.*
|
||||
*Creation date: 2026-04-14*
|
||||
|
||||
<!-- subframe-template-version: 1 -->
|
||||
# AGENTS.md
|
||||
|
||||
Universal agent instructions for **Hermes-Relay**. This is the entry point for any
|
||||
coding agent (Claude Code, Codex, Cursor, etc.).
|
||||
|
||||
## Read this first
|
||||
|
||||
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
|
||||
architecture, the upstream Hermes API reference, repository layout, per-language
|
||||
code style, the dev loop, and the Key Files map. Read it before touching code,
|
||||
then `docs/spec.md` and `docs/decisions.md`.
|
||||
|
||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection —
|
||||
chat via the API server, standard voice via the Hermes dashboard — must work
|
||||
against unmodified upstream hermes-agent. Server-side needs go through upstream
|
||||
PRs or the optional relay plugin, never fork patches.
|
||||
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
|
||||
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
|
||||
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
|
||||
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
||||
|
||||
## Public-repo writing hygiene
|
||||
|
||||
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
|
||||
notes:
|
||||
|
||||
- **No personal names** — attribute impersonally; identity lives in git + the
|
||||
signing cert.
|
||||
- **No private infrastructure** — real hostnames/IPs, internal deployment names,
|
||||
`~/SYSTEM.md`. (Generic example IPs in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** ("I should have…", course
|
||||
corrections) — state the technical conclusion only.
|
||||
- **No internal jargon or fork/branch plumbing** in user-facing notes.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping; condense the version block to
|
||||
crisp public bullets at release-prep (see RELEASE.md §2 "Scrub for public
|
||||
distribution"). **DEVLOG** is a depersonalized, factual engineering log.
|
||||
|
||||
+826
-5
@@ -6,6 +6,823 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Automated Play Console upload on release.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, the release builds publish to GitHub Releases exactly as before.
|
||||
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
|
||||
- **Plugin: guided env-key setup.** The relay plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of hand-editing `.env`. The standard no-plugin path needs none.
|
||||
- **Plugin: native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
|
||||
- **`/relay` slash commands.** `relay status · devices · pair` usable mid-conversation from any platform (CLI / Discord / TUI).
|
||||
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
|
||||
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release names normalized by surface.** Future GitHub Releases are named `Hermes-Relay-Android`, `Hermes-Relay-Plugin`, and `Hermes-Relay-CLI`, with future tags on `android-v*`, `plugin-v*`, and `cli-v*`. The CLI installer and updater still understand historical `desktop-v*` prereleases during the migration.
|
||||
- **Per-surface release notes.** Plugin and CLI GitHub Releases now use hand-written `PLUGIN_RELEASE_NOTES.md` / `CLI_RELEASE_NOTES.md` files (Summary + Added/Changed/Fixed + Install/Verify) — the same format as Android's `RELEASE_NOTES.md` — instead of static boilerplate baked into the workflow. The release workflows substitute the version into the install commands automatically.
|
||||
- **Settings screen overhaul (Android).** Status pills are now exception-only — they appear only when a surface needs attention and stay quiet when healthy. The Power tools section shows a single state-aware **Plugin active / required / offline** badge instead of an identical "Relay paired" chip on every card. Connections moved to the top (above the Hermes section), Diagnostics + Developer options moved into the App section, the status chips were restyled to match the app's translucent-bordered language, and the brand blue was deepened.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Force-close on connect when the stored credential keyset was corrupt.** A corrupt encrypted token store (which can happen after an app upgrade or device restore) threw during construction and crashed the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset on the spot, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
|
||||
- **Dashboard plugin: unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background. Solid button variants now keep their proper contrast colour.
|
||||
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
|
||||
- **Chat settings (Android).** The streaming-endpoint picker no longer wraps "Gateway"/"Sessions" onto a second line, and the system-prompt preview now reflects the enabled context toggles (foreground app, battery, safety rails) with representative placeholder values instead of looking inert.
|
||||
- **Dashboard plugin: buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button`/`Badge` use boolean variant flags (`outlined`/`ghost`/`invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter, and drops a label-hiding CSS reset.
|
||||
|
||||
## [1.0.0] - 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay plugin diagnostics and install guidance.** `hermes relay doctor` now reports standard upstream API/dashboard reachability, Relay loopback state, dashboard plugin presence, plugin-manager layout, and whether the legacy bootstrap monkeypatch is installed. The plugin manifest now advertises its Android and desktop tools, and `after-install.md` gives the upstream plugin manager a first-run handoff.
|
||||
|
||||
- **Plugin-owned compatibility hook lifecycle.** `hermes relay compat status/install/remove` now owns the optional `hermes_relay_bootstrap.pth` startup hook, so the monkeypatch can be inspected, added, or removed without rerunning the legacy installer. The standard v1.0.0 path does not require this hook.
|
||||
|
||||
- **Legacy cleanup alignment.** The legacy installer now installs the optional `.pth` hook through the plugin compat lifecycle, and the uninstaller removes every shell shim it creates (`hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale`) while delegating hook cleanup to `hermes relay compat remove` when available.
|
||||
|
||||
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
|
||||
|
||||
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
|
||||
|
||||
- **Gateway warm-start + Keep connected in background.** Pre-warming the gateway on foreground moves the cold session-setup cost off the send path. An opt-in foreground-service toggle (both flavors; `specialUse`, off by default) holds the socket open in the background so a long-backgrounded conversation resumes instantly.
|
||||
|
||||
- **Switch agent profiles from chat.** Pick a different agent — model, SOUL, personality, and skills — per conversation. The selection is **ephemeral** (bound to the session like the official desktop; it never changes the server's default agent for other clients). The session drawer scopes to the active profile and loads that profile's history, and the right agent is restored on cold start. The Manage tab's server-wide **Activate Profile** action now confirms first.
|
||||
|
||||
- **Manage parity with the desktop dashboard.** Change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk for an instant cold launch.
|
||||
|
||||
- **Open & save chat images and attachments.** Tap an image for a full-screen viewer (pinch-zoom, double-tap, Share/Save); non-image attachments gain an Open/Share/Save menu. Saves land in `Pictures`/`Download/Hermes-Relay` with no permission on Android 10+, preserving the original bytes.
|
||||
|
||||
- **Persistent Realtime Agent voice + background runs (ADR 33).** The realtime engine keeps one session across turns (follow-ups retain context); a long Hermes run is promoted to a tracked background task and spoken when ready, so the conversation stays responsive.
|
||||
|
||||
- **Redesigned chat input bar.** A Telegram-clean pill field with one trailing button that morphs between Send / Voice / Stop / Steer / Queue; the slash button is gone (typing `/` still opens autocomplete).
|
||||
|
||||
- **Routes card reachability verdicts** ("Reachable", or the specific failure reason) and per-turn **latency tracing** (`TurnLatency`, durations only) for diagnosing transport speed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Relay plugin/server version aligned to v1.0.0.** The Python package, plugin manifest, dashboard manifest, and relay runtime now use the same `1.0.0` line as the stable Android release so a retagged source checkout describes one product version.
|
||||
|
||||
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
|
||||
|
||||
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
|
||||
|
||||
- **Editable, roaming routes.** Add/edit/remove routes in Settings → Connections; bare-host URLs default their scheme and port (and preview what will be saved); remote-access (Tailscale) is surfaced in the main setup flow with a "Remote" readiness line.
|
||||
|
||||
- **Faster Manage.** A shared auth preamble plus concurrent payloads cut a full load from ~40 round trips to ~12; a process-lifetime cache and startup pre-warm render the last-seen data instantly, and Manage now names which dashboard URL it's talking to.
|
||||
|
||||
- **Faster, calmer cold start.** Key-less connections skip the multi-second keystore decrypt; the startup sphere is now the actual loading screen with narrated check lines, and the OS splash blends into it.
|
||||
|
||||
- **Docs + branding.** The docs site was rechromed to the app theme and repositioned around the two-path story; the README and Play listing were refreshed standard-first; product-name copy normalized to **Hermes-Relay**.
|
||||
|
||||
- **Quality-of-life.** Quote-in-reply, share-conversation-as-Markdown, ambient mode as a long-press gesture, a floating status pill, decluttered Manage cards, back buttons on pushed screens, and a softer active-connection card.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No "Connect to Hermes" flash on cold start.** The empty-state now distinguishes "still hydrating from disk" from "nothing configured" (`ConnectionStore.isHydrated` → `chatConnectState`), showing a quiet "Connecting to Hermes…" spinner until ready and the connect CTA only once hydration confirms no connection exists.
|
||||
|
||||
- **In-app What's New renders cleanly** — parsed into a version subtitle, bold section headers, and real bullets instead of raw text with literal `*`.
|
||||
|
||||
- **App-start UI freeze from Keystore lock contention.** The encrypted cookie store built its StrongBox-backed prefs eagerly in its constructor (1–4 s under a process-global lock) from several code paths at once; it now builds lazily on an I/O thread and is shared per connection.
|
||||
|
||||
- **Standard connections now follow LAN↔Tailscale changes**, standard voice follows the resolved route (not the persisted URL), and a stale probe cache can no longer pin a dead route after a handoff or resume.
|
||||
|
||||
- **Editing a URL no longer wipes fallback routes** (edits merge with stored extras instead of rebuilding from the edited URL alone); **"Re-check" / "Use now" no longer fail silently** (the probe always publishes its outcome and per-route failure reasons); and a network change can no longer resurrect a deliberately disconnected relay socket.
|
||||
|
||||
## [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*`, plugin/Python releases use `server-v*`, and CLI releases continue on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Plugin vX.Y.Z`, and `Hermes-Relay-CLI 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 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.** 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.
|
||||
|
||||
- **Experimental realtime Hermes voice-agent plan.** `docs/plans/2026-05-19-realtime-hermes-voice-agent.md` records the next architecture step: provider-native realtime speech with Hermes-brokered profiles, sessions, tools, confirmations, and transcript mirroring. The stable Hermes chat + voice-output path remains the default.
|
||||
|
||||
- **Desktop tray pairing and consent flow.** The desktop surface gained Tauri tray pairing, QR/consent affordances, sidecar preparation, and computer-action approval polish so desktop and Android pairing flows are closer to parity.
|
||||
|
||||
- **Shared relay/Quest scaffolding.** Experimental `relay-core`, `relay-ui`, and Quest prototype modules were added for shared pairing, terminal, transport, voice, and morphing-sphere work without changing the Android phone app's default route.
|
||||
|
||||
- **Desktop Chat tab with first-run route setup.** The Tauri tray dashboard now has a Chat tab inspired by the Hermes Desktop chat-first flow. It streams through the saved paired relay when `~/.hermes/remote-sessions.json` has an active session, or through a direct Hermes gateway/API URL when relay pairing is not available. The tab supports stop, retry, new chat, clear, current-session transcript history, and a setup panel that offers relay pairing or direct WebAPI configuration without saving the optional API key.
|
||||
|
||||
- **First-class desktop TUI tab.** The Tauri tray dashboard now gives the embedded xterm/PTY Hermes session its own sidebar tab instead of nesting it under Terminal / CLI. Terminal remains the external launcher, shim-state, and copyable-command surface, while plugin embeds route into the same TUI tab.
|
||||
|
||||
- **Desktop surface plugins.** The desktop CLI and Tauri tray now register built-in terminal surface plugins, starting with Herm (`herm-tui`) from `liftaris/herm`. Users can inspect plugin status, install or update Herm, launch a fresh dashboard, resume with `herm -c`, or embed the plugin in the tray's xterm/PTY surface with `bunx`/`npx` fallback when the `herm` binary is not installed.
|
||||
|
||||
- **Relay server release track.** Relay server and Python package releases now use `relay-v*` tags, validate relay-owned version metadata, build wheel/sdist artifacts, generate checksums, and publish through `.github/workflows/release-relay.yml`. This lets Relay server fixes ship independently from Android app `versionCode` bumps and desktop CLI alphas.
|
||||
|
||||
- **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 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.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Stable voice is now the main Android voice path.** Voice mode defaults to Hermes chat streaming plus relay-managed voice output, with realtime-provider work kept as a standalone lab/testbench and future experimental mode instead of replacing Hermes session/tool authority.
|
||||
|
||||
- **Realtime voice output uses balanced coalescing.** Normal assistant speech is batched into more natural chunks while tool/status speech stays immediate, reducing provider render resets and tone/volume variation during voice replies.
|
||||
|
||||
- **Voice settings are profile-scoped and option-aware.** Android can fetch provider/model/voice options from relay endpoints, show profile context in voice settings, save voice choices per Hermes profile, and expose advanced manual entry when provider metadata is incomplete.
|
||||
|
||||
- **Voice UI state is synchronized with chat state.** Voice mode now reuses more of the chat session/profile state, preserves live transcript and tool timeline visibility, and improves overlay exit/minimize behavior for hands-free use.
|
||||
|
||||
- **Release versioning is split by surface.** Android app releases remain on `v*` and use `gradle/libs.versions.toml`; Relay releases use `relay-v*` and keep `pyproject.toml`, `plugin/relay/__init__.py`, `plugin/plugin.yaml`, and dashboard plugin metadata in lockstep; desktop remains on `desktop-v*` and `desktop/package.json`. `scripts/bump-version.sh` is now a backward-compatible Android alias, with new explicit `scripts/bump-android-version.sh` and `scripts/bump-relay-version.sh` helpers.
|
||||
|
||||
- **Upstream voice imports are isolated.** Relay voice routes now call upstream Hermes STT/TTS helpers through `plugin.relay.upstream_voice`, keeping private upstream voice helper imports in one adapter module until Hermes exposes a stable HTTP voice API.
|
||||
|
||||
- **CI paths and release actions tightened.** Relay CI now watches Relay-owned paths instead of all `plugin/**`, validates Relay version metadata during syntax checks, uses explicit timeouts, and runs the focused route/auth/session test slice instead of broad test discovery. Release workflows now use `softprops/action-gh-release@v3`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Profile switching no longer silently falls back to the wrong local API host.** Profile API URL resolution now handles per-profile Hermes API servers, default/Victor compatibility, and relay-managed profile metadata so selecting a profile does not try to create sessions against `localhost` from the phone.
|
||||
|
||||
- **Non-default profile names remain visible in chat.** Agent display metadata is normalized so selected profile names persist above finalized assistant messages instead of disappearing back to the default label after stream completion.
|
||||
|
||||
- **Voice waveform and playback state are better aligned to real audio.** The output waveform waits for audio playback, handles processing separately, and avoids returning to the microphone too early at the end of an assistant response.
|
||||
|
||||
- **Continuous voice mode no longer starts a session just because auto mode is enabled.** Auto/continuous remains a preference, while explicit voice start/stop controls decide when a voice session is active.
|
||||
|
||||
- **Android voice mode no longer 403s when paired over plain-LAN `ws://` with a Hermes API key saved.** Symptom: tap the mic in Voice mode → red banner *"Voice access expired — extend or re-pair with voice grants"* even though the Connections card shows API Server / Relay / Session all green. Root cause: `RelayVoiceClient` preferred the saved Hermes API key over the paired Relay session token; the relay's `_request_is_secure_enough_for_api_bearer` correctly rejects API-bearer auth on `/voice/*` over plaintext outside loopback/Tailscale, returning a generic 403 that the client flattened to "expired." Fix: invert bearer precedence so paired devices use the session token first (no transport guard — it's the credential the QR/pair handshake already established), with the API key as fallback for chat+voice-only installs that never paired. `describeHttpError` now also reads the server's text/plain response body when present so future 403s show the relay's actual reason instead of a one-size-fits-all string.
|
||||
|
||||
## [0.6.1] - 2026-05-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Android bridge media sharing and MMS handoff.** New `android_share_media` and `android_send_mms` tools expose full file/attachment support through the relay media registry and Android `FileProvider` `content://` grants. Host-local paths are registered with `/media/register`, phones fetch bytes with their paired relay session, and the sideload app opens Android's native share or MMS compose UI after on-device confirmation. Relay HTTP now includes `/share_media` and `/send_mms`, and docs spell out that direct `android_send_sms` remains text-only `{to, body}`.
|
||||
|
||||
- **Relay voice endpoints accept Hermes API bearer tokens.** `/voice/config`, `/voice/transcribe`, and `/voice/synthesize` now accept either a Relay session token with explicit `voice:*` grants or the existing Hermes API bearer token. API bearer validation is voice-only, uses the configured Hermes API server's protected `/v1/models` endpoint with a short positive cache, and rejects non-loopback plaintext by default unless a trusted HTTPS proxy header or the explicit dev escape hatch is configured. Existing Relay sessions are backfilled with voice grants so paired phones do not need to re-pair.
|
||||
|
||||
- **Relay CLI can toggle plain-LAN API-key voice auth without restart.** `hermes relay insecure-api-key status|on|off` calls the running relay's loopback-only `/relay/security` endpoint and flips the runtime `allow_insecure_api_bearer` flag immediately. This keeps HTTPS as the default for API-key voice auth while making Android phone LAN smoke tests possible without exporting env vars or restarting the service.
|
||||
|
||||
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
|
||||
|
||||
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Reported gap: *"...we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android bridge tool/route contract drift.** The active plugin import now uses `plugin.tools.android_tool` as the single source of truth, while top-level `plugin/android_tool.py` remains as a compatibility shim. The relay now registers `/return_to_hermes`, matching the documented and phone-side command, and bridge status gating checks `/bridge/status` so tools are hidden unless a phone is actually connected.
|
||||
|
||||
- **Android CI/release gate no longer hangs on the broad Gradle test aggregate.** The Android CI and `v*` release workflows now run the stable sideload pairing/connection regression slice with explicit timeouts while the deferred full JVM test-suite cleanup remains tracked separately.
|
||||
|
||||
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
|
||||
|
||||
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** A user saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
|
||||
|
||||
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** On alpha.9, `hermes-relay update --check` expected to see alpha.10 but reported "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
|
||||
|
||||
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android voice no longer requires Relay pairing when a Hermes API key is saved.** The phone now resolves voice auth from the saved Hermes API key first, then falls back to the paired Relay session for `/voice/config`, `/voice/transcribe`, and `/voice/synthesize`. Chat+voice-only setups can use manual/API-key configuration without the full pairing-code flow; bridge, terminal, media, clipboard, profile writes, and Android-control routes remain paired-session-only.
|
||||
|
||||
- **Relay grant labels are now human-readable in Android and dashboard management UI.** Relay session grant chips still preserve the server keys internally, but user-facing lists now sort the known grant set and render labels such as `Voice STT` / `Voice TTS` instead of raw `voice:stt` / `voice:tts`. Privacy and configuration docs now reflect that Voice mode uses runtime microphone permission and split voice grants.
|
||||
|
||||
- **Desktop CLI alpha.8 — `/screenshot` is multi-monitor aware by default.** The alpha.6/alpha.7 `screenshotHandler` / `captureScreenshot` captured only the primary display on Windows and treated `display` as a number-only param. alpha.8 changes the default to `-1` (all monitors stitched) and accepts string aliases so both the agent tool call and the `/screenshot` slash command can say `'all'` / `'primary'` / `'1'` / `'2'` etc. Windows path uses `System.Windows.Forms.SystemInformation.VirtualScreen` for the union rect (handles negative coordinates when monitors are arranged left-of-primary). macOS path uses `screencapture -D N` for 1-indexed per-display capture. Linux path relies on grim/scrot/import's inherent whole-X-screen behavior. REPL `/screenshot` defaults to all monitors; `/screenshot primary` or `/screenshot 0 | 1 | 2` narrow. Live smoke on a multi-monitor Windows box: all = 1.6 MB stitched, primary = 405 KB — 4× size ratio confirms virtual-screen path. Zero server changes; `image.attach.bytes` RPC consumes whatever bytes the client sends.
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop CLI alpha.7 — native image paste in `hermes-relay chat`.** `desktop-v0.3.0-alpha.7`. Plan: [`docs/plans/2026-04-23-desktop-alpha-7-native-paste.md`](docs/plans/2026-04-23-desktop-alpha-7-native-paste.md). Users now type `/paste` (system clipboard), `/screenshot` (primary display), or `/image <path>` (file on disk) inside the `chat` REPL, get a one-line feedback echo (`[📎 clipboard 1920×1080, 234 KB — attached to next message]`), and the NEXT `prompt.submit` ships with the image attached so the vision-capable model sees it in the same turn. Parity with Claude Desktop's paste behavior — minus OS-level Ctrl+V, which terminals fundamentally don't deliver image bytes through. Spans two repos: the client half is new `desktop/src/chatAttach.ts` (captureClipboardImage / captureScreenshot / readImageFile — platform-shelled like the alpha.6 clipboard handler: Windows PowerShell `Get-Clipboard -Format Image` + `System.Drawing.Bitmap.CopyFromScreen`, macOS `pngpaste`/`screencapture -x -t png`, Linux Wayland-first `wl-paste --type image/png`/`grim` with X11 `xclip`/`scrot` fallbacks) plus new slash-command branches in `desktop/src/commands/chat.ts`; the server half is ONE new `@method("image.attach.bytes")` RPC handler on the fork's `tui_gateway/server.py` (`Codename-11/hermes-agent` branch `feat/image-attach-bytes` → merged to `axiom`) that accepts `{session_id, format, bytes_base64, filename_hint?}`, validates magic bytes (PNG `89 50 4E 47` / JPEG `FF D8 FF` / WEBP `RIFF....WEBP`) to prevent content-type laundering, decodes to `~/.hermes/images/remote_<ts>_<rand6>.<ext>`, and appends to `session["attached_images"]`. The fork's **existing** `_enrich_with_attached_images` pipeline already handles the hard part — multimodal payload plumbing, session-scoped image state, vision-model routing — so this release is almost entirely about bridging client-captured bytes to the server-side state that's been there for months. The `tui` relay channel is a transparent RPC forwarder; zero relay changes. Fallback when hermes-host hasn't been updated yet: client's `image.attach.bytes` RPC call gets `method not found`, client catches it specifically and prints `[attach failed: method not found — server may need axiom rollout]` to stderr, REPL stays alive, user can still send text — no crash, and the exact error points the operator at the fix. Non-goals locked for this release: no Ctrl+V terminal keybinding (terminals don't pipe image bytes to stdin — that's OS-level), no Kitty/iTerm2 inline image protocols (defer to alpha.10+), no PTY shell-mode support (the remote `hermes` CLI has its own paste handling), no multimodal `prompt.submit` payload extension (the attach-then-submit pattern is cleaner and matches the existing server state model).
|
||||
|
||||
- **Desktop CLI alpha.6 — seamless-local dev pass.** Nine features across six parallel agent workstreams delivered in one integration. Plan: [`docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md`](docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md). (1) **Workspace-awareness envelope** (`#1+#8`) — new `src/workspaceContext.ts` detects `cwd`/`git_root`/`git_branch`/`git_status_summary`/`repo_name`/`hostname`/`platform`/`arch`/`active_shell` via parallel `git rev-parse`/`git status --porcelain=v1 --branch` calls under a 2 s total budget; `RelayTransport` auto-sends a `desktop.workspace` envelope after first `auth.ok` (guarded against reconnect re-send); server-side `plugin/relay/channels/desktop.py::DesktopChannel` stashes per-ws as ephemeral session metadata. Active-editor hints (`src/activeEditor.ts`) poll tmux (`display-message -p "#{pane_current_path}:#{pane_current_command}"`) or detect VSCode/Cursor via `$VSCODE_IPC_HOOK_CLI`+`TERM_PROGRAM`; dedupes envelopes so only actual changes fire. New `hermes-relay workspace` subcommand prints the context; `doctor` output gains a `workspace:` block. Gated client-side by `--watch-editor` for the poller; envelope itself is always-on. (2) **`hermes-relay update` self-update** (`#2`) — new `src/updater.ts` + `src/commands/update.ts`. Polls GitHub Releases API (the same prerelease-aware resolver the installer uses), semver-compares to `VERSION`, downloads asset with SHA256 verification, and atomic-swaps on POSIX (`fs.rename` — running process's inode stays live so the daemon keeps running; next invocation picks up new binary). Windows can't replace a running `.exe`, so the updater writes to `<bin>.new.exe` and `finalizePendingUpdate()` runs at the top of `main()` on every subsequent invocation to rename it into place. `--check` dry-runs; `--yes` skips confirm; `--json` emits machine-readable status. (3+4) **Editor tool + interactive patch approval** (`#3+#4`) — new `src/tools/handlers/editor.ts` for `desktop_open_in_editor(path, line?, col?, wait?)` with launcher detection (`$VISUAL`→`$EDITOR`→PATH probe for `code`/`cursor`/`subl`/`nvim`/`vim`→platform fallback); `-g` injection for GUI editors supports `:line:col`. `desktop_patch` now routes through `src/tools/patchApproval.ts` in interactive mode — renders unified diff with ANSI (green/red/cyan, NO_COLOR/isTTY aware), prompts `y/n/e/r` via readline on stderr; `e` opens the patch in `$EDITOR` and re-reads on close. Non-interactive modes (daemon, piped stdin) auto-reject with structured reason; never auto-accepts. Router (`src/tools/router.ts`) carries an `interactive` flag set at construct time (`stdin.isTTY && HERMES_RELAY_DAEMON !== '1'`). (5) **Conversation picker on connect** (`#5`) — new `src/sessionPicker.ts` calls tui_gateway's `session.list` JSON-RPC (same RPC upstream Ink TUI uses), renders a numbered list with human-readable age + first-prompt preview. `shell.ts` injects after banner / before PTY attach, appending `--resume '<id>'` to the hermes exec when a session is picked. `chat.ts` injects before the chat loop. `--session <id>` (chat: legacy alias for `--conversation`; shell: tmux session name — distinct), `--conversation <id>` and `--new` bypass the picker. Graceful degradation: 404 / "method not found" returns empty list silently, picker falls through to `'new'`. (9+12) **Clipboard + screenshot handlers** (`#9+#12`) — `src/tools/handlers/clipboard.ts` and `.../screenshot.ts`. Clipboard: Windows `powershell Get-Clipboard -Raw` / `$input | Set-Clipboard` (strips trailing CRLF); macOS `pbpaste`/`pbcopy`; Linux Wayland-first (`wl-paste`/`wl-copy` via `$WAYLAND_DISPLAY`), xclip fallback. 5 s timeout, 10 MB cap both directions. Screenshot: Windows writes a temp `.ps1` using `System.Drawing.Bitmap.CopyFromScreen` (honors multi-monitor via `Screen.AllScreens[display]`); macOS `screencapture -x -t png`; Linux `grim`→`scrot`→`import` fallback chain. `save_to` keeps the file; otherwise base64 + tempfile delete. 10 s timeout, 50 MB cap. All three wired into `shell.ts`/`chat.ts`/`daemon.ts` router handler map (9 handlers advertised now, up from 5). (13) **`hermes` alias** (`#13`) — `install.sh` creates a POSIX symlink `~/.hermes/bin/hermes → hermes-relay`; `install.ps1` drops a universal `.cmd` shim (no admin required — avoids Windows symlink Developer-Mode requirement). Collision-safe: only creates if nothing else lives at that name. Uninstall scripts remove the alias only when it points at our binary (preserves an unrelated upstream hermes-agent install).
|
||||
|
||||
- **Dev-iteration additions.** `npm run smoke` expanded from 4 to 5 assertions (added `workspace`); still runs locally in ~1 s post-build. CI workflow already runs the equivalent 5-command smoke on the Linux binary before publishing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke runs in `release-cli.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
|
||||
- **`hermes-relay --version` printed `0.0.0` in compiled binaries.** `readVersion()` tried to read `package.json` via `__dirname + '../package.json'`, which doesn't resolve in a Bun `--compile` binary (no real filesystem layout). Replaced with a build-time-generated `src/version.ts` module (`npm run gen:version` writes the version from package.json before every build and every `build:bin:*`). `readVersion()` now just returns the embedded constant. Works identically in tsx / Node / Bun.
|
||||
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from the CLI release workflow, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
|
||||
- **Installer couldn't find alpha-only releases.** GitHub's `/releases/latest/download/` URL deliberately skips prereleases, so the default `curl | sh` / `irm | iex` one-liner failed against alpha.1 with "maybe no Windows release for this version yet?" Both `install.sh` and `install.ps1` now query the Releases API directly (`GET /repos/.../releases`, filter to `desktop-v*` tags, take first) when `HERMES_RELAY_VERSION=latest`. Pinned versions unchanged.
|
||||
|
||||
### 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 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>`.
|
||||
|
||||
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
|
||||
|
||||
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Transport Security badge is now role-aware — "Plain (on LAN)" instead of "Insecure (network unknown)".** The previous badge derived its label from `PairingPreferences.insecureReason`, which only got populated when the user toggled "Allow insecure connections" ON via the Ack dialog and picked a reason. If a user paired directly from a plain-`ws://` LAN QR, they never had to toggle that flag — the connection was already `ws://` — so the reason stayed blank and the badge degraded to the alarming `"Insecure (network unknown)"` even though the multi-endpoint resolver was tracking `activeEndpointRole = "lan"` in real time. Fix: `insecureReasonLabel` now accepts an optional `activeRole: String?` and prefers the live role over the stored ack reason (`Plain (on LAN)` / `Plain (on Tailscale)` / `Plain (on public URL)`). Neutral fallback when both role and reason are unknown is `"Plain (no TLS)"` — matches the new "Plain / Secure" vocabulary, drops the scary "Insecure" adjective. Binary-boolean `TransportSecurityBadge(isSecure, reason, ...)` overload gains an optional `activeRole` param with default `null` so existing call sites compile unchanged. `ConnectionViewModel.applyPairingPayload` auto-stamps `PairingPreferences.insecureReason` at pair time based on the selected endpoint's role (`lan` → `lan_only`, `tailscale` → `tailscale_vpn`, `public`/unknown → leave blank so the user thinks); clears any stale reason when upgrading to a secure endpoint. Only overwrites blank values — never clobbers a user-selected reason. Two user-visible "Insecure" strings inside the Advanced section's insecure-toggle subsection also rewritten to "Plain" for consistency (`"Plain connection — traffic is not encrypted"`, `"Allow plain (unencrypted) connections"`).
|
||||
|
||||
### Added
|
||||
|
||||
- **Bridge destructive-verb "Don't ask again" per verb.** `BridgeSafetyManager` now consults a new `trustedDestructiveVerbs: Flow<Set<String>>` in `BridgeSafetyPreferences` and short-circuits the confirmation overlay when the incoming verb is in the set (logging the auto-approval to the activity log so the trail is preserved). The `DestructiveVerbConfirmDialog` gets a `Don't ask again for "{verb}"` checkbox — off on every dialog open, so the user has to actively opt in per-action. Deny path never persists trust (denying a command is not consent). Kill-switch precedence is preserved and strictly ordered: master-disable wins over blocklist wins over per-verb trust. A trusted verb in a blocklisted app still 403s. `BridgeScreen` surfaces a `Trusted actions · N actions bypass confirmation` row with a `Reset` button under the existing safety section so a user who changes their mind can find the escape hatch without deep-linking to developer options. Addresses the confirmation-fatigue trap where approving `send_sms` 50 times trains the user to click through without reading the 51st.
|
||||
|
||||
- **AllInsecure pairing — one-time acknowledgment gate.** When every endpoint in a scanned QR is plain `ws://` / `http://` (no secure sibling to fall back to), `ConnectionWizard.ConfirmStep` now renders an `"I understand this pairing sends traffic in plain text — visible to anyone on the network."` checkbox that gates the Pair button. Per-install via new `PairingPreferences.allInsecurePairAckSeen` — once the user has acknowledged it, subsequent AllInsecure pairs pair one-tap. Mixed and AllSecure pairings are ungated (the amber "Mixed — secure fallback available" warning on Mixed is sufficient because the secure route exists). Matches the `InsecureConnectionAckDialog` precedent of per-install Tier-1 consent and complements the UX pass's explicit "subtle warning for Tier-2, forced confirm for Tier-1 absolute boundaries" philosophy documented in DEVLOG 2026-04-22.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connection UX self-narration pass — Route / Relay sessions vocabulary + section headers + per-route security chips.** Three linked problems shipped as one commit: (1) pairing step 2 read as "you're stuck with insecure" for any multi-endpoint QR with LAN first, because the security badge + warning card were both computed from `endpoints[0]` alone — never acknowledging a secure Tailscale fallback in the same list; (2) the post-refactor active card had the right structure but no narration — sections stacked without headers, no captions explaining what Routes / Advanced / Security are for, Advanced surfaced manual URLs with no "most people don't need this" framing; (3) "Paired Devices" sounded like Bluetooth to anyone outside the project — the actual concept is server-side relay sessions with per-channel grants. Fix: introduce a shared vocabulary (Route for network path, Active/Fallback for state, Secure/Plain for transport, Relay sessions for server records) used consistently across `ConnectionWizard.kt` ConfirmStep, `ActiveConnectionSections.kt` (all three body sections), `EndpointsCard.kt`, and `PairedDevicesScreen.kt`. New `TransportSecurityState` tri-state (`AllSecure` / `Mixed` / `AllInsecure`) drives a context-aware pairing badge — the Mixed case now reads "LAN is plain ws:// — fine at home or the office, not on public Wi-Fi. Tailscale is encrypted (wss://) and the app uses it automatically when LAN is unreachable. You're safe on any network." — so users see they have a secure fallback without needing to understand the candidate-list mental model. Active card gains four labelMedium section headers (Connection health / Routes (N) / Advanced / Security) each with a one-line bodySmall caption above the section body. Endpoint rows in both surfaces carry per-row Secure/Plain chips (green 🔒 / amber 🔓, not scary red) so each route's security is visible at a glance; ordinal labels are humanized (`1st choice` / `Fallback` / `Fallback 2` on pairing step 2; `Active` / `Fallback` on the active card — different framings because pre-connection the commitment is ordinal and post-connection what matters is state). `PairedDevices` Kotlin identifier and deep-link route string stay — only the user-visible labels change — so nav deep links are unaffected. New intro paragraph on the Relay sessions screen explains that rows are sessions (not Bluetooth pairings), and a tap-for-info icon on "Channel grants" opens a dialog explaining that chat/bridge/voice are per-feature permissions with independent expiries. Delivered as three parallel `general-purpose` implementation agents (one per surface, isolated file ownership) plus a post-implementation `code-reviewer` sweep that caught seven leftover `endpoint`/`Paired Devices` strings across `ConnectionInfoSheet.kt`, `SessionTtlPickerDialog.kt`, `EndpointsCard.kt`, `SettingsScreen.kt`, and the `Screen.PairedDevices` nav title — all corrected before commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Add-Connection navigation now fires on the tap instead of waiting for placeholder persistence.** Pre-fix, `RelayApp.kt`'s `onAddConnection` lambda awaited `beginAddConnection().join()` *before* calling `navController.navigate(Screen.Pair)` — so three serialized DataStore writes (addConnection / persistUrls / setActiveConnection) blocked the QR scanner appearing. On a warm device this was ~15-50 ms; on a cold / flash-pressured device it spiked to 100-150 ms, a visible freeze on every FAB tap. Fix pre-allocates the placeholder UUID synchronously on the UI thread, fires `navController.navigate(Screen.Pair.route(connectionId = id, autoStart = "scan"))` immediately, and runs `connectionViewModel.beginAddConnection(preAllocatedId = id)` in a fire-and-forget background coroutine. `ConnectionViewModel.beginAddConnection` gains an optional `preAllocatedId: String? = null` param — when provided, skips UUID generation, does an existence check (idempotent re-entry on double-tap / recomposition), and falls through to the existing mutex-guarded placeholder-build path. PairScreen's existing reactive `collectAsState` on `connectionStore.connections` / `activeConnectionId` picks up the placeholder milliseconds later — the user is still framing the QR. Critical path drops from three DataStore writes to zero; the writes still happen, just off the critical path. Zero behavior change for `preAllocatedId == null` callers (the legacy placeholder-reuse scan path is preserved byte-for-byte).
|
||||
|
||||
### Added
|
||||
|
||||
- **`relayReady` signal gates voice + bridge surfaces.** New `ConnectionViewModel.relayReady: StateFlow<Boolean>` composes three inputs — WSS `ConnectionState.Connected`, `AuthState.Paired`, AND non-blank `relayUrl` — into a single "WSS is actually functional" truth. ChatScreen's mic button dims + Toasts "Voice mode unavailable — relay not connected" instead of launching an overlay that would immediately fail on `/voice/transcribe`. BridgeScreen surfaces an error-container banner at the top of the scroll region so the user doesn't enable the master toggle expecting commands to flow. Soft-gate semantics — neither surface hard-disables, matching the existing Chat-send / Terminal-Refresh patterns; BridgeScreen intentionally still lets the user pre-configure permissions and safety rails before a relay pairs. Three-input (rather than the simpler two-input `chatReady` form) because the Case-C teardown edge — last connection removed, `_apiServerUrl`/`_relayUrl` blanked — can leave a stale `Paired` token alive alongside a dead URL; without the URL check the banner would never surface in that state.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connection settings unified — one screen, one mental model.** The pre-refactor app had two near-identically-named screens (`ConnectionSettings` singular, `ConnectionsSettings` plural) reached from two different Settings-top surfaces (Active Connection quick-look card vs. "Connections" category row), each covering overlapping functionality. Everything the singular screen did — pair QR entry, manual URL config, insecure toggle, manual pairing code fallback, 3 tappable status rows — now folds inline onto the **active card** of the plural screen as expandable body sections. The singular `ConnectionSettings` screen (1429 lines), its route, its `Screen` enum entry, its `onNavigateToConnectionSettings` param chain, and the Active Connection quick-look card on Settings have all been removed. New active-card structure: Status rows (always visible) → Endpoints expander → Advanced expander (manual URL / insecure toggle / manual pairing code) → Security posture strip (transport badge + Tailscale chip + hardware keystore badge + Paired Devices row). Non-active cards stay flat. Navigation path throughout the user docs updates from `Settings → Connection → X` to `Settings → Connections → [active card] → X` (or `→ Advanced → X`). New file `ui/components/ActiveConnectionSections.kt` (~650 lines) owns the active-card bodies; `ui/screens/ConnectionsSettingsScreen.kt` is rewritten (~580 lines) with screen-scope hoisting for info sheets + the insecure-Ack dialog so `LazyColumn` item disposal can't silently dismiss them mid-scroll. Team-delivered: three parallel `feature-dev:code-explorer` agents produced the full feature inventory + integration map + caller trace in under 2 minutes, which made the synthesis + implementation mechanical.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice-exit chime firing on every Add-connection tap.** `ConnectionSwitchCoordinator.switchConnection` fires the `voiceStopCallback` unconditionally at step 3 (correct for connection-to-connection switches while voice is active), but `beginAddConnection` also routes through `switchConnection` to bind the placeholder Connection's auth store before the pair wizard runs — and `VoiceViewModel.exitVoiceMode()` was playing `sfxPlayer.playExit()` regardless of whether voice mode was actually on. Logcat confirmed the chime on every Add-connection FAB tap. Fix adds an idempotence guard at the top of `exitVoiceMode()`: early-return when `_uiState.value.voiceMode` is already false. Teardown is still safe to skip because every inner statement is null-guarded + try/catch-wrapped and would be a no-op on an already-stopped voice session; the only meaningful line is the `playExit()` SFX, which is what we're silencing.
|
||||
- **500 ms freeze on every Add-connection tap.** `ConnectionSwitchCoordinator.switchConnection` runs a `withTimeoutOrNull(AUTH_HYDRATE_TIMEOUT_MS = 500L)` block at step 10 to wait for the freshly-bound `AuthManager` to flip `AuthState` from `Loading` to `Paired`. The comment acknowledged Add-connection is the common path and the 500 ms was meant to be "imperceptible," but on-device it wasn't — the user perceived the delay (and the voice chime masking it) on every tap. The placeholder Connection created by `beginAddConnection` has `pairedAt == null` and an empty EncryptedSharedPreferences store, so `AuthState` will NEVER reach `Paired` — the 500 ms is pure stall. Fix short-circuits the hydrate wait when `target.pairedAt == null`: skip `withTimeoutOrNull` entirely for placeholders and log at DEBUG instead of the misleading "auth hydrate timeout" INFO. Real paired-to-paired switches still run the full hydrate wait because both sides have `pairedAt != null`.
|
||||
- **KDoc nested-comment trap in `ConnectionViewModel.relayReady` doc block.** A literal `/voice/*` path pattern inside the `relayReady` KDoc opened a nested block comment (Kotlin supports nested `/* */`, Java does not) whose `*/` then closed only the nested level — leaving the outer `/**` open for the remaining ~2200 lines of the file. Symptom: `MainActivity.kt:67` "Unresolved reference 'isReady'" plus ~50 cascading "Cannot infer type" errors across `PairedDevicesScreen`, `SettingsScreen`, `TerminalScreen`. Real errors (`Missing '}`, `Unclosed comment`) were the last two lines of `./gradlew compileGooglePlayDebugKotlin` output, easy to miss. Fix was a two-character rewrite: path patterns now wrapped in backticks AND `/*` → `/...` so the glob-looking character isn't in a block-comment position. Lesson logged in `DEVLOG.md` 2026-04-21; worth a sweep of other KDoc blocks for shell/regex-looking patterns before the next large diff.
|
||||
|
||||
- **Orphan placeholder connections from abandoned Add-connection flows.** The `beginAddConnection` path pre-creates a placeholder Connection and switches to it before the pair wizard runs — so `applyPairingPayload` lands the token in the right auth store. Previously, cleanup of the placeholder was wired only to the explicit Cancel button and TopAppBar back arrow. System back (gesture back / predictive back) bypassed that branch, leaving the placeholder in the connection list forever. Two-part fix: (a) `PairScreen` now installs a `BackHandler` that routes system back through the same `onCancel` → `discardPlaceholderConnection` branch the explicit back arrow uses; (b) `ConnectionViewModel.init` sweeps for any existing orphans (tuple: `pairedAt == null && apiServerUrl.isBlank() && label == PLACEHOLDER_LABEL`) on cold start and removes them — the tuple cannot be produced by any real pairing, so the sweep is safe without a dry-run. If the active connection at startup points at an orphan, the sweep switches to the first surviving real connection before deleting. Fixes the "why does my chip say 'New connection…'" symptom on devices that were affected pre-fix.
|
||||
- **Pair flow now auto-starts the camera on Add connection.** `ConnectionWizard` gains an `autoStart: String?` param (currently only `"scan"` is honored). The Add-connection FAB on `ConnectionsSettingsScreen` passes it so the wizard fires the camera permission launcher on first composition instead of forcing users through the Method chooser — one obvious next step, one-tap flow. Re-pair surfaces intentionally leave `autoStart` null so the full Scan / Enter code / Show code chooser stays available there. The deep-link arg is plumbed through `Screen.Pair`'s route (`pair?connectionId=...&autoStart=...`) and `PairScreen`'s new `autoStart` param; unrecognized values fall through to the default Method step so future builds can add more targets without breaking old ones.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of the double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
|
||||
### Added
|
||||
|
||||
- **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-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 (deliberate: 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.
|
||||
- **`MorphingSphereCore.kt` — pure, platform-agnostic sphere algorithm.** Extracted from `MorphingSphere.kt` as the single source of truth for the sphere going forward. Uses only `kotlin.math` — no Android, no Compose, no `Paint` — so the same math can back a terminal TUI (Hermes CLI), the codename-11.dev user site, or a Compose Desktop port without visual drift between surfaces.
|
||||
- **`preview/web/` — zero-dep browser harness for the sphere.** `sphere.js` is a line-for-line JS mirror of `MorphingSphereCore.kt` (`Math.imul` + `|0` for Kotlin `Int` overflow, floored modulo for `.mod()`, `Math.trunc` for `.toInt()`). `index.html` exposes live panel controls for state / voice / layout (cols, rows, fill%, aspect, char size) + a `phone 9:16` preset matching Compose `@Preview(widthDp=360, heightDp=640)`. Serve via `python3 -m http.server --directory preview/web`.
|
||||
- **Runtime parity harness for the sphere.** `preview/web/parity-check.mjs` + JVM `MorphingSphereCoreParityTest` render the 8 Compose `@Preview` fixtures on both sides and emit FNV-1a 32-bit checksums. **8/8 structural checksums** (over discrete `(row, col, char)` tuples) and **8/8 zone histograms** match between JS and Kotlin; 6/8 full (color/alpha-inclusive) checksums match — the 2 voice-modulated fixtures drift at the 3rd decimal due to Float (Kotlin) vs Double (JS) precision in compound expressions, sub-perceptible.
|
||||
- **Multi-endpoint pairing QR** (ADR 24). A single pairing now carries an ordered list of endpoint candidates (`lan` / `tailscale` / `public` / operator-defined) so the same phone works seamlessly across LAN, Tailscale, and a public reverse-proxy URL. The phone picks the highest-priority reachable candidate at connect time and re-probes reachability on every `ConnectivityManager` network change with a 30s per-candidate cache. Strict-priority semantics — reachability only breaks ties among equal priorities, never promotes a lower priority over a higher one. New `plugin/pair.py` CLI flags `--mode {auto,lan,tailscale,public}` (default auto) and `--public-url <url>` drive candidate emission. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **First-class Tailscale helper** (ADR 25). New `plugin/relay/tailscale.py` + `hermes-relay-tailscale` CLI shim fronts the loopback-bound relay with `tailscale serve --bg --https=<port>` so the port is reachable over the tailnet with managed TLS + ACL-based identity. Safe to call unconditionally — no-ops with structured-dict failure when the `tailscale` binary is absent. `install.sh` gains an optional step [7/7] offering Tailscale enablement; skipped silently when the binary is missing, when `TS_DECLINE=1`, or under non-interactive shells without `TS_AUTO=1`. Auto-retires when upstream PR [#9295](https://github.com/NousResearch/hermes-agent/pull/9295) merges.
|
||||
- **Remote Access dashboard tab** (in the dashboard plugin). Operators can enable/disable the Tailscale helper, mint multi-endpoint pairing QRs, and inspect which endpoint modes are currently active — all from the hermes-agent web UI.
|
||||
- **Reachability probe + network-change re-probe** in the Android client. `ConnectionManager.resolveBestEndpoint()` does `HEAD /health` against each API candidate with a 2s timeout + 30s cache; `NetworkCallback.onAvailable` / `onLost` triggers a re-probe. `RelayUiState` gains `activeEndpointRole` so the UI can render which endpoint (LAN / Tailscale / Public) is currently serving.
|
||||
- **Opt-in terminal sessions.** Fresh terminal tabs no longer auto-attach — each tab shows a centered **Start session** overlay and spawns the tmux-backed shell only after the user taps it. Tabs that have already been started still auto-reattach on reconnect. Removes the previous behavior of creating persistent server-side shells just by opening the Terminal tab.
|
||||
- **`terminal.kill` envelope** — hard-destroy a session. The relay runs `tmux kill-session -t <name>` out-of-band before tearing down the PTY so the background shell (and any running commands) die with it. Closing a tab now opens a confirmation dialog with explicit **Detach** (preserve tmux session) vs **Kill** (destroy it) choices; the session info sheet also gains an error-tinted **Kill session** button.
|
||||
- **Touch-scroll + scrollback buttons for the terminal.** A vertical swipe on the terminal surface now moves xterm.js's scrollback (with a 12 px deadzone so long-press-to-select still works); the extras toolbar gains ⇑ / ⇓ / ⇲ buttons for ten-line scroll up, ten-line scroll down, and jump-to-bottom. Scrollback depth is unchanged at 10 000 lines.
|
||||
- **Friendly names for terminal tabs.** The session info sheet now has an inline rename field that persists a cosmetic name (up to 40 chars) keyed on the wire-side `session_name`. Names survive app restart and re-pair; cleared on Kill but preserved on Detach. The tab chip renders `1 · build` when named.
|
||||
- **`--prefer <role>` priority override** on every pair surface (`hermes-pair --prefer tailscale`, the `/hermes-relay-pair` skill, and the dashboard Remote Access tab's "Prefer role" dropdown). Open-vocab role string — promotes the named role to priority 0 with the rest renumbered in natural order. Unknown role emits a stderr warning and keeps the natural order. Case-insensitive matching; role string preserved verbatim for HMAC round-trip.
|
||||
- **Active-endpoint chip in the Chat top bar.** Compact tappable chip (e.g. "LAN" / "Tailscale" / "Public" / "Custom VPN (…)") rendered next to the ambient-mode button when the resolver has picked an endpoint. Tap jumps to the Connections screen so the user can probe / override / re-pair without leaving chat. Hidden for single-endpoint legacy pairings — the existing Settings row already spells the host out.
|
||||
- **Re-pair hint on single-endpoint connections.** When the active connection has exactly one endpoint (legacy single-URL pair), the Connections list card shows a tertiary-container info strip suggesting "Re-pair with Mode = Auto to get LAN + Tailscale + Public in one QR" with an inline Re-pair button. Silent when zero or ≥2 endpoints are stored.
|
||||
- **Tailscale Funnel auto-detect for the public candidate.** `plugin.relay.tailscale.funnel_url(port)` probes `tailscale serve status --json` for `AllowFunnel` flags and returns the `https://<hostname>/` URL when the relay port is funneled. `plugin/pair.py` `build_endpoint_candidates` calls it as a fallback whenever `mode=auto` or `mode=public` is picked without an explicit `--public-url` — removes the "pin the public URL on Remote Access tab" step when Funnel is already publishing. Soft-fail on every error path; missing CLI / non-funneled port / unparseable JSON all return None.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Install-command copy buttons stay pinned.** The copy buttons on the docs home's "Install in 30 seconds" commands used to scroll out of view with long one-liners because `.install-code` had both `position: relative` and `overflow-x: auto` — the button's absolute coordinates anchored to the scrolling content box, not the visible viewport. Split into `.install-code` (positioning context, no overflow) wrapping a new `.install-code-scroll` (padding + horizontal overflow). Button now overlays the code as a proper static copy affordance.
|
||||
- **Docs hero (mobile).** VitePress's default `.image-container` is a fixed 320×320 square on mobile (designed for round illustrations) with negative margins on `.image` that overlap `.main`. On a 9:16 phone-frame video this caused the frame to overflow the square and the text/CTAs to sit on top of the video. `custom.css` now overrides the container to `height: auto` and zeroes the negative margins below 960 px, and `HeroDemo.vue` swaps three breakpoint widths (280/240/200 px) for one `clamp(180px, 62vw, 280px)` rule with a `max-height: 70vh` safety rail so the frame can't dominate the fold on tall narrow viewports.
|
||||
- **`MorphingSphere.kt` is now a thin Compose renderer** that delegates all math to `MorphingSphereCore`. Public `@Composable` API is unchanged (same params, same defaults); call sites in `VoiceModeOverlay` and the chat empty state need no updates. Renderer also swapped legacy `android.graphics.Paint` + `Typeface` + `nativeCanvas.drawText` for Compose's `rememberTextMeasurer()` + `drawText`, dropping all `android.graphics.*` imports.
|
||||
- **Pairing QR now carries the `hermes: 3` schema when endpoints are emitted.** `plugin/pair.py` → `build_payload(endpoints=...)` bumps the version only when the `endpoints` array is present; pairs without endpoint candidates continue to emit `hermes: 2`. `canonicalize()` in `plugin/relay/qr_sign.py` preserves array order and role strings verbatim (no case/whitespace normalization) so HMAC signatures round-trip across Python / Kotlin.
|
||||
- **Paired Devices screen renders per-endpoint rows.** Each paired device now shows one row per `(device, endpoint)` pair, with a styled chip per role (LAN / Tailscale / Public / Custom VPN). Settings and Paired Devices both read from the new `PairingPreferences` per-device endpoint store.
|
||||
- **Terminal session info sheet is vertically scrollable** — tall phones in landscape with the new Start / Reattach / Kill action rows no longer clip the Done button.
|
||||
- **Connections list subtitle shows role names, not count.** Active card's subtitle was "hostname • Connected • LAN • 2 endpoints" — accurate but opaque (users couldn't tell which endpoints the QR carried without expanding). Now shows "hostname • Connected • Active: LAN • LAN + Public" — role set on display, not count. Non-active cards unchanged.
|
||||
- **Looser resolver probe timing.** Per-candidate HEAD `/health` timeout raised from 2s → 4s and cache TTL from 30s → 60s. ADR 24's 2s was tight enough that LTE hand-off and slow hotel Wi-Fi routinely got marked unreachable spuriously; 4s preserves fast-fail-on-real-outage while surviving the flaky-network case. NetworkCallback still invalidates the cache on real network changes, so the longer cache is functionally equivalent but saves battery.
|
||||
|
||||
### Backward compatible
|
||||
|
||||
- **Old v1 / v2 QRs keep parsing unchanged.** The Android parser's `ignoreUnknownKeys = true` plus the nullable `endpoints` field means pre-v3 QRs work on new phones (the phone synthesizes a single priority-0 `role: lan` candidate from the top-level fields, promoted to `role: tailscale` when the host matches `100.64.0.0/10` / `.ts.net`), and v3 QRs work on v0.6.x and earlier clients (they ignore `endpoints` and use the top-level fields). No forced re-pair for existing installs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Profile `PUT` endpoints restored.** The ADR 24 commit collaterally deleted ~479 lines of `handle_profile_soul_put` / `handle_profile_memory_put` while adding multi-endpoint passthrough to the pairing handlers. `PUT /api/profiles/{name}/soul` and `PUT /api/profiles/{name}/memory/{filename}` are back at their canonical positions; atomic-write semantics and loopback-or-bearer auth unchanged.
|
||||
- **Stray terminal errors no longer poison the wrong tab.** Server-level error envelopes without a `session_name` (e.g. "Unknown terminal message type" from an older relay) previously fell through to the active tab and flashed an error overlay on whichever tab the user happened to be looking at. Errors without session scope now log only.
|
||||
- **Dashboard-minted QRs now show the correct 10-minute expiry.** `handle_pairing_mint` was returning `expires_at = now + 60` whenever the caller didn't pin a session TTL (every dashboard mint), which conflated the pairing-code window with the future session's lifetime and made the dashboard dialog count down from ~1 minute even though the underlying code was valid for 10. Now stamps `expires_at = now + _PAIRING_CODE_TTL` explicitly — the pairing-code TTL is what the UI cares about. Session TTL continues to ride the QR payload's `ttl_seconds` field for the phone's TTL picker.
|
||||
- **PairDialog: multi-endpoint aware, Authelia-trap guardrail.** The dashboard Management tab's "Pair new device" button was still minting legacy single-endpoint QRs (no `endpoints[]`, no `mode`, no `prefer`) while the Remote Access tab had been on the modern path for months. Swapped to `mintPairingWithMode` with `Mode` + `Prefer role` dropdowns as primary inputs; the legacy host/port/tls fields moved under a collapsed "Advanced · API-server override" section with a warning that triggers when the typed host looks like a forward-auth-gated FQDN (the root cause of "relay pairs but phone drops config" reports: e.g. `wss://hermes.example.com` fronted by Authelia gets pinned into the QR's API block, relay WSS succeeds over LAN, then API probes return 401 and the wizard cleans up). Modal widened from `max-w-md` to `max-w-xl` to fit the endpoints receipt without horizontal scroll.
|
||||
- **PairDialog: proxy-fronted override now requires explicit consent.** Previously the Advanced warning was purely informational — the dialog still auto-minted a QR the phone would fail to use. Now the auto-mint is gated: when the pinned host matches the proxy-fronted heuristic, the dialog pauses and shows "Mint anyway / Clear override" instead of proceeding. Consent is per-host — changing the host resets `proxyConfirmed` so a new host triggers a fresh confirm step.
|
||||
|
||||
## [0.6.0] — 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Pair with multiple Hermes servers** and switch in one tap. A new Connection chip on the left of the Chat top bar opens a switcher sheet with a health indicator for each paired server — tap one to cancel in-flight chat, disconnect the old relay, rebind to the new server, and reload sessions + personalities + profiles. The chip is hidden automatically when you only have one Connection. Existing single-server installs migrate transparently on first launch of this version — zero re-pair, zero token migration. See `docs/decisions.md` §19.
|
||||
- **Connections management screen** at Settings → Connections. Each paired server is a card with inline rename, re-pair (reuses the QR onboarding flow), revoke, and remove. Add a new Connection from the same screen. Per-connection state kept separate: sessions, memory, personalities, skills, profiles, relay URL + cert pin, voice endpoints, last-active session. Theme, bridge safety preferences, and TOFU cert-pin map stay global.
|
||||
- **Agent Profiles** — the relay now auto-discovers upstream Hermes profiles by scanning `~/.hermes/profiles/*/` (plus a synthetic "default" for the root config) and advertises them in the `auth.ok` payload. On chat send with a profile selected, the phone overlays the request's `model` and `system_message` with the profile's `model.default` + `SOUL.md`. Selection is ephemeral and clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED=1` (default on) — operators can set it to `false` to keep the picker empty. See `docs/decisions.md` §21.
|
||||
- **Consolidated agent sheet** on the Chat top bar. Tap the agent name in the middle of the top bar to open a scrollable bottom sheet holding Profile selection, Personality selection, and session info + analytics (message count, tokens in/out, avg TTFT). Replaces the separate top-bar chips from intermediate v0.5.x builds. Toast confirmations fire on Profile and Personality switches.
|
||||
- **"Active agent" card** at the top of Settings — summarizes the current Connection / Profile / Personality. Tap navigates to Chat with the agent sheet auto-opened via the `openAgentSheet` nav arg, giving Settings-originating users a one-tap path to change agent context.
|
||||
- **Three-layer agent model** formalized: Connection (server) → Profile (agent directory) → Personality (system-prompt preset). Documented in `docs/spec.md`, `docs/decisions.md` §8 / §19 / §21, and `user-docs/features/{connections,profiles,personalities}.md`.
|
||||
- **Pair wizard URL scheme cross-validation** — an inline hint fires when the API field is given a `wss://` URL (or any obviously-wrong scheme), so misplaced values surface before the pair attempt instead of after.
|
||||
- **Pair-stamp on the active Connection** — successful auth now stamps the active Connection's pairing metadata (paired-at, transport hint, expiry) in place, so a re-pair from Settings doesn't leave stale state on the card.
|
||||
- **Live WSS state on the active Connection row** in the Connections list — the active card now reflects Connected / Reconnecting… / Stale in real time instead of a static "Paired N minutes ago" timestamp. A Stale state also surfaces an inline **Reconnect** action button (promoted above Rename) tinted to signal "attention."
|
||||
- **Reconnect taps get explicit feedback.** Every Stale-recovery affordance (the Relay row, the Reconnect button in Connection Settings, and the new Reconnect action in the Connections list) now shows a snackbar / toast "Reconnecting to relay…" so users know the tap registered even during the sub-second before the row flips to Connecting.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Unified relay status across screens.** `SettingsScreen`, `ConnectionSettingsScreen`, and the Connections list used to resolve relay status independently (each with its own ad-hoc stale / auto-reconnect / probing combinator), which let them disagree on what state the relay was in — e.g. the Settings card said **Disconnected** red while the Connection sub-screen said **Reconnecting…** amber for the same moment. State resolution now lives on `ConnectionViewModel.relayUiState: StateFlow<RelayUiState>` with five well-defined cases (`NotConfigured` / `Connected` / `Connecting` / `Stale` / `Disconnected`) and a 5 s grace window before a Paired-but-Disconnected pose is promoted to `Stale` — every screen maps the single source of truth onto the existing `ConnectionStatusRow` API.
|
||||
- **Settings "Connection" card → "Active Connection".** Title renamed, and the current Connection's label now renders as the card subtitle so installs with multiple servers can see at a glance which one the status rows describe. Fresh `reconnectIfStale()` tick on first compose so the Relay row doesn't flash red before the lifecycle observer's resume path lands.
|
||||
- **Status-badge UX polish.** `ConnectionStatusBadge` top-aligns cleanly on multi-line rows (was vertically centered and drifted off-center when the label wrapped). The Settings screen now treats a paired Connection with a briefly-down relay as **Connecting** (amber) instead of **Disconnected** (red) — avoids scare-red during the few seconds around a relay restart.
|
||||
- **Top-bar chip layout.** `ProfilePicker.kt` and `PersonalityPicker.kt` as standalone top-bar chips are gone; their selection now lives inside the consolidated agent sheet.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`POST /pairing/mint` emits the correct wire format.** Dashboard-minted QRs were unscannable — the relay endpoint put the freshly-minted pairing code in top-level `key` and defaulted the top-level port to the relay's own `8767` (its `server.config.port`) instead of the Hermes API server's `8642`. The Android scanner reads top-level `host:port` as the **API** server URL and expects the minted code inside `relay.code`, so phones saw `serverUrl=http://host:8767` (wrong port, no API reachable) and an empty `relay` block — `applyServerIssuedCodeAndReset` bailed on the empty code and the WSS never handshook. Silent fail. The `hermes-pair` CLI and `/hermes-relay-pair` skill were unaffected because they go through `pair.py`'s CLI path which builds the payload correctly; only the dashboard's "Pair new device" flow hit the bug. `handle_pairing_mint` now mirrors `pair.py:762` — top-level `host/port/key/tls` default from `RelayConfig.webapi_url` (resolved to a LAN-routable IP via `_resolve_lan_ip`) with `host`/`port`/`tls`/`api_key` body overrides, and the `relay` block carries `url` from `_relay_lan_base_url(server.config.host, server.config.port, ...)` plus the minted `code`. Shape now matches `docs/spec.md` §3.3.1 and `QrPairingScanner.kt`. Regression test at `plugin/tests/test_pairing_mint_schema.py` (8 cases) pins the payload shape against what the Android parser expects so the two sides can't drift silently again.
|
||||
- **Dashboard Relay Management tab no longer crashes on paired-session list.** `RelayManagement.jsx:172` wrapped a dict-shaped `s.grants` (`{chat, terminal, bridge}`) in a 1-element array and rendered each entry as a React child, tripping minified React error #31 ("objects are not valid as a React child"). Now uses `Object.keys(s.grants)` when the value is dict-shaped so Badge children are always strings; existing array path preserved for future callers. Rebuilt bundle at `plugin/dashboard/dist/index.js` — the hermes-agent dashboard loads that file verbatim so source changes require a rebuild.
|
||||
|
||||
### Deferred
|
||||
|
||||
- True per-profile isolation on a single Connection (memory + sessions + `.env` shared today; use separate Connections for full isolation).
|
||||
- Persisted Profile selection per Connection across app restarts.
|
||||
- Gateway-running probe (hermes-desktop-inspired) on the Connection health indicator.
|
||||
|
||||
## [0.5.x] — Unreleased feature work
|
||||
|
||||
### Added — Voice silence auto-stop (2026-04-18)
|
||||
|
||||
- **Silence-based auto-stop for Listening turns.** `VoiceViewModel.startListening()`
|
||||
now arms a `silenceWatchdogJob` that polls `VoiceRecorder.amplitude` every
|
||||
150 ms and calls `stopListening()` after the user's configured
|
||||
`silenceThresholdMs` of continuous silence following at least one
|
||||
above-floor frame. Uses the existing `RESUME_SILENCE_THRESHOLD = 0.08f`
|
||||
floor (already tuned to reject mic hiss / room tone while catching
|
||||
whispered speech). Cancelled on manual stop, `interruptSpeaking`, and
|
||||
`onCleared`. Skipped in `InteractionMode.HoldToTalk` — the physical
|
||||
release is the authoritative stop there. Closes the previously-dead
|
||||
`VoiceSettings.silenceThresholdMs` preference, which was persisted
|
||||
+ exposed via a Settings slider but never consumed by any code path.
|
||||
|
||||
### Fixed — Bootstrap crash when wrapping command middleware (2026-04-18)
|
||||
|
||||
- **`hermes_relay_bootstrap/_command_middleware.py`** — `maybe_install_middleware()`
|
||||
was replacing `app._middlewares` (an aiohttp `FrozenList`) with a plain
|
||||
tuple via `(*existing, middleware)`. When `AppRunner.setup()` later
|
||||
called `app._middlewares.freeze()`, tuples have no `.freeze()` method
|
||||
and the gateway crashed on startup with `'tuple' object has no
|
||||
attribute 'freeze'`. Switched to in-place `app._middlewares.append(middleware)`
|
||||
— the FrozenList is still mutable at middleware-install time. 31/31
|
||||
tests in `test_command_middleware.py` pass.
|
||||
|
||||
### Added — Dashboard plugin
|
||||
|
||||
- **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 + 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`
|
||||
filters); **Push Console** ships as a stub with an
|
||||
"FCM not configured" banner until FCM lands; **Media Inspector**
|
||||
lists active `MediaRegistry` tokens with live TTL countdowns and
|
||||
basename-only file names (absolute paths never leave the server).
|
||||
Frontend is a pre-built React IIFE at `plugin/dashboard/dist/index.js`
|
||||
(~16 KB) loaded verbatim by the dashboard shell; backend is a thin
|
||||
FastAPI proxy at `plugin/dashboard/plugin_api.py` mounted at
|
||||
`/api/plugins/hermes-relay/*`.
|
||||
- **Three new loopback-only relay routes** feeding the plugin —
|
||||
`GET /bridge/activity` (ring buffer; `?limit=N`, max 500),
|
||||
`GET /media/inspect` (token list; `?include_expired=true` to
|
||||
include evicted entries), and `GET /relay/info` (aggregate
|
||||
`{version, uptime_seconds, session_count, paired_device_count,
|
||||
pending_commands, media_entry_count, health}`). Plus a
|
||||
loopback-exempt branch on the existing `GET /sessions` so the
|
||||
plugin proxy doesn't need to mint a bearer.
|
||||
- **`BridgeCommandRecord` ring buffer** on `BridgeHandler`
|
||||
(`deque(maxlen=100)`) — records `request_id`, `method`, `path`,
|
||||
redacted `params`, `sent_at`, `response_status`, `result_summary`,
|
||||
`error`, and `decision`. Commit `777a06a` wires append/update into
|
||||
`handle_command()` / `handle_response()` without changing external
|
||||
behaviour; timeouts flip `decision=timeout`, phone-side safety
|
||||
denials flip `blocked`. Params are redacted for keys in
|
||||
`{password, token, secret, otp, bearer}`.
|
||||
- **`MediaRegistry.list_all(include_expired=False)`** — lock-guarded
|
||||
snapshot method returning `{token, file_name, content_type, size,
|
||||
created_at, expires_at, last_accessed, is_expired}` dicts sorted
|
||||
newest-first. Absolute paths are never included. Commit `2212fbc`.
|
||||
- **Pairing workflow from the dashboard** — new `POST /pairing/mint`
|
||||
relay route (loopback-only) generates a random 6-char A-Z/0-9 code,
|
||||
registers it with the existing `PairingManager`, and returns the
|
||||
signed QR payload built via `plugin.pair.build_payload`. The
|
||||
dashboard backend exposes it at
|
||||
`POST /api/plugins/hermes-relay/pairing`. A new **PairDialog** in
|
||||
the Management tab renders the QR (via the `qrcode` npm lib bundled
|
||||
into the IIFE), shows the code + expiry countdown, and lets the
|
||||
operator **override Host / Port / TLS** in the QR payload — useful
|
||||
for Traefik-fronted deploys where the phone needs
|
||||
`wss://relay.example.com:443` even when the dashboard itself is
|
||||
served at a different hostname. Settings persist per-browser in
|
||||
localStorage.
|
||||
- **Functional session revocation** — loopback-exempt branch on
|
||||
`DELETE /sessions/{token_prefix}` plus a proxy route at
|
||||
`DELETE /api/plugins/hermes-relay/sessions/{prefix}`. The Revoke
|
||||
button on the Management tab now confirms via native dialog, calls
|
||||
the proxy, and auto-reloads the session list on success.
|
||||
|
||||
### Added — Installer
|
||||
|
||||
- **`--dashboard-plugin=yes|no`** flag on `install.sh` (default `yes`;
|
||||
also via `HERMES_RELAY_DASHBOARD_PLUGIN` env var). Passing `no`
|
||||
renames `plugin/dashboard/manifest.json` → `manifest.json.disabled`
|
||||
so the hermes-agent dashboard loader skips the plugin entirely.
|
||||
Re-running with the opposite flag flips it back — no config lives
|
||||
anywhere else.
|
||||
- **Live dashboard rescan** in both `install.sh` and `uninstall.sh` —
|
||||
parses `hermes-dashboard.service` ExecStart for `--host` / `--port`
|
||||
and GETs `/api/dashboard/plugins/rescan`, falling back to loopback
|
||||
and common ports. The relay tab appears/disappears without a
|
||||
dashboard restart. Silent no-op when the dashboard isn't running.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dashboard plugin UI** uses plain tab buttons instead of Radix
|
||||
`<Tabs>`: Radix's `Tabs` container expects `TabsContent` children
|
||||
(not exposed in the SDK whitelist) and its internal context blew
|
||||
up at first render as `o is not a function` after minification.
|
||||
- **Install banner** no longer claims "Phase 3 — Bridge channel +
|
||||
status tool" (stale since v0.2.x). Phase-agnostic copy now.
|
||||
|
||||
### Added — Sideload in-app update check
|
||||
|
||||
- **In-app update banner** on the `sideload` flavor. On cold start (at
|
||||
most once every 6h) the app queries the GitHub `releases/latest`
|
||||
endpoint and, if it's behind, shows a slim `UpdateBanner` at the
|
||||
top of the scaffold with the current and latest versions. Tap
|
||||
**Update** → opens the `-sideload-release.apk` asset URL directly in
|
||||
the browser; Android's DownloadManager fetches it and hands it to
|
||||
the OS installer. Tap the **X** to dismiss for this version — the
|
||||
banner reappears automatically on the next release.
|
||||
- **"Updates" row in About → About** card — manual "Check" button
|
||||
with the same plumbing. After a successful check shows either
|
||||
"You're on the latest release" or "Update available — v0.x.y" with
|
||||
a **Download** CTA. The row is hidden on the `googlePlay` flavor
|
||||
(Play Store owns update delivery there).
|
||||
- **No new permissions** — the app never installs APKs itself; it
|
||||
only opens the asset URL via `ACTION_VIEW`. The Android download +
|
||||
install path is unchanged from what sideload users already use.
|
||||
- Files: `update/UpdateChecker.kt`, `UpdatePreferences.kt`,
|
||||
`UpdateModels.kt`, `SemverCompare.kt`;
|
||||
`viewmodel/UpdateViewModel.kt`;
|
||||
`ui/components/UpdateBanner.kt`;
|
||||
wire-up in `ui/RelayApp.kt` + `ui/screens/AboutScreen.kt`.
|
||||
|
||||
### Added — v0.4.1 Bridge page polish pass
|
||||
|
||||
- **`UnattendedGlobalBanner`** — thin 28dp amber strip at the top of
|
||||
`RelayApp`'s scaffold, visible on every tab when master + unattended
|
||||
are both on (sideload only). Pulsing amber dot, copy "Unattended
|
||||
access ON — agent can wake and drive this device", chevron →
|
||||
navigates to the Bridge tab. Theme-aware colours (amber-on-dark in
|
||||
dark mode, dark-amber-on-pale-amber in light). Pairs with the existing
|
||||
`BridgeStatusOverlayChip` — banner handles the app-foregrounded case,
|
||||
the overlay chip handles the app-backgrounded case.
|
||||
- **`PhoneSnapshot` agent-awareness fields** — `unattendedEnabled`,
|
||||
`credentialLockDetected`, `screenOn`.
|
||||
`PhoneStatusPromptBuilder.buildBridgeLine()` now appends explicit
|
||||
guidance so the LLM knows upfront whether commands will land on the
|
||||
device while the user is away, instead of finding out reactively via
|
||||
`keyguard_blocked` error responses.
|
||||
- **`MASTER` pill** next to the master-toggle title, and leading
|
||||
"Master switch —" subtitle copy, so the parent-gate role of the
|
||||
toggle is legible without reading a wall of helper text.
|
||||
|
||||
### Changed — v0.4.1 Bridge page polish pass
|
||||
|
||||
- **Bridge tab card order** rewritten with a clear hierarchy: Master →
|
||||
Permission Checklist → [Advanced divider] → Unattended Access → Safety
|
||||
Summary → Activity Log. The previous standalone `BridgeStatusCard`
|
||||
was dropped from the layout because its device / battery / screen /
|
||||
current-app rows already render inline inside the master toggle card.
|
||||
(The component file remains in-tree and is still unit-testable; it's
|
||||
just not rendered by `BridgeScreen` any more.)
|
||||
- **Unattended Access gated on the master toggle.** The Switch inside
|
||||
`UnattendedAccessRow` is now `enabled = masterEnabled` and the
|
||||
subtitle reads "Requires Agent Control — enable the master switch
|
||||
above first." when master is off. The standalone
|
||||
`KeyguardDetectedChip` card was inlined as a `KeyguardDetectedAlert`
|
||||
Surface band inside the Unattended Access card so the credential-lock
|
||||
warning lives next to the thing that triggers it (same concern, one
|
||||
card).
|
||||
- **Persistent-notification copy corrected.** The unattended one-time
|
||||
scary dialog no longer implies the unattended toggle owns the
|
||||
"Hermes has device control" notification — explicitly attributes it
|
||||
to the master switch. The master-toggle info dialog gained a
|
||||
matching paragraph naming the persistent notification.
|
||||
|
||||
### Fixed — v0.4.1 Bridge page polish pass
|
||||
|
||||
- **Master toggle silent no-op** when Accessibility Service isn't
|
||||
granted. Tapping the disabled Switch used to do nothing (stock
|
||||
Android disabled-switch behavior); now it surfaces a snackbar —
|
||||
"Accessibility Service must be enabled first." — with an "Open
|
||||
Settings" action that deep-links to
|
||||
`Settings.ACTION_ACCESSIBILITY_SETTINGS`.
|
||||
- **Permission checklist Optional pill wrapped** on narrow titles
|
||||
(e.g. "Notification Listener"). Switched the row layout to
|
||||
`FlowRow` and forced `softWrap=false` on the pill's text so the
|
||||
pill renders as a single unbroken element.
|
||||
- **Runtime-permission rows silently no-opped** after permanent denial.
|
||||
Mic / Camera / Contacts / SMS / Phone / Location rows now fall back
|
||||
to `Settings.ACTION_APPLICATION_DETAILS_SETTINGS` when the user has
|
||||
selected "Don't ask again", taking the user straight to the app's
|
||||
permission page instead of consuming the tap.
|
||||
|
||||
### Added — v0.4.1 Bridge fast-follows (in progress)
|
||||
|
||||
- **Tiered permission checklist** on the Bridge tab — the previously-flat
|
||||
4-row layout is now four explicit sections (Core bridge, Notification
|
||||
companion, Voice & camera, Sideload features). Each runtime dangerous
|
||||
permission gets its own row with a `RequestPermission` launcher that
|
||||
re-probes status on grant. Optional rows render an "Optional" Material 3
|
||||
pill so users don't perceive them as urgent. Sideload-only rows
|
||||
(Contacts, SMS, Phone, Location) are hidden on the googlePlay flavor
|
||||
via the existing `BuildFlavor.isSideload` gate.
|
||||
- **JIT permission-denied surfacing** for the Tier C agent-tool wrappers
|
||||
(`android_search_contacts`, `android_send_sms`, `android_call`,
|
||||
`android_location`). When the phone reports a missing runtime permission,
|
||||
the wrapper upgrades the bridge response to a structured envelope
|
||||
carrying `code: "permission_denied"` + `permission:
|
||||
"android.permission.READ_CONTACTS"` + a deterministic LLM-readable
|
||||
explanation that names the exact Settings deep-link path. The LLM no
|
||||
longer has to guess from a free-text error string why the tool failed.
|
||||
- **Voice-mode JIT chip** — when a voice intent dispatch returns
|
||||
`permission_denied`, a tappable errorContainer-coloured chip surfaces
|
||||
above the mic button with copy like "I need Contacts to Send SMS here.
|
||||
Tap to open Settings." Tapping deep-links to
|
||||
`Settings.ACTION_APPLICATION_DETAILS_SETTINGS` for the running
|
||||
package's permission page. Cleared on tap or on the next mic tap.
|
||||
- **`ResolveResult` typed-union** in `plugin/tools/resolve_result.py` —
|
||||
`Found(value)` / `NotFound(detail)` / `PermissionDenied(permission,
|
||||
reason)` dataclass hierarchy with a `from_bridge_response` classifier.
|
||||
Reads both the canonical wire keys (`code` / `permission`, v0.4.1) and
|
||||
the legacy aliases (`error_code` / `required_permission`, pre-v0.4.1)
|
||||
for forwards/backwards compatibility across the v0.4.x APK rollout.
|
||||
- **17 new Python unit tests** in `plugin/tests/test_resolve_result.py`.
|
||||
|
||||
### Changed — v0.4.1 Bridge fast-follows (in progress)
|
||||
|
||||
- **`BridgeCommandHandler.respondFromResult`** now emits the canonical
|
||||
`code` + `permission` wire keys alongside the legacy `error_code` +
|
||||
`required_permission` on permission-failure bridge responses. Existing
|
||||
consumers that read the legacy keys keep working unchanged.
|
||||
- **`BridgePermissionStatus`** extended with `microphonePermitted`,
|
||||
`cameraPermitted`, `contactsPermitted`, `smsPermitted`,
|
||||
`phonePermitted`, `locationPermitted`. `refreshPermissionStatus()`
|
||||
probes each on every `Lifecycle.Event.ON_RESUME`.
|
||||
|
||||
### Added — v0.4.1 unattended access mode (sideload-only)
|
||||
|
||||
Opt-in "Unattended Access" toggle on the Bridge tab that lets the
|
||||
agent wake the screen and dismiss the keyguard while the user is
|
||||
away from the phone. Sideload-only — the googlePlay flavor never
|
||||
sees the toggle, never installs the wake lock, and never invokes
|
||||
`requestDismissKeyguard`.
|
||||
|
||||
- **`UnattendedAccessManager`** — sideload-only singleton holding
|
||||
the screen-bright wake lock and orchestrating the `KeyguardManager.
|
||||
requestDismissKeyguard` call. `acquireForAction()` is invoked from
|
||||
the bridge command dispatcher pre-gate for any non-read-only route
|
||||
and returns one of `Success` / `SuccessNoKeyguardChange` /
|
||||
`KeyguardBlocked` / `Disabled`. The wake lock uses
|
||||
`SCREEN_BRIGHT_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP | ON_AFTER_RELEASE`
|
||||
with a 30 s hard timeout per acquire.
|
||||
- **One-time scary opt-in dialog** — fires the first time the user
|
||||
flips the unattended toggle ON. Explains the security model
|
||||
("agent can drive your phone while you're away"), the credential-
|
||||
lock limitation ("Android won't let us dismiss PIN / pattern /
|
||||
biometric locks"), and the three disable paths (toggle off, auto-
|
||||
disable timer expiry, relay disconnect). Latched via
|
||||
`BridgeSafetySettings.unattendedWarningSeen` so it never re-appears
|
||||
after dismissal.
|
||||
- **Persistent keyguard-detected chip** — when unattended is ON
|
||||
and the device has `KeyguardManager.isDeviceSecure == true`, an
|
||||
error-tinted Card on the Bridge tab warns the user that the
|
||||
screen will wake but stop at the lock screen.
|
||||
- **Amber "Unattended ON" status-overlay chip** — when unattended
|
||||
is on, the existing `BridgeStatusOverlayChip` switches from the
|
||||
red-dot "Hermes active" variant to an amber-dot "Unattended ON"
|
||||
variant so the user (or anyone glancing at the device) can tell
|
||||
at a glance that the agent is permitted to wake the screen.
|
||||
Forced visible whenever unattended is on, even if the user has
|
||||
the regular status-overlay preference disabled.
|
||||
- **`keyguard_blocked` structured error** — when the wake fires
|
||||
but the keyguard refuses to dismiss, the bridge dispatch short-
|
||||
circuits with HTTP 423 and `error_code = "keyguard_blocked"`
|
||||
before invoking the action. The LLM's tool wrapper sees the
|
||||
classification and can tell the user to change their lock screen
|
||||
to None / Swipe rather than blindly retrying. `ActionExecutor`
|
||||
also classifies dispatch failures against the live keyguard
|
||||
state via the new `classifyGestureFailure()` helper, so the
|
||||
same `error_code` surfaces if a gesture fails on a locked
|
||||
device with unattended OFF.
|
||||
- **Manifest:** `DISABLE_KEYGUARD` declared in
|
||||
`app/src/sideload/AndroidManifest.xml`. WAKE_LOCK was already
|
||||
declared in the main manifest for the bridge gesture wake-lock
|
||||
scope and is reused.
|
||||
- **Lifecycle wiring:** `MainActivity.onResume` registers the host
|
||||
activity for `requestDismissKeyguard`, `onPause` clears it.
|
||||
Master-toggle-off and relay-disconnect both call
|
||||
`UnattendedAccessManager.release()` so the screen-bright lock
|
||||
drops immediately and the screen returns to its natural timeout.
|
||||
|
||||
**Decisions documented during implementation, not re-litigated:**
|
||||
|
||||
- No WiFi-disconnect failsafe — rejected because Tailscale / VPN
|
||||
invalidates the "leaving WiFi = leaving LAN" assumption. Rely
|
||||
on existing relay-disconnect detection plus auto-disable timer.
|
||||
- Default auto-disable timer stays as-is (30 minutes). No special
|
||||
unattended-mode default.
|
||||
- Credential lock cannot be dismissed by third-party apps — surfaced
|
||||
via the one-time warning dialog, the persistent chip, and the
|
||||
`keyguard_blocked` error code rather than worked around.
|
||||
|
||||
### Added — Voice intent → server session sync (v0.4.1 fast-follow)
|
||||
|
||||
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per a 2026-04-14 on-device repro).
|
||||
- **Implementation.** Each phone-local voice intent now records a structured `VoiceIntentTrace` (tool name, JSON args, success, JSON result envelope) on the post-dispatch chat-trace bubble it produces. `VoiceIntentSyncBuilder` walks the chat history before each `POST /v1/runs` / `POST /api/sessions/{id}/chat/stream` call and synthesizes OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from any unsynced traces. The synthesized array rides under the existing payload's new `messages` field — additive, ignored by older servers, picked up by anything OpenAI Chat Completions–shaped. Idempotency: traces flip to `syncedToServer=true` the moment the API client takes ownership of the request, so subsequent turns don't re-emit them.
|
||||
- **Zero server changes.** Frontend-only, no hermes-agent edits needed.
|
||||
- **Files.** `data/ChatMessage.kt` (new `voiceIntent: VoiceIntentTrace?` field), `voice/VoiceIntentSyncBuilder.kt` (pure-function builder + helpers), `network/HermesApiClient.kt` (optional `voiceIntentMessages` parameter on both stream methods), `viewmodel/ChatViewModel.kt` (build + sync + flag flip in `startStream`), `viewmodel/VoiceViewModel.kt` (extended dispatch callback wires the structured trace into the chat-trace bubble), `voice/VoiceBridgeIntentHandler.kt` (new `androidToolName` + `androidToolArgsJson` on `IntentResult.Handled`), sideload `VoiceBridgeIntentHandlerImpl.kt` populates them per intent, sideload + googlePlay `VoiceBridgeIntentFactory.kt` typealias updates. Tests in `test/voice/VoiceIntentSyncBuilderTest.kt` (12 cases — empty input, single success, failure with error_code, idempotency, chronological order, prefix gate, blank-args gate, call-id pairing, helpers) and `test/network/handlers/ChatHandlerTest.kt` (4 new cases for trace storage + `markVoiceIntentsSynced`).
|
||||
|
||||
### Added — Barge-in (interrupt the agent)
|
||||
|
||||
Voice mode can now be interrupted by speaking while the agent is
|
||||
replying — the same turn-taking pattern ChatGPT, Siri, and Google
|
||||
Assistant use. Stops the current TTS response the moment your voice
|
||||
is detected, flips to Listening, and hands the mic back to you
|
||||
without you needing to tap anything. If you then stay quiet for
|
||||
~600 ms, the agent resumes from the next sentence of the response
|
||||
you interrupted — so a quick breath or pause won't throw away its
|
||||
answer.
|
||||
|
||||
- **Duplex audio + Silero VAD.** A new `BargeInListener` runs a
|
||||
continuous `AudioRecord` (16 kHz mono PCM, `VOICE_COMMUNICATION`
|
||||
source) alongside TTS playback, feeding 32 ms frames to a bundled
|
||||
Silero voice-activity-detection model via `com.github.gkonovalov:
|
||||
android-vad:silero`. `AcousticEchoCanceler` + `NoiseSuppressor` are
|
||||
attached to the ExoPlayer audio session so the VAD doesn't trip on
|
||||
our own TTS output. A second hysteresis layer on top of the library
|
||||
(`2`–`3` consecutive speech frames depending on sensitivity) rejects
|
||||
isolated false-positive frames.
|
||||
- **Two-stage ducking → cutoff.** A single raw speech frame fires a
|
||||
`maybeSpeech` event → TTS volume ducks to 30 % as a soft
|
||||
acknowledgement (user hears the shift, knows we heard something).
|
||||
If hysteresis passes → hard `bargeInDetected` → `interruptSpeaking()`
|
||||
fires (same path V4 wired for user-initiated interrupts in the
|
||||
voice-quality-pass — cancels synth/play workers, deletes pending
|
||||
cache files). If no follow-up detection within 500 ms, a watchdog
|
||||
un-ducks so a single stray frame doesn't leave playback quieted.
|
||||
- **Resume-from-next-sentence.** `VoiceViewModel` tracks the list of
|
||||
sentence chunks the play worker has spoken plus the index the user
|
||||
interrupted at. After an interrupt, a 600 ms watchdog listens to
|
||||
`VoiceRecorder.amplitude` — if the user keeps speaking past the
|
||||
threshold, the new turn proceeds normally and the interrupted
|
||||
response is dropped. If silence wins, remaining chunks re-enqueue
|
||||
onto the TTS queue and playback resumes from the sentence after the
|
||||
cut. Controlled by the "Resume after interruption" sub-toggle
|
||||
(default on).
|
||||
- **Settings UI.** New "Barge-in" section in Voice Settings: master
|
||||
toggle (default off), sensitivity segmented button (`Off / Low /
|
||||
Default / High` — inverted from the library's `Mode` enum so higher
|
||||
user-facing value = more sensitive), resume sub-toggle, and a
|
||||
compatibility warning badge that shows on devices where
|
||||
`AcousticEchoCanceler.isAvailable() == false` ("Your device may have
|
||||
limited echo cancellation. Barge-in quality will vary.").
|
||||
Preferences live in `BargeInPreferences` DataStore following the
|
||||
existing `BridgeSafetyPreferences` shape.
|
||||
- **Shipped default-off.** AEC quality varies widely across Android
|
||||
OEMs — Pixel is solid, many mid-tier and older devices aren't. The
|
||||
feature ships disabled by default; users opt in from Voice Settings
|
||||
and see the compatibility badge if their device has no AEC. A
|
||||
`useExoPlayerVoice` flavor-safe architecture from the voice-quality-
|
||||
pass already exposed `VoicePlayer.audioSessionId`, which is what
|
||||
AEC binds against.
|
||||
- **Live settings reactivity.** Toggling the feature on or off
|
||||
mid-conversation works without restarting voice mode; the
|
||||
coordinator observes `BargeInPreferences.flow` and starts/stops the
|
||||
listener on each emission.
|
||||
|
||||
Tests: 7 new `VoiceViewModelBargeInTest` cases covering the
|
||||
interrupt path, resume-vs-keep-talking branches, the ducking
|
||||
watchdog, and live prefs-change reactivity. Plus unit tests for each
|
||||
new subsystem (VAD engine, duplex listener with `AudioFrameSource`
|
||||
seam for non-instrumented tests, ducking helpers, DataStore).
|
||||
|
||||
### Changed — Voice output quality pass
|
||||
|
||||
Addresses four symptom classes that surfaced in on-device voice testing
|
||||
after v0.4.0: voice output switching between crisp and muffled, volume
|
||||
drifting between sentences, audible pauses between chunks, and occasional
|
||||
jumbled-letter spell-outs when the agent emitted markdown, URLs, or
|
||||
tool-annotation tokens. Root-caused across five compounding layers and
|
||||
fixed end-to-end in a single agent-team session on
|
||||
`feature/voice-quality-pass`.
|
||||
|
||||
- **Text sanitization, both ends.** A new `plugin/relay/tts_sanitizer`
|
||||
module strips markdown (code fences, links, URLs, bold/italic, inline
|
||||
code, headers, list markers, horizontal rules), Hermes tool-annotation
|
||||
tokens (`` `💻 terminal` ``, `` `🔧 android_foo` ``, etc.), and a
|
||||
conservative standalone-emoji set before `/voice/synthesize` hands
|
||||
text to the upstream `text_to_speech_tool`. The same regex set is
|
||||
mirrored client-side in `VoiceViewModel.sanitizeForTts` and applied
|
||||
per delta before the sentenceBuffer sees the text, with multi-delta
|
||||
code-fence deferral so unclosed fences don't leak orphaned backticks
|
||||
to the chunker. Kills the "jumbled letters" symptom — ElevenLabs no
|
||||
longer reads URLs character-by-character or speaks backtick+emoji
|
||||
wrappers aloud.
|
||||
- **Coalescing chunker.** The old `MIN_SENTENCE_LEN=6` chunker emitted
|
||||
every tiny acknowledgement (`"Sure."`, `"Okay."`) as its own TTS
|
||||
call, guaranteeing audible inter-chunk variance. New
|
||||
`MIN_COALESCE_LEN=40` + `MAX_BUFFER_LEN=400` secondary-break escape
|
||||
merges short runs into one synthesize call, splits run-on sentences
|
||||
at the last comma/semicolon/em-dash inside the 400-char window, and
|
||||
preserves the `e.g.`/`U.S.` abbreviation lookahead. An 800 ms
|
||||
silent-delta timer force-flushes buffered text so trailing fragments
|
||||
on an abrupt stream-end don't strand in the buffer.
|
||||
- **Prefetch pipelining.** `VoiceViewModel.startTtsConsumer` was
|
||||
previously a strictly serial `synthesize → play → awaitCompletion`
|
||||
loop — every sentence boundary cost one full network round-trip. Now
|
||||
split into two `supervisorScope`-rooted coroutines joined by a
|
||||
bounded `Channel<File>(capacity=2)`: the synth worker runs up to one
|
||||
sentence ahead of the play worker, so N+1's audio is already on disk
|
||||
when N's playback finishes. Synth failures on N+1 no longer stall
|
||||
N's playback. Cancellation paths (`stopVoice`,
|
||||
`interruptSpeaking`, `exitVoiceMode`) cancel the scope and delete any
|
||||
unplayed `voice_tts_<ts>.mp3` cache files; a `finally`-scoped
|
||||
cleanup catches any late-arriving synth results that beat the cancel
|
||||
signal.
|
||||
- **Gapless ExoPlayer playback.** `VoicePlayer` swapped from
|
||||
recreating a `MediaPlayer` per file to a single persistent Media3
|
||||
ExoPlayer + `addMediaItem` queue. Appending is non-blocking;
|
||||
`awaitCompletion()` now returns when the queue is drained AND the
|
||||
player is idle (documented semantic change). Kills the codec-reset
|
||||
pop between sentences and composes naturally with the prefetcher —
|
||||
the play worker appends without blocking the synth worker. Visualizer
|
||||
attaches once against the ExoPlayer audio session (deferred to the
|
||||
first `onIsPlayingChanged(true)` since some OEMs initialize the
|
||||
session id lazily) and degrades gracefully if attach fails. Ships
|
||||
behind a `FeatureFlags.useExoPlayerVoice` hook as a safety net; no
|
||||
`MediaPlayer` fallback is currently wired.
|
||||
- **ElevenLabs model flipped to `eleven_flash_v2_5`.** Operator change
|
||||
applied to `~/.hermes/config.yaml` on hermes-host ahead of the code
|
||||
work. `eleven_multilingual_v2` is expressive but re-interprets
|
||||
prosody per call — wrong model for a chunked pipeline.
|
||||
`eleven_flash_v2_5` is the streaming-optimized model (~75 ms
|
||||
per-request latency, lower per-call variance, designed exactly for
|
||||
sentence-scale pipelines) and is net-cheaper per character. Voice id
|
||||
unchanged. This single flip accounts for the bulk of the perceived
|
||||
"clear↔muffled switching" reduction; the code units below reduce
|
||||
what remained.
|
||||
|
||||
Deferred: upstream PR exposing `VoiceSettings` (stability /
|
||||
similarity_boost / use_speaker_boost) in
|
||||
`hermes-agent/tools/tts_tool.py::_generate_elevenlabs`. Useful once
|
||||
merged — default `stability` is a hair too low for consistent chunked
|
||||
output — but not blocking; the flash model already solves most of what
|
||||
the settings would.
|
||||
|
||||
Tests: 33 new relay sanitizer tests, 4 new client test files covering
|
||||
sanitization parity, chunking semantics, prefetch pipelining timing +
|
||||
cancellation cleanup, and ExoPlayer queue behavior.
|
||||
|
||||
## [0.4.0] - 2026-04-14
|
||||
|
||||
### Added — Bridge feature expansion (the big one)
|
||||
@@ -364,7 +1181,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
|
||||
@@ -398,7 +1215,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
|
||||
@@ -428,20 +1245,24 @@ 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.1.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
|
||||
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
|
||||
[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
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
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.
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Standard chat, Manage, and dashboard voice work against unmodified upstream Hermes. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
|
||||
|
||||
**Current state:** v0.4.x — Phase 0–3 complete. Direct API chat, session management, pairing + security, inbound media, voice mode, bridge/accessibility control, notification companion, and safety rails. Two product flavors: `googlePlay` (conservative) and `sideload` (full-capability).
|
||||
**Current state:** v1.0.0 stable. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Standard voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
Phone (WS) -> Hermes dashboard (:9119) [standard gateway chat, live thinking]
|
||||
Phone (HTTP/SSE) -> Hermes API Server (:8642) [standard chat fallback, sessions, runs]
|
||||
Phone (HTTP) -> Hermes dashboard (:9119) [standard Manage + voice]
|
||||
Phone (WSS/HTTP) -> Relay plugin/server (:8767) [optional bridge, terminal, relay voice, remote tools]
|
||||
```
|
||||
|
||||
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
|
||||
The standard path must stay vanilla upstream only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; standard chat, Manage, and dashboard voice must not.
|
||||
|
||||
### Upstream Hermes API Reference
|
||||
|
||||
@@ -29,40 +31,54 @@ 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 | — |
|
||||
| `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** (`plugin/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. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
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`, `/categories`, `/{name}` | Skill discovery | Fork OR bootstrap OR upstream-merged |
|
||||
| `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 — 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/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 uses it for Manage, standard voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the standard fallback. 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
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection path — gateway/API chat, Manage, and standard voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **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 `plugin/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
|
||||
|
||||
@@ -79,13 +95,34 @@ hermes-android/
|
||||
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
|
||||
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
|
||||
│ └── notifications/ # HermesNotificationCompanion
|
||||
├── relay-core/ ← [EXPERIMENTAL] Quest/XR shared core lib (com.axiomlabs.hermesrelay.core) — pairing, transport, terminal, voice, wire
|
||||
├── relay-ui/ ← [EXPERIMENTAL] Quest/XR shared Compose UI lib — sphere, terminal WebView, QR scanner
|
||||
├── quest/ ← [EXPERIMENTAL] Meta Spatial SDK Quest/XR app (gradle includeBuild; in development, not shipped)
|
||||
├── ui-preview/ ← Desktop Compose Hot Reload harness for PC UI iteration (NOT shipped; shares MorphingSphereCore)
|
||||
├── desktop/ ← Node thin-client CLI (`@hermes-relay/cli`)
|
||||
│ ├── bin/hermes-relay.js # #!/usr/bin/env node shim → dist/cli.js
|
||||
│ ├── src/
|
||||
│ │ ├── cli.ts # argv parser + subcommand dispatcher (bare → shell)
|
||||
│ │ ├── commands/ # chat, shell, pair, status, tools, devices
|
||||
│ │ ├── banner.ts # contextual connect line (LAN / Tailscale / Plain / Secure)
|
||||
│ │ ├── renderer.ts # GatewayEvent → plain-line stdout formatter (chat only)
|
||||
│ │ ├── endpoint.ts # ADR 24 EndpointCandidate + role helpers
|
||||
│ │ ├── pairingQr.ts # v3 QR decode + priority-raced reachability probe
|
||||
│ │ ├── pairing.ts # readline 6-char prompt + payload validator
|
||||
│ │ ├── credentials.ts # token → pair-qr → code → stored → prompt precedence
|
||||
│ │ ├── certPin.ts # TOFU SPKI sha256 extract / pinKey / compare
|
||||
│ │ ├── tools/ # desktop.command router + fs/terminal/search handlers + consent
|
||||
│ │ ├── transport/ # RelayTransport (reconnect state machine + TLS probe TOFU)
|
||||
│ │ └── lib/ # gracefulExit, rpc, circularBuffer (vendored)
|
||||
│ └── scripts/ # install.sh + install.ps1 curl/iwr one-liners
|
||||
├── plugin/ ← Hermes agent plugin
|
||||
│ ├── android_tool.py # 18 android_* tool handlers
|
||||
│ ├── pair.py # QR pairing implementation
|
||||
│ ├── relay/ # Canonical WSS relay (server.py, auth.py, channels/, media.py, voice.py)
|
||||
│ └── tools/ # android_navigate.py, android_notifications.py
|
||||
│ ├── 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/ ← Legacy import shim for older startup hooks
|
||||
├── 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
|
||||
@@ -99,6 +136,17 @@ hermes-android/
|
||||
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
|
||||
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
|
||||
|
||||
### Public-repo writing hygiene
|
||||
|
||||
This is a **public, distributed repo** — every committed file (CHANGELOG, DEVLOG, README, docs, release notes) is public-facing. Write accordingly:
|
||||
|
||||
- **No personal names** in prose — attribute impersonally ("a user reported", "observed"). Author identity lives in git history + the signing cert, not the changelog.
|
||||
- **No private infrastructure** — real server hostnames/IPs, internal deployment names, `~/SYSTEM.md` contents. (Generic example IPs like `192.168.1.100` in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** — no "I should have…", no course-correction confessionals. State the technical conclusion, not the path to it.
|
||||
- **No internal jargon / fork-branch plumbing** in user-facing notes — keep *what changed*, drop *where we staged it*.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping (Added / Changed / Fixed). Detail may accumulate during iteration, but at **release-prep the version block is condensed to crisp public bullets** (1–2 lines each) — deep "how we debugged it" stays in commits/DEVLOG. See [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution".
|
||||
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
|
||||
|
||||
### Code Style — Android (Kotlin)
|
||||
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
|
||||
- **kotlinx.serialization** — not Gson. Type-safe, faster.
|
||||
@@ -108,6 +156,13 @@ hermes-android/
|
||||
- **applicationId:** `com.axiomlabs.hermesrelay` (googlePlay), `com.axiomlabs.hermesrelay.sideload` (sideload)
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 36** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
|
||||
### Code Style — Desktop CLI (Node/TypeScript)
|
||||
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
|
||||
- **Zero runtime deps** — `@types/node` + `tsx`/`rimraf`/`typescript` are devDeps only. Ship compiled `dist/`, not tsx.
|
||||
- **One binary, subcommands** — idiomatic for Node CLIs (codex, continue, vite pattern). Bare invocation is `chat`.
|
||||
- **Vendor-for-now** — transport/gateway/types are copied verbatim from `hermes-agent-tui-smoke/ui-tui/src/` with a header note. Extract to a shared package when the TUI and CLI stabilize.
|
||||
- **Dev loop:** `npx tsx src/cli.ts <args>` (no rebuild). `npm run build` + `npm link` before pushing to verify the bin shim. Never ship tsx in the published tarball — pre-build with `tsc` so Windows `npm install -g` can cmd-shim the JS directly.
|
||||
|
||||
### Code Style — Server (Python)
|
||||
- **aiohttp** — async, matches existing Hermes relay patterns
|
||||
- **Type hints everywhere** — Python 3.11+ syntax
|
||||
@@ -115,15 +170,17 @@ hermes-android/
|
||||
|
||||
### Git
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **Feature branches** as of 2026-04-13. Straight-to-main for single-file typos only.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches.
|
||||
- **Version bumps on `main` only.** Use `bash scripts/bump-version.sh <new-version>` to bump all three sources atomically (`gradle/libs.versions.toml`, `pyproject.toml`, `plugin/relay/__init__.py`).
|
||||
- **Branch protection** on `main` since 0.3.0 — PRs must pass CI; direct push blocked except `release: vX.Y.Z`.
|
||||
- **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`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-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 runs on every push** — build must pass before merge
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.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
|
||||
|
||||
@@ -131,13 +188,22 @@ hermes-android/
|
||||
|------|-----|
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
|
||||
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()` |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
|
||||
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
|
||||
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
|
||||
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
|
||||
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
|
||||
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
|
||||
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
|
||||
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
|
||||
@@ -148,13 +214,16 @@ hermes-android/
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
|
||||
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
|
||||
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge UI — master toggle, status, permission checklist, safety summary, activity log |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
|
||||
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
|
||||
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
|
||||
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
|
||||
| `accessibility/HermesAccessibilityService.kt` | AccessibilityService subclass; `@Volatile instance` singleton for BridgeCommandHandler |
|
||||
| `accessibility/ScreenReader.kt` | UI tree → ScreenContent; `findNodeBoundsByText()`, `findFocusedInput()` |
|
||||
@@ -162,32 +231,102 @@ 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 |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
|
||||
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
|
||||
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
|
||||
| `ui/components/ChatImageContent.kt` | Parses `` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret` |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `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/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| **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 |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
| **Gradle modules — experimental Quest/XR (in development)** | |
|
||||
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
|
||||
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
|
||||
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
|
||||
| **Tooling — dev iteration (not shipped)** | |
|
||||
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -235,9 +374,10 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
1. **Edit locally** — Windows checkout. Both plugin (`plugin/`) and app (`app/`) live here.
|
||||
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
|
||||
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
|
||||
4. **Commit + push** — feature branch for anything >1-2 commits.
|
||||
5. **Pull + restart on server** — see Server Deployment below.
|
||||
6. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
|
||||
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs (see `.github/workflows/ci.yml` → `gradlew lint` fallback) and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Lint is a hard blocker in CI: Build + Test show "skipping" until lint passes, and lint prints only the **first failure** before aborting — so CI iterations reveal errors one at a time while a single local lint run surfaces all of them.
|
||||
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
|
||||
6. **Pull + restart on server** — see Server Deployment below.
|
||||
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
|
||||
|
||||
### Server Deployment
|
||||
|
||||
@@ -253,6 +393,12 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
|
||||
|
||||
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
|
||||
|
||||
**Compat hook:** `hermes relay compat status/install/remove` manages only the
|
||||
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
|
||||
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
|
||||
package is only a legacy import shim. Standard chat, Manage, and dashboard voice
|
||||
must not depend on this hook.
|
||||
|
||||
**Key conventions:**
|
||||
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
|
||||
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
|
||||
@@ -271,31 +417,47 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
- **Version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`)
|
||||
- **Bump atomically:** `bash scripts/bump-version.sh <new-version>` — updates all three sources
|
||||
- **`appVersionCode` is monotonic** — always increment across prereleases
|
||||
- **Cut a release:** bump → commit → `git tag vMAJOR.MINOR.PATCH` → push tag → CI builds + GitHub Release
|
||||
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
|
||||
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
|
||||
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
|
||||
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
|
||||
- **`appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
|
||||
## Integration Points
|
||||
|
||||
| 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 (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Standard upstream dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| 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 |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Standard upstream dashboard surface; do not proxy through Relay |
|
||||
| Standard voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Standard no-plugin voice; uses dashboard session from Manage |
|
||||
| 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` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable\|disable\|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| 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` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
## Upstream References
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Hermes-Relay-CLI v__VERSION__
|
||||
|
||||
**Release Date:** <!-- YYYY-MM-DD -->
|
||||
**Since the previous CLI release:** <!-- one line: the theme of this release -->
|
||||
|
||||
<!-- One short paragraph: what this desktop/CLI release is about and who should care. -->
|
||||
|
||||
<!--
|
||||
═══ RELEASE-PREP CHECKLIST (delete this comment block when done) ═══
|
||||
• This file is the GitHub Release body for `cli-v*` tags. The release workflow
|
||||
substitutes __VERSION__ (bare, e.g. 0.3.0) and __TAG__ (full, e.g. cli-v0.3.0) —
|
||||
leave those tokens in the Install section; do NOT hardcode versions there.
|
||||
• Rewrite the Summary + the Added/Changed/Fixed groups from the CLI/desktop-relevant
|
||||
bullets in CHANGELOG.md's promoted version block.
|
||||
• Keep-a-Changelog rules: include only the groups that have entries; delete empty ones.
|
||||
• Keep the "Experimental phase" notice until the CLI reaches GA.
|
||||
• Scrub for public distribution (RELEASE.md §2): no personal names, no private infra,
|
||||
no fork-branch plumbing, no AI self-narration.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
-
|
||||
|
||||
### Changed
|
||||
-
|
||||
|
||||
### Fixed
|
||||
-
|
||||
|
||||
## Install
|
||||
|
||||
**Windows tray app (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=__TAG__`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
|
||||
|
||||
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
+13
-3
@@ -92,16 +92,26 @@ After the plugin is in place, restart hermes and verify pairing with `hermes-pai
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
|
||||
|
||||
Feature branches are the house style — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — merged into `main` via `--no-ff` merge commits so the per-branch history stays visible in `git log --graph`. Straight-to-`main` is reserved for single-file typo fixes.
|
||||
**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 + tag) are allowed to push directly to `main` via a branch-protection carve-out — 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.
|
||||
|
||||
## Changelog & writing conventions
|
||||
|
||||
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
|
||||
|
||||
- **`CHANGELOG.md`** follows [Keep a Changelog](https://keepachangelog.com/) (Added / Changed / Fixed). Append your change to the `## [Unreleased]` block in the PR. Entries can carry detail while they accumulate, but at release-prep the version block is **condensed to crisp public bullets** (1–2 lines each) — the deep "how we debugged it" narrative belongs in commit messages and `DEVLOG.md`, not the public changelog.
|
||||
- **`DEVLOG.md`** is a factual engineering log — what changed, why, and how it was verified. Keep it depersonalized and third-person; it's a record, not a diary.
|
||||
- **No non-public wording anywhere committed:** no personal names (attribute impersonally — identity lives in git history), no real server hostnames/IPs or internal deployment names, no AI/assistant process self-narration, no fork/branch plumbing in user-facing notes. Generic example IPs in setup docs are fine.
|
||||
|
||||
Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/play-store-listing.md`) are theme-framed and user-facing; see [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution" for the full checklist.
|
||||
|
||||
## 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 (`.github/workflows/ci.yml`) runs lint, Android build, Android unit tests, and a Python relay syntax check on every push.
|
||||
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?
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** June 16, 2026
|
||||
**Since the previous plugin release:** Easier setup and a fixed dashboard panel — plus mid-conversation `/relay` controls and a relay-status widget.
|
||||
|
||||
This release makes the relay plugin easier to install and live with. Setup now prompts for the optional voice-provider keys instead of asking you to hand-edit `.env`, tools-only hosts can install through the native `hermes plugins install` path, and the installer no longer breaks on `uv`-managed Hermes cores. The dashboard panel — which previously rendered as blank boxes on the host's design system — now displays correctly, and a header widget plus `/relay` slash commands surface relay state from anywhere. The standard no-plugin path needs none of this.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
- **Guided env-key setup.** The plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of requiring a hand-edited `.env`. The standard no-plugin path needs none.
|
||||
- **Native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
|
||||
- **`/relay` slash commands.** `relay status · devices · pair` are usable mid-conversation from any platform (CLI / Discord / TUI).
|
||||
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
|
||||
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
|
||||
|
||||
### Fixed
|
||||
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
|
||||
- **Dashboard buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button` / `Badge` use boolean variant flags (`outlined` / `ghost` / `invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter and drops a label-hiding CSS reset.
|
||||
- **Unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background; solid button variants now keep their proper contrast colour.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==__VERSION__
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use `android-v*`, CLI releases use `cli-v*`. Historical
|
||||
relay/plugin releases used `relay-v*` tags.
|
||||
@@ -1,19 +1,23 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo.svg" alt="Hermes-Relay" width="120">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
<p align="center">
|
||||
<strong>Runs on your machine. Lives on your devices.</strong><br>
|
||||
A native Android companion for your <a href="https://github.com/NousResearch/hermes-agent">Hermes agent</a> — streaming chat, hands-free voice,
|
||||
and full agent management. Plus a single-binary CLI that gives the agent hands on any machine you pair.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Native Android client for the Hermes agent platform.<br>
|
||||
Chat, control, and connect — one app for your AI agent.
|
||||
<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="56"></a>
|
||||
</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/Platform-Android-green.svg" alt="Android"></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://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></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://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,217 +27,297 @@
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo.mp4" poster="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo_poster.jpg" autoplay loop muted playsinline width="280"></video>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## What it is
|
||||
|
||||
Two steps: install the Android app on your phone, then install the plugin on your Hermes server.
|
||||
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
|
||||
|
||||
### 1. Install the Android app
|
||||
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
|
||||
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
|
||||
<!-- 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>
|
||||
-->
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases/latest)
|
||||
## Quick Start (Android)
|
||||
|
||||
#### Sideload APK (GitHub Releases)
|
||||
Install → connect → talk, in about two minutes.
|
||||
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
### 1 · Install the app
|
||||
|
||||
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).
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — 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). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
### 2 · Have Hermes running
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is standard Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
Open the app and pick how to connect — any of:
|
||||
|
||||
- **Standard Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Standard Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
|
||||
|
||||
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 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
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
Use the legacy installer instead if you also want the systemd user service,
|
||||
shell shims, and the full clone/update workflow:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
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 phone via either of these equivalent entry points:
|
||||
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
|
||||
and agent tools. `hermes relay compat status/install/remove` manages only the
|
||||
optional legacy API compatibility hook when an older Hermes build needs it. Scan
|
||||
the QR from the phone's Connections screen — or use
|
||||
`hermes pair --register-code ABCD12` with the manual code from Android
|
||||
**Settings → Connections → Advanced**.
|
||||
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR 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. 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. Composes with `--ttl` / `--grants`.
|
||||
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
|
||||
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
|
||||
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It 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.
|
||||
|
||||
Scan the QR from the Android app's onboarding screen and you're connected. One scan configures **both** the direct-chat API server **and** the WSS relay (for terminal/bridge) — 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, 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.
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**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.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
|
||||
|
||||
**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.
|
||||
## Screenshots
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26), [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+.
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/01_startup.png" alt="Cold start" width="100%"><br><sub><b>Cold start</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/02_chat.png" alt="Streaming chat" width="100%"><br><sub><b>Streaming chat</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/03_voice.png" alt="Hands-free voice" width="100%"><br><sub><b>Hands-free voice</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/05_commands.png" alt="Command palette" width="100%"><br><sub><b>Command palette</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections & routes</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/08_settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### For AI Agents
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
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.
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — rides standard Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a 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 keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
|
||||
- **Hands-free voice** — talk 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 and an opt-in provider-native Realtime Agent with background task handoff.
|
||||
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
|
||||
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
|
||||
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
|
||||
- **Notification companion** — opt-in 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.
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
> **Alpha · Windows today** (macOS / Linux coming soon). A single self-contained binary — no Node required. Binaries are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay daemon # headless tool router — agent reaches you anytime
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, standard voice]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat fallback, sessions, runs]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
|
||||
```
|
||||
|
||||
Chat prefers the Hermes dashboard gateway when Manage auth is ready, then falls
|
||||
back to the upstream API server SSE path with the API key. Manage 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, machine tools, and
|
||||
relay-side voice, which is 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/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | 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 |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
|
||||
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
|
||||
<br>
|
||||
|
||||
If an AI assistant 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 + Python plugin for the Hermes AI agent platform.
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
Read the canonical setup recipe before acting:
|
||||
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
|
||||
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the 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`
|
||||
- Verifying with `hermes-status`
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`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` (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.
|
||||
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.
|
||||
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — all native on Android.
|
||||
|
||||
| Channel | What | Status |
|
||||
|---------|------|--------|
|
||||
| **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
|
||||
## Features
|
||||
|
||||
- **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
|
||||
- **Voice mode** — Real-time voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Uses your server's configured TTS/STT providers (Edge TTS, ElevenLabs, OpenAI, MiniMax, Mistral, NeuTTS / faster-whisper, Groq, OpenAI Whisper)
|
||||
- **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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install the app** from the link above
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) --> Relay Server (:8767) [terminal, bridge — future]
|
||||
```
|
||||
|
||||
Chat connects directly to the Hermes API Server — same pattern used by Open WebUI and other Hermes frontends. The relay server is a separate lightweight Python service for terminal and bridge channels (coming in Phase 2/3).
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, features, configuration — start here** |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the app works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by the app |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
|
||||
---
|
||||
</details>
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **File > Open** the repo root in Android Studio
|
||||
2. Wait for Gradle sync
|
||||
3. **Run** (Shift+F10) to deploy to emulator or device
|
||||
|
||||
### Dev Scripts
|
||||
|
||||
```bash
|
||||
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
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)
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp)
|
||||
├── plugin/ # Hermes agent plugin (18 android_* tools + pair module)
|
||||
├── skills/ # Hermes agent skills
|
||||
│ └── devops/
|
||||
│ └── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server / plugin** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
|
||||
|
||||
### Relay Server (optional — terminal/bridge only)
|
||||
<details>
|
||||
<summary><b>Repository structure</b></summary>
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Hermes-Relay CLI thin-client (TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, machine tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/devops/ # Hermes agent skills (pairing, self-setup, CLI setup recipes)
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-plugin / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Running the server / plugin from a clone</b></summary>
|
||||
|
||||
<br>
|
||||
|
||||
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
# or from a repo checkout:
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
python -m plugin.relay --no-ssl # or from a repo checkout
|
||||
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
# Docker:
|
||||
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
|
||||
```
|
||||
|
||||
See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
# Or symlink for live edits:
|
||||
# Live-edit the plugin against a local Hermes:
|
||||
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 14 `android_*` 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 `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
## Hermes Agent
|
||||
</details>
|
||||
|
||||
## Built for Hermes Agent
|
||||
|
||||
Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
|
||||
|
||||
## Found a bug? Let us know!
|
||||
## Found a bug? Let us know
|
||||
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" is genuinely useful.
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line *"this didn't work on my Pixel 7"* is genuinely useful.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+406
-128
@@ -3,7 +3,7 @@
|
||||
> The full recipe for cutting a new release. Read this end-to-end before
|
||||
> tagging your first release.
|
||||
|
||||
## Versioning
|
||||
## Release Tracks And Versioning
|
||||
|
||||
Hermes-Relay follows [SemVer](https://semver.org/): `MAJOR.MINOR.PATCH`,
|
||||
with optional prerelease identifiers.
|
||||
@@ -13,6 +13,26 @@ with optional prerelease identifiers.
|
||||
- `PATCH` — bug fixes, backwards compatible
|
||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||
|
||||
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
|
||||
Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
|
||||
for automation.
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-cli.yml` |
|
||||
|
||||
This split is intentional. The plugin carries relay features for both Android
|
||||
and CLI clients, so plugin fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
|
||||
Android releases before this naming split used bare `v*` tags. Historical
|
||||
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
|
||||
`desktop-v*` tags. New releases use the explicit tag prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
**Source of truth:** `gradle/libs.versions.toml`
|
||||
|
||||
```toml
|
||||
@@ -44,36 +64,80 @@ Never decrement `appVersionCode` — Play Console rejects any upload whose
|
||||
code is lower than or equal to a previous upload on the same track. Confirm
|
||||
current values with `scripts\dev.bat version`.
|
||||
|
||||
### The three version sources (MUST stay in lockstep)
|
||||
|
||||
There are **three** places the version lives, and they MUST all match on
|
||||
every release commit. Drift is silent and painful — we chased a "why does
|
||||
/health say 0.2.0" bug for hours on 2026-04-12 because `pyproject.toml`
|
||||
had drifted to `0.5.0` speculatively and `plugin/relay/__init__.py` was
|
||||
still at a stale `0.2.0`.
|
||||
|
||||
| File | Line | Written by |
|
||||
|---|---|---|
|
||||
| `gradle/libs.versions.toml` | `appVersionName = "…"` | You (canonical) |
|
||||
| `pyproject.toml` | `version = "…"` | You (Python package) |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "…"` | You (runtime, reported by `/health`) |
|
||||
|
||||
**Always bump them atomically via `scripts/bump-version.sh`**:
|
||||
Always bump Android releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-version.sh 0.3.0
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
The script validates SemVer, bumps `appVersionCode` monotonically, rewrites
|
||||
all three files, runs a post-bump sanity grep, prints the diff, and tells
|
||||
you the next steps. It deliberately does NOT commit, tag, or touch
|
||||
`CHANGELOG.md` / `RELEASE_NOTES.md` — those need human prose.
|
||||
`scripts/bump-version.sh` remains as a backward-compatible alias for the
|
||||
Android script.
|
||||
|
||||
### Plugin / Python package versioning
|
||||
|
||||
Plugin version metadata lives in these plugin-owned files and must stay in
|
||||
lockstep:
|
||||
|
||||
| File | Line | Purpose |
|
||||
|---|---|---|
|
||||
| `pyproject.toml` | `version = "..."` | Python package metadata |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` and `/relay/info` |
|
||||
| `plugin/plugin.yaml` | `version: ...` | Hermes plugin metadata |
|
||||
| `plugin/dashboard/manifest.json` | `"version": "..."` | Hermes dashboard plugin metadata |
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Plugin releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Check the current metadata with:
|
||||
|
||||
```bash
|
||||
python scripts/check-plugin-version-sync.py
|
||||
```
|
||||
|
||||
Check all release tracks at once with:
|
||||
|
||||
```bash
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
This aggregate check reports Android, plugin, and CLI versions
|
||||
side by side and validates that each track's own source files are internally
|
||||
consistent. It deliberately does not require all three tracks to share the same
|
||||
SemVer.
|
||||
|
||||
The `plugin-v*` release workflow validates the tag against the same metadata,
|
||||
runs plugin tests, builds a wheel and sdist, generates checksums, and
|
||||
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
|
||||
artifacts.
|
||||
|
||||
## Branching policy
|
||||
|
||||
Hermes-Relay uses **feature branches + no-ff merges**, with version bumps
|
||||
gated to release-prep commits on `main`. `main` is always "last release +
|
||||
unreleased features," never mid-refactor.
|
||||
> **Updated 2026-04-19:** moved from `main`-only to `main + dev`. See
|
||||
> `docs/decisions.md` §23 for the rationale.
|
||||
|
||||
Hermes-Relay uses **`main` + `dev` with feature branches and no-ff
|
||||
merges**. `main` is **released state only** — every commit on `main`
|
||||
corresponds to a shipped version or a release-merge of `dev`. Day-to-day
|
||||
integration happens on `dev`.
|
||||
|
||||
**Merging is decoupled from releasing.** Feature branches land on `dev`
|
||||
continuously as they go green in CI — there is no "one feature per
|
||||
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
|
||||
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
|
||||
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
|
||||
see state that lives on `main` and on release tags.
|
||||
|
||||
### Branch names
|
||||
|
||||
@@ -84,14 +148,17 @@ unreleased features," never mid-refactor.
|
||||
| `docs/<name>` | Docs-only changes larger than a typo | `docs/sideload-guide` |
|
||||
| `chore/<name>` | Cleanup / refactor / tooling | `chore/sync-version-sources` |
|
||||
|
||||
Straight-to-main is still OK for **single-file typo fixes** and **tiny
|
||||
one-liner tweaks**. Judgment call — if in doubt, branch.
|
||||
All of the above branch off `dev` and merge back to `dev`. There is no
|
||||
straight-to-main exemption — even single-file typos go through a feature
|
||||
branch and PR into `dev`.
|
||||
|
||||
### Merge style: `--no-ff`
|
||||
|
||||
Always merge with `git merge --no-ff <branch>` (or the "Create a merge
|
||||
commit" option in the GitHub PR UI). This preserves the branch context
|
||||
as a visible merge commit in `git log --graph`, which is valuable when:
|
||||
commit" option in the GitHub PR UI). This applies at every level —
|
||||
feature → `dev`, and `dev` → `main` for release merges. `--no-ff`
|
||||
preserves the branch context as a visible merge commit in
|
||||
`git log --graph`, which is valuable when:
|
||||
|
||||
- An agent team pushed several commits to a branch — the per-commit trail
|
||||
is useful for "which agent did what"
|
||||
@@ -101,31 +168,32 @@ as a visible merge commit in `git log --graph`, which is valuable when:
|
||||
|
||||
Squash merges lose that detail and are **not** the house style.
|
||||
|
||||
### Version bumps happen at release-prep, NOT on feature branches
|
||||
### Version bumps happen at release-prep on `dev`, NOT on feature branches
|
||||
|
||||
Feature branches **never** touch `gradle/libs.versions.toml`,
|
||||
`pyproject.toml`, or `plugin/relay/__init__.py`. If two feature branches
|
||||
both bumped the version, they'd collide on `appVersionCode` (which must
|
||||
be monotonic) and you'd hit a merge conflict for no good reason.
|
||||
plugin-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).
|
||||
|
||||
The version-bump commit lives on `main`, created via
|
||||
`scripts/bump-version.sh`, immediately before `git tag`. It's a dedicated
|
||||
commit with the message `release: vX.Y.Z` that also lands the CHANGELOG
|
||||
and RELEASE_NOTES updates.
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
|
||||
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
|
||||
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection on `main`
|
||||
### Branch protection
|
||||
|
||||
Light branch protection is enabled on `main` to enforce the above:
|
||||
Light branch protection is enabled:
|
||||
|
||||
- Direct pushes blocked (must go through PR)
|
||||
- PR must pass CI (build + unit tests) before merge
|
||||
- Force push and branch deletion blocked
|
||||
- Signed commits + review approval NOT required (solo-dev overhead)
|
||||
|
||||
Release-prep commits (`release: vX.Y.Z`) are an exception — they're the
|
||||
one time direct push is allowed via a short-lived bypass, because they
|
||||
include the version bump + tag push in one transaction. Everything else
|
||||
goes through a PR.
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Plugin) 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
|
||||
blocked.
|
||||
- Signed commits + review approval NOT required (solo-dev overhead).
|
||||
|
||||
## One-time Setup
|
||||
|
||||
@@ -156,10 +224,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
|
||||
@@ -220,43 +290,80 @@ for the full text.
|
||||
|
||||
### 3. Play Developer API service account (optional)
|
||||
|
||||
Required only if you want `gradlew publishReleaseBundle` to upload directly
|
||||
to Play Console. Manual UI uploads work without this.
|
||||
Required for automated upload (the `android-v*` workflow's Play step, or local
|
||||
`gradlew publishGooglePlayReleaseBundle`). Manual UI uploads work without this.
|
||||
|
||||
1. Open <https://console.cloud.google.com/> and select the project linked
|
||||
to your Play Console account (Play Console > Setup > API access shows
|
||||
which one).
|
||||
2. **IAM & Admin > Service Accounts > Create Service Account** (e.g.
|
||||
`hermes-relay-publisher`). No project roles needed.
|
||||
3. On the new service account, **Keys > Add key > Create new key > JSON**
|
||||
and download the file.
|
||||
4. In Play Console > **Setup > API access**, find the service account,
|
||||
click **Grant access**, and assign the **Release manager** role.
|
||||
5. Save the JSON as `play-service-account.json` in the repo root (already
|
||||
in `.gitignore`).
|
||||
6. Verify with `gradlew bootstrapReleasePlayResources` — should succeed
|
||||
without auth errors.
|
||||
The service account is **created in Google Cloud Console** and then **authorized
|
||||
in Play Console** — two separate consoles. (Play Console's older "Setup > API
|
||||
access" page has been reorganized; there is no longer a "Setup" group. Use the
|
||||
paths below.)
|
||||
|
||||
1. **Create the service account (Google Cloud Console).** Open
|
||||
<https://console.cloud.google.com/iam-admin/serviceaccounts>, pick the project
|
||||
(any project works; if Play Console's **API access** page already names a linked
|
||||
project, use that one). **Create service account** → name it e.g.
|
||||
`hermes-relay-publisher` → **Done**. No project roles needed.
|
||||
2. **Create a JSON key.** On the new service account → **Keys** tab → **Add key >
|
||||
Create new key > JSON** → download. This file's *contents* are the secret.
|
||||
3. **Authorize it in Play Console.** Open the Play Console account-level left
|
||||
sidebar → **Users and permissions** → **Invite new users** → paste the service
|
||||
account's email (`...@...iam.gserviceaccount.com`). Under **App permissions**
|
||||
(for `com.axiomlabs.hermesrelay`) or **Account permissions**, grant the
|
||||
**Release** permissions — "Release apps to testing tracks" and "Release to
|
||||
production, exclude devices, and use Play App Signing" — plus "View app
|
||||
information". (Granting **Admin (all permissions)** also works but is broader
|
||||
than needed.) **Invite user**.
|
||||
4. **Use it.** For CI, paste the JSON contents into the `PLAY_SERVICE_ACCOUNT_JSON`
|
||||
repo secret (step 4 / secrets table). For local publish, save the JSON as
|
||||
`play-service-account.json` in the repo root (already in `.gitignore`).
|
||||
5. Verify locally with `gradlew bootstrapGooglePlayReleaseResources` — succeeds
|
||||
without auth errors once permissions propagate (allow a few minutes).
|
||||
|
||||
### 4. GitHub Actions secrets
|
||||
|
||||
In the repo: **Settings > Secrets and variables > Actions > New repository
|
||||
secret.** Add all four (see the table in "Required GitHub Secrets" below).
|
||||
secret.** Add all four (see the table in "Required Android Release Secrets"
|
||||
below).
|
||||
|
||||
If `HERMES_KEYSTORE_BASE64` is missing, CI release builds fall back to
|
||||
debug signing and print a warning in the workflow summary — those
|
||||
artifacts will not be accepted by Play Console.
|
||||
|
||||
## When to cut a release
|
||||
|
||||
Cut a release when **any of the following** is true:
|
||||
|
||||
- The `[Unreleased]` section of `CHANGELOG.md` has enough user-facing
|
||||
change that a version number is worth attaching.
|
||||
- A user-facing bug is fixed and you want affected users to pick it up
|
||||
via `hermes-relay-update` or a Play Store auto-update.
|
||||
- A regulatory / policy deadline applies (new Play Console target SDK,
|
||||
etc).
|
||||
- You've been sitting on unreleased work for more than a couple of
|
||||
weeks and the delta-from-last-release is growing faster than it
|
||||
should.
|
||||
|
||||
**Don't** cut a release just because a feature landed. If one feature
|
||||
isn't enough to justify a version bump, wait — merge the next one, let
|
||||
it sit alongside in `[Unreleased]`, and ship them together. A release
|
||||
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** (`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.
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Bump the version (atomic across all three sources)
|
||||
### 1. Bump the Android app version
|
||||
|
||||
Use `scripts/bump-version.sh` — it rewrites `libs.versions.toml`,
|
||||
`pyproject.toml`, AND `plugin/relay/__init__.py::__version__` in lockstep,
|
||||
increments `appVersionCode` monotonically, and runs a sanity check. Don't
|
||||
edit the files by hand; drift is silent and painful.
|
||||
Use `scripts/bump-android-version.sh`. It rewrites
|
||||
`gradle/libs.versions.toml`, increments `appVersionCode` monotonically,
|
||||
and runs a sanity check. Don't edit the Android version files by hand.
|
||||
|
||||
```bash
|
||||
bash scripts/bump-version.sh 0.3.0
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Confirm the bump:
|
||||
@@ -265,26 +372,75 @@ Confirm the bump:
|
||||
scripts\dev.bat version
|
||||
```
|
||||
|
||||
The script's diff output should show exactly three files changed and all
|
||||
three carrying the new version string.
|
||||
The script's diff output should show `gradle/libs.versions.toml` carrying
|
||||
the new app version and a higher `appVersionCode`.
|
||||
|
||||
### 2. Update release notes and changelog
|
||||
|
||||
> Each surface has its own GitHub-Release-body file, all in the same format
|
||||
> (Summary + Added/Changed/Fixed + Install/Verify): `RELEASE_NOTES.md` (Android),
|
||||
> `PLUGIN_RELEASE_NOTES.md` (plugin), `CLI_RELEASE_NOTES.md` (CLI). This step covers
|
||||
> the Android artifacts; the plugin/CLI files are filled in their own release
|
||||
> sections below but follow the identical scrub and Keep-a-Changelog grouping.
|
||||
|
||||
- `CHANGELOG.md` — promote the accumulated `[Unreleased]` block to a
|
||||
versioned header. The block already exists: every feature PR has
|
||||
been appending to it. All you do here is:
|
||||
1. Change the `## [Unreleased]` header to `## [X.Y.Z] - YYYY-MM-DD`.
|
||||
2. Insert a fresh empty `## [Unreleased]` header above it so the
|
||||
next PR has a landing spot.
|
||||
3. Skim the new versioned block and tighten / reorder if needed —
|
||||
Keep-a-Changelog grouping (`Added` / `Changed` / `Fixed`) should
|
||||
already be in place from the accumulator phase.
|
||||
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
|
||||
(rewritten each release; the workflow uses this as-is). Keep the
|
||||
(rewritten each release; the workflow uses this as-is). This is the
|
||||
operator-facing summary, not the CHANGELOG mirror. Keep the
|
||||
**Download** section near the top — it should spell out which file
|
||||
to grab by its `-sideload-release.apk` / `-googlePlay-release.aab`
|
||||
suffix (every artifact is version-tagged as
|
||||
`hermes-relay-<version>-<flavor>-<buildType>` via `archivesName`
|
||||
in `app/build.gradle.kts`) and link to the sideload guide.
|
||||
The v0.3.0 body is a good template.
|
||||
- `CHANGELOG.md` — cumulative history; append a new section.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
(v0.4.0 shipped with 0.1.0 content until caught post-release).
|
||||
- `docs/play-store-listing.md` — Play Store listing copy. Update
|
||||
the version reference and the "Release Notes" section that gets
|
||||
pasted into the Play Console "What's new" field. Keep the Play
|
||||
"What's new" within **500 characters** and framed around the
|
||||
release's themes, not a feature dump.
|
||||
|
||||
#### Scrub for public distribution
|
||||
|
||||
This is a **public repo** and these four files are user-facing. Before
|
||||
promoting the `[Unreleased]` block and writing the notes, scrub the
|
||||
versioned CHANGELOG block and all three release-notes artifacts for
|
||||
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
|
||||
dev-log voice during the iteration phase — release-prep is where it
|
||||
becomes public copy. Check for and remove/rewrite:
|
||||
|
||||
- **Personal names / quoted asides** — `git grep -niE "bailey|: \"" CHANGELOG.md`
|
||||
on the new block. Attribute fixes impersonally ("a user reported"),
|
||||
not by name. (Author identity already lives in git + the signing cert.)
|
||||
- **Private infrastructure** — server hostnames/IPs, `~/SYSTEM.md`,
|
||||
internal deployment names, anything that should stay in the operator's
|
||||
environment and not the repo. `grep -niE "192\.168|10\.0\.|hermes-host|SYSTEM\.md"`.
|
||||
(Example IPs like `192.168.1.100` in install docs are fine.)
|
||||
- **Fork / branch plumbing + internal nicknames** — references to private
|
||||
fork branches, rollout channels, or in-team incident nicknames read as
|
||||
internal. Keep the *what changed*, drop the *where we staged it*.
|
||||
- **Personal example data** — genericize sample profile/agent names to
|
||||
neutral placeholders so the copy doesn't expose a specific setup.
|
||||
|
||||
The goal is that someone who has never seen the repo can read the block
|
||||
and the release notes and learn only what the software does.
|
||||
|
||||
### 3. Build and verify locally
|
||||
|
||||
```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
|
||||
@@ -301,57 +457,122 @@ prefixed `hermes-relay-<version>-` via `archivesName` in
|
||||
Optional device smoke test: `scripts\dev.bat release` then
|
||||
`adb install -r app\build\outputs\apk\sideload\release\hermes-relay-*-sideload-release.apk`.
|
||||
|
||||
### 4. Commit and tag
|
||||
### 4. Commit on `dev`, merge to `main`, tag from `main`
|
||||
|
||||
The release-prep commit is one of the few allowed direct-to-main pushes
|
||||
(see Branching Policy above — branch protection exempts the
|
||||
`release: vX.Y.Z` pattern because tagging + bumping must be atomic):
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
|
||||
```bash
|
||||
git add gradle/libs.versions.toml pyproject.toml plugin/relay/__init__.py \
|
||||
RELEASE_NOTES.md CHANGELOG.md
|
||||
git commit -m "release: v0.3.0"
|
||||
git push origin main
|
||||
# From a clean dev checkout:
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
git tag v0.3.0
|
||||
git push origin v0.3.0
|
||||
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(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 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.
|
||||
|
||||
> **Why all three files in the commit?** See "The three version sources"
|
||||
> above — `bump-version.sh` rewrites them atomically, so they must be
|
||||
> staged + committed atomically too. Missing one creates the same drift
|
||||
> the script was built to prevent.
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin package itself is also being released.
|
||||
|
||||
### Plugin / Python package release
|
||||
|
||||
Use this when plugin or relay behavior changes independently of Android app
|
||||
delivery, for example CLI channel support, bridge routes, pairing server fixes,
|
||||
voice auth, dashboard plugin UI, or packaging changes.
|
||||
|
||||
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
|
||||
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
|
||||
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
|
||||
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
|
||||
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-plugin-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 PLUGIN_RELEASE_NOTES.md
|
||||
git commit -m "release(plugin): plugin-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 plugin-v0.6.2
|
||||
git push origin plugin-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
|
||||
validates all plugin-owned version metadata with
|
||||
`scripts/check-plugin-version-sync.py`. Run
|
||||
`python scripts/check-version-tracks.py` locally before tagging when a change
|
||||
touches more than one release surface. The workflow also runs plugin tests,
|
||||
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
|
||||
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
|
||||
**Manual upload (default):**
|
||||
> **If `PLAY_SERVICE_ACCOUNT_JSON` is configured as a repo secret, this step is
|
||||
> automated for stable tags.** The release workflow runs
|
||||
> `publishGooglePlayReleaseBundle --track=production` and the build appears as a
|
||||
> Production **draft** — skip to the Play Console, confirm the draft, and click
|
||||
> **Start rollout**. The manual path below is the fallback when the secret is
|
||||
> unset (or for staging on a non-production track).
|
||||
|
||||
**Pick the track first.** The AAB is track-agnostic — the same
|
||||
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
|
||||
not habit:
|
||||
|
||||
- **Production** — the default for a stable GA release (`android-vX.Y.Z`). The
|
||||
listing is live, so this is where real releases land. The org account is
|
||||
D-U-N-S-verified, so the 14-day / 12-tester closed-testing gate does **not**
|
||||
apply — you can publish straight to Production.
|
||||
- **Open / Closed testing** — only when you actually want a public/private beta
|
||||
channel for this build.
|
||||
- **Internal testing** — only for a throwaway pre-release smoke check (e.g. a
|
||||
prerelease tag), not for a GA. Don't default here.
|
||||
|
||||
**Manual upload:**
|
||||
|
||||
1. Download the file ending in `-googlePlay-release.aab` from the GitHub
|
||||
Release assets (for example, `hermes-relay-0.3.0-googlePlay-release.aab`),
|
||||
Release assets (for example, `hermes-relay-1.0.0-googlePlay-release.aab`),
|
||||
or use your local build at
|
||||
`app\build\outputs\bundle\googlePlayRelease\hermes-relay-<version>-googlePlay-release.aab`.
|
||||
2. In Play Console: **Release > Testing > Internal testing** (the 14-day
|
||||
closed-testing rule does NOT apply to this account — see "Google Play
|
||||
Console developer account" above).
|
||||
2. In Play Console, open the track you chose above — for a GA that's
|
||||
**Release > Production**.
|
||||
3. **Create new release** > upload the AAB.
|
||||
4. Paste `RELEASE_NOTES.md` into the release notes field.
|
||||
5. **Review release** > **Start rollout.**
|
||||
4. Paste the Play "What's new" from `docs/play-store-listing.md` (≤500 chars) into
|
||||
the release notes field. (`RELEASE_NOTES.md` is the GitHub-Release body, not the
|
||||
Play field — don't paste that; it's over the limit.)
|
||||
5. **Review release** > **Start rollout** (set the staged-rollout percentage if you
|
||||
want a gradual production ramp).
|
||||
|
||||
**Automated upload (if `play-service-account.json` is configured):**
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
gradlew publishReleaseBundle
|
||||
gradlew publishReleaseBundle --track=production
|
||||
```
|
||||
|
||||
Defaults to the `internal` track with `DRAFT` status (configured in the
|
||||
`play { }` block in `app/build.gradle.kts`). Override per-invocation with
|
||||
`--track=alpha` (= Closed testing), `--track=beta` (= Open testing), or
|
||||
`--track=production`.
|
||||
The `play { }` block in `app/build.gradle.kts` defaults to the `internal` track
|
||||
with `DRAFT` status as a safety net for unattended runs, so pass `--track` explicitly
|
||||
for a real release: `--track=production` (GA), or `--track=alpha` (Closed) /
|
||||
`--track=beta` (Open) for a beta channel.
|
||||
|
||||
To promote an existing release between tracks without rebuilding:
|
||||
|
||||
@@ -359,18 +580,24 @@ To promote an existing release between tracks without rebuilding:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
|
||||
```
|
||||
|
||||
### 6. Promote through tracks
|
||||
### 6. Tracks (a menu, not a mandatory ladder)
|
||||
|
||||
Typical path:
|
||||
The org account is exempt from the 14-day / 12-tester closed-testing rule, so a
|
||||
stable GA publishes **straight to Production** — there is no required promotion
|
||||
chain. The other tracks are opt-in tools, not steps you must climb:
|
||||
|
||||
1. **Internal testing** — personal smoke test (no tester or time minimum)
|
||||
2. **Closed testing (alpha)** — optional for staged rollout; Axiom-Labs'
|
||||
org account is exempt from the 14-day / 12-tester rule, so you can skip
|
||||
straight from Internal to Production if the build is ready
|
||||
3. **Open testing (beta)** — optional public beta
|
||||
4. **Production** — live on the Play Store
|
||||
- **Production** — live on the Play Store. Where GA releases go.
|
||||
- **Open testing (beta)** — opt-in public beta channel.
|
||||
- **Closed testing (alpha)** — opt-in private beta (named tester lists).
|
||||
- **Internal testing** — throwaway smoke check (e.g. a prerelease tag), no tester
|
||||
or time minimum.
|
||||
|
||||
Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
If you *do* stage through tracks, promote an existing release without rebuilding via
|
||||
the Play Console UI or:
|
||||
|
||||
```bat
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
```
|
||||
|
||||
### 7. After release
|
||||
|
||||
@@ -380,9 +607,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`.)
|
||||
@@ -391,23 +618,52 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
|
||||
## CI Behavior
|
||||
|
||||
On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
Android, Plugin, dashboard, and desktop now have separate CI/release lanes.
|
||||
This keeps a dashboard CSS fix from running the full server suite, and keeps
|
||||
plugin changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
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).
|
||||
2. Runs `./gradlew assembleDebug` and `./gradlew test`.
|
||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||
regression slice with explicit timeouts.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. Builds both artifacts: `./gradlew bundleRelease assembleRelease`.
|
||||
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.
|
||||
|
||||
## Required GitHub Secrets
|
||||
On every push of a tag matching `plugin-v*`,
|
||||
`.github/workflows/release-plugin.yml`:
|
||||
|
||||
1. Validates the tag matches all plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`.
|
||||
2. Runs plugin 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 `Hermes-Relay-Plugin v<version>` with the wheel,
|
||||
sdist, and checksum file attached.
|
||||
|
||||
On every push of a tag matching `cli-v*`,
|
||||
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
|
||||
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
|
||||
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
|
||||
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
|
||||
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
|
||||
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
|
||||
Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
runs the dashboard API tests, and verifies the modal CSS markers are present
|
||||
in the built bundle.
|
||||
|
||||
## Required Android Release Secrets
|
||||
|
||||
| Secret | Purpose | How to populate |
|
||||
|-----------------------------|-------------------------------------|--------------------------------------------------|
|
||||
@@ -415,30 +671,52 @@ On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
|
||||
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
|
||||
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
|
||||
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
|
||||
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
|
||||
tags only — prereleases are skipped). CI does the upload; you still click **Start
|
||||
rollout** in Play Console. If the secret is unset, the workflow skips the upload
|
||||
and you upload manually (§5) — nothing else changes.
|
||||
|
||||
## Hotfix Recipe
|
||||
|
||||
When production has a bug and you need to ship a fix without picking up
|
||||
unrelated `main` changes:
|
||||
unreleased work from `dev`, branch from the affected release tag and only
|
||||
bump the version source for the surface you are shipping.
|
||||
|
||||
1. `git checkout -b fix/short-name v0.1.0` — branch from the released tag.
|
||||
For an Android app hotfix:
|
||||
|
||||
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. Bump `appVersionName` and `appVersionCode` in
|
||||
3. Run `bash scripts/bump-android-version.sh 0.6.2` to update
|
||||
`gradle/libs.versions.toml`.
|
||||
4. Update `RELEASE_NOTES.md` and `CHANGELOG.md`.
|
||||
5. `git tag v0.1.1 && git push origin v0.1.1` — CI builds and publishes.
|
||||
6. Upload to Play Console as normal.
|
||||
7. Merge the hotfix branch back into `main` so the fix isn't lost.
|
||||
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 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`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, and tag `plugin-v<next-version>`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`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
|
||||
|
||||
+29
-144
@@ -1,170 +1,55 @@
|
||||
# Hermes-Relay v0.3.0
|
||||
# Hermes-Relay-Android v1.1.0
|
||||
|
||||
**Release Date:** April 13, 2026
|
||||
**Since v0.2.0:** 56 commits · 8 major feature merges · 2 new build flavors · 1 new agent-control channel
|
||||
**Release Date:** June 16, 2026
|
||||
**Since v1.0.0:** A settings + chat-UX overhaul — quieter status surfaces, a single state-aware plugin badge, and chat-settings polish — plus a force-close fix and release-pipeline upgrades.
|
||||
|
||||
> **Bridge channel.** The agent can now read your screen, tap, type, swipe, and take screenshots on your phone — gated behind a five-stage safety rails system, a master toggle, the Android Accessibility Service, MediaProjection consent, and per-channel session grants. Plus full voice-mode polish, a notification companion, and two new agent-introspection tools so the agent stops flying blind.
|
||||
v1.1.0 is a refinement release on top of the 1.0 milestone. Settings is calmer and easier to read: status pills now appear only when a surface needs attention, the Power tools section shows one **Plugin active / required / offline** badge instead of an identical chip on every card, and the most-used controls sit where you reach for them. Chat settings render correctly, the system-prompt preview reflects your toggles, and a crash that could hit right after a successful pair is gone.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Download
|
||||
## Download
|
||||
|
||||
v0.3.0 ships in **two build flavors**. APK filenames are version-tagged, so every file carries its release number:
|
||||
v1.1.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| **sideload** (recommended) | `hermes-relay-0.3.0-sideload-release.apk` | Full feature set — bridge channel, voice-to-bridge intents, vision-driven `android_navigate`. Installs alongside the Play Store build with a `.sideload` applicationId. |
|
||||
| **Google Play** | `hermes-relay-0.3.0-googlePlay-release.aab` | Uploaded to Play Console for Internal testing. Conservative feature set (chat, voice, safety rails — no agent device control) to match Play Store's Accessibility policy. |
|
||||
| googlePlay APK | `hermes-relay-0.3.0-googlePlay-release.apk` | Parity + diff tooling — not the primary download. |
|
||||
| sideload AAB | `hermes-relay-0.3.0-sideload-release.aab` | Parity + diff tooling — not the primary download. |
|
||||
| Google Play | `hermes-relay-1.1.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.1.0-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.1.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.1.0-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
**Verify integrity** with `SHA256SUMS.txt` from the same release before installing. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for the step-by-step install walkthrough.
|
||||
|
||||
> **Why two flavors?** The `googlePlay` build stays inside Play Store's Accessibility Service policy review. The `sideload` build unlocks the full agent-control feature set and installs with a `.sideload` applicationId suffix so both can coexist on the same device — the sideload launcher is labelled **"Hermes Dev"** for disambiguation. For a capability-by-capability breakdown, see the [Release tracks comparison](https://codename-11.github.io/hermes-relay/guide/release-tracks.html).
|
||||
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
|
||||
## Highlights
|
||||
|
||||
- **Bridge Channel** — The agent can read the phone's screen, tap, type, swipe, and take screenshots via a new `HermesAccessibilityService` + `MediaProjection` pipeline. Five independent safety gates must all be green before a single command executes: session grant → master toggle → Accessibility permission → MediaProjection consent → safety rails.
|
||||
### Settings screen overhaul
|
||||
|
||||
- **Voice Mode polish** — Full-screen voice UI with an ASCII morphing sphere (Listening = blue/purple, Speaking = green/teal), reactive layered-sine waveform visualizer, pill-edge merge, tap/hold/continuous interaction modes, and sentence-boundary streaming through a TTS queue. Backed by three new relay endpoints — `POST /voice/transcribe`, `POST /voice/synthesize`, `GET /voice/config` — with 6 TTS and 5 STT providers available via `~/.hermes/config.yaml`.
|
||||
Settings was reorganized around what you actually touch and quieted down everywhere else:
|
||||
|
||||
- **Voice-to-bridge intents** *(sideload only)* — Spoken commands like *"text Mom saying on my way"* route to the bridge channel with destructive-verb confirmation instead of falling through to chat. Regex-based intent classifier covers send-sms / open-app / tap / back / home (scroll removed in v0.4.0 — server-side android_scroll tool handles it instead).
|
||||
- **Exception-only status pills.** Status pills now appear only when a surface needs attention and stay quiet when everything is healthy — no more a wall of green chips to read past.
|
||||
- **One state-aware plugin badge.** The Power tools section shows a single **Plugin active / required / offline** badge instead of an identical "Relay paired" chip repeated on every card.
|
||||
- **Layout that follows your reach.** Connections moved to the top (above the Hermes section), and Diagnostics + Developer options moved into the App section.
|
||||
- **Restyled to match the app.** The status chips now use the app's translucent-bordered language, and the brand blue was deepened.
|
||||
|
||||
- **Notification Companion** — `HermesNotificationCompanion` (`NotificationListenerService`) forwards posted notifications to the relay over a new `notifications` channel so the agent can summarize them or act on them. Opt-in via the standard Android notification-access grant.
|
||||
### Chat settings polish
|
||||
|
||||
- **Agent introspection tools** — Two new Hermes tools close the "agent has no idea what permissions are granted" loop: `android_phone_status()` returns the full structured phone state (battery, screen, current app, bridge permission flags, safety state) via a new loopback-only `/bridge/status` relay endpoint, and `hermes-status` is a matching CLI shim for operators with three exit codes so shell scripts can tell connected / relay-down / no-phone apart.
|
||||
- **Streaming-endpoint picker fixed.** The picker no longer wraps "Gateway" / "Sessions" onto a second line.
|
||||
- **Live system-prompt preview.** The system-prompt preview now reflects the context toggles you've enabled (foreground app, battery, safety rails) with representative placeholder values, instead of looking inert.
|
||||
|
||||
- **Per-channel grant revoke** — Paired Devices screen now shows per-channel grant chips with relative TTL labels and an inline x icon. Tap the x → confirm → revoke just that channel. The full-session Revoke button still nukes the whole session.
|
||||
### Force-close fix
|
||||
|
||||
- **Manual pairing fallback** — For setups without a QR path (phone is the only camera / host is SSH-only): Settings → Connection → **Manual pairing code (fallback)** → copy the 6-char code → run `hermes-pair --register-code ABCD12 [--ttl 30d --grants chat:never,bridge:7d]` on the host → tap **Connect**.
|
||||
A corrupt encrypted token store — which can happen after an app upgrade or a device restore — used to throw during construction and crash the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset in place, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
|
||||
|
||||
- **Self-install skill for AI agents** — `/hermes-relay-self-setup` is a single-source agent-readable install recipe. Pre-install users paste the raw GitHub URL into any AI; post-install users get it as a slash command. The README has a copy-paste prompt block to hand off setup to Claude / GPT / any agent.
|
||||
### Release pipeline
|
||||
|
||||
- **Version-tagged release artifacts** — Every APK and AAB in this release is named `hermes-relay-<version>-<flavor>-<buildType>`, so `which-file-is-which` is obvious at a glance and multiple versions don't overwrite each other in your Downloads folder.
|
||||
- **Automated Play Console upload.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, releases publish to GitHub Releases exactly as before.
|
||||
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Bridge Channel
|
||||
## Upgrade notes
|
||||
|
||||
The headline feature. Everything in this section is gated behind the five-stage safety system documented above.
|
||||
|
||||
### Bridge Tab UI
|
||||
- **Master toggle card** — "Allow Agent Control" is the load-bearing user-facing gate
|
||||
- **Bridge status card** — live bridge-connected indicator (reuses `ConnectionStatusBadge`'s pulsing ring)
|
||||
- **Permission checklist** — Accessibility / Screen Capture / Overlay / Notification Listener rows with in-app **Test** buttons that fire single-command smoke tests instead of requiring the agent to be online
|
||||
- **Activity log** — tap-to-expand entries with timestamps, status, result text, and optional screenshot tokens (capped at 100 entries)
|
||||
- **Safety summary card** — live countdown to auto-disable, blocklist/verb counts at a glance
|
||||
|
||||
### Safety Rails
|
||||
- **App blocklist** — 30 default banking / payments / password-manager / 2FA apps pre-seeded; searchable `PackageManager.queryIntentActivities(CATEGORY_LAUNCHER)` picker for custom entries
|
||||
- **Destructive-verb confirmation modal** — word-boundary regex match against `/tap_text` + `/type` payloads. Default verbs: `send`, `pay`, `delete`, `transfer`, `confirm`, `submit`, `post`, `publish`, `buy`, `purchase`, `charge`, `withdraw`. Modal rendered via a `WindowManager` overlay so it's visible even when Hermes isn't in the foreground.
|
||||
- **Idle auto-disable** — 5-120 min slider. Any command resets the timer; process death clears state so a stale grant can't survive a crash.
|
||||
- **Optional persistent status overlay** — small floating "Hermes active" pill via `SYSTEM_ALERT_WINDOW`, gated behind the overlay-permission walk-through
|
||||
- **Confirmation timeout** — 10-60s slider; fails-closed if missing overlay permission
|
||||
|
||||
### AccessibilityService Pipeline
|
||||
- `HermesAccessibilityService` — `@Volatile` singleton so `BridgeCommandHandler` reaches the live service without DI
|
||||
- `ScreenReader` — UI tree → `ScreenContent(rootBounds, nodes[], truncated)` with node recycling and `MAX_NODES=512` cap
|
||||
- `ActionExecutor` — `tap`/`tapText`/`swipe`/`scroll` via `GestureDescription` wrapped in `suspendCancellableCoroutine` so suspend form actually waits for completion; `typeText` via `ACTION_SET_TEXT`; `pressKey` mapped to a curated string vocab (no raw KeyEvent codes)
|
||||
- `BridgeForegroundService` — persistent "Hermes has device control" notification with **Disable** + **Settings** action buttons; declared as `foregroundServiceType=specialUse|mediaProjection` per Android 14+ requirements
|
||||
|
||||
### Relay Bridge Server
|
||||
- 18 HTTP routes registered on `plugin/relay/server.py` — `/ping`, `/screen`, `/screenshot`, `/get_apps`, `/current_app`, `/tap`, `/tap_text`, `/type`, `/swipe`, `/open_app`, `/press_key`, `/scroll`, `/wait`, `/setup`, `/send_sms`, `/call`, `/search_contacts`, `/return_to_hermes` (last 4 added in v0.4.0)
|
||||
- Wire protocol migrated from the legacy standalone `plugin/tools/android_relay.py` (port 8766) into the unified relay on port 8767. Envelope fields match the legacy relay byte-for-byte.
|
||||
- 30s per-command timeout, fail-fast on phone disconnect so HTTP callers don't wedge
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ Voice Mode
|
||||
|
||||
- **Three interaction modes** — Tap-to-talk / Hold-to-talk / Continuous, configurable in Voice Settings
|
||||
- **Reactive layered-sine waveform visualizer** — three overlapping waves at co-prime frequencies (1.2 / 2.1 / 3.4) with amplitude-driven phase velocity, pill-edge merge via `BlendMode.DstIn` + geometric `sin(π·t)` taper
|
||||
- **Sphere voice states** — `SphereState.Listening` (soft blue/purple, subtle amplitude wobble) and `SphereState.Speaking` (vivid green/teal, dramatic core-warmth pulse, data ring spin up to 4× on peak)
|
||||
- **In-flow STT display** — "YOU"-labelled transcribed text lives between the waveform and the response area so the eye flow is one linear motion (mic → up → STT → response), not split top↔bottom
|
||||
- **Auto-scroll response** — text area follows new tokens via `LaunchedEffect(responseText.length) { scrollState.animateScrollTo(maxValue) }` with a fade-to-transparent gradient mask at top and bottom edges (`graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }` + `drawWithContent + BlendMode.DstIn`)
|
||||
- **Stop preserves history** — tapping Stop while the agent is speaking freezes the current response text on screen instead of clearing it; voice mode stays open
|
||||
- **Voice SFX chimes** — pre-synthesized 200 ms PCM sweeps (ascending 440→660 Hz enter, descending mirror exit) via `AudioTrack.MODE_STATIC` with `USAGE_ASSISTANT`
|
||||
- **Sentence-boundary TTS queue** — top-level `extractNextSentence(StringBuilder)` helper with whitespace-lookahead for abbreviations (`e.g.`, `i.e.`, `Dr.`, etc.), dedicated consumer coroutine that only triggers auto-resume when the queue is actually drained (waveform stays alive through multi-sentence playback)
|
||||
- **Attack-fast/release-slow envelope follower** — 0.75 / 0.10 at 60 Hz replaces the old Compose spring so the sphere and waveform respond instantly to speech onsets
|
||||
- **Voice test toasts** — three-toast lifecycle (Testing / Success / Failed: reason) with explicit `cancel()` between trigger and result so toasts don't stack
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Notifications & Agent Introspection
|
||||
|
||||
- **`HermesNotificationCompanion`** — `NotificationListenerService` subclass with the same opt-in flow as Wear OS / Android Auto / Tasker
|
||||
- **Cold-start buffer** — `pendingEnvelopes` queue capped at 50 entries preserves ordering when notifications fire before the multiplexer is wired
|
||||
- **In-memory bounded deque** on the relay side — `NotificationsChannel` holds the most recent 100 entries, wiped on relay restart by design (matches smartwatch-companion semantics)
|
||||
- **`android_notifications_recent(limit=20)`** — Hermes tool registered by `plugin/tools/android_notifications.py`, calls the loopback `GET /notifications/recent` endpoint
|
||||
- **Notification Companion settings screen** — status indicator, test notification dump (pulls `service.activeNotifications` directly for end-to-end verification without a relay round-trip), open-Android-settings button
|
||||
- **`android_phone_status()`** — new agent tool returning the full phone state via loopback `/bridge/status`
|
||||
- **`hermes-status`** — CLI shim with three exit codes for shell-scriptable bridge state queries
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security & Pairing
|
||||
|
||||
- **Android 14+ MediaProjection** — `foregroundServiceType=mediaProjection` added to `BridgeForegroundService` so the screen-capture grant survives backgrounding and Android 14+'s auto-revocation window (symptom prior to fix: consent dialog appears, user allows, dialog closes, grant evaporates within a frame)
|
||||
- **Master toggle gate fix** — `cachedMasterEnabled` was never written in v0.2.0, so the gate was no-op; service now owns the observer directly
|
||||
- **MediaProjection consent flow** — `MainActivity` hosts the `ActivityResultLauncher` + a process-singleton `MediaProjectionHolder` rendezvous for non-Activity callers
|
||||
- **Self-healing EncryptedSharedPreferences** — `KeystoreTokenStore` and `LegacyEncryptedPrefsTokenStore` catch `AEADBadTagException` on master-key rotation (happens automatically on every Android Studio reinstall) and rebuild the prefs file instead of leaving the user permanently unable to decrypt their session token
|
||||
- **Strict-mode sandbox opt-in** — `RELAY_MEDIA_STRICT_SANDBOX=1` re-enables the allowlist enforcement on `/media/by-path`; permissive-by-default since 2026-04-11 (path-token route still always enforces sandbox)
|
||||
- **Per-channel grant revoke API** — `PATCH /sessions/{token_prefix}` restarts the clock from now; `DELETE /sessions/{token_prefix}` matches on first-4+ chars, 200 exact / 404 zero / 409 ambiguous, self-revoke flagged via `revoked_self: true`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Installer & Developer Workflow
|
||||
|
||||
- **`install.sh` TUI pass** — ANSI colors, boxed banner, unicode step bullets, spinner for the long pip install step, structured closing message
|
||||
- **`install.sh` restart actually restarts** — fixed the subtle bug where `enable --now` on an already-active systemd service was a no-op (the source of the 2026-04-12 "install ran clean but relay is still on stale code" debug session)
|
||||
- **Optional `hermes-gateway` restart prompt** — gates on TTY, respects `HERMES_RELAY_RESTART_GATEWAY=1` for non-interactive runs so it's automation-safe
|
||||
- **`hermes-relay-update` shim** — two-line wrapper around the canonical curl pipe, re-fetches the latest `install.sh` on every invocation so improvements to the installer itself take effect immediately
|
||||
- **Three version sources in lockstep** — `scripts/bump-version.sh` atomically bumps `gradle/libs.versions.toml`, `pyproject.toml`, and `plugin/relay/__init__.py::__version__` with SemVer validation and monotonic `appVersionCode` enforcement
|
||||
- **Branch protection on `main`** — direct push blocked except for the `release: vX.Y.Z` pattern, PR must pass CI before merge, force push + branch deletion blocked
|
||||
- **Feature branches + `--no-ff` merges** — direct-to-main reserved for single-file typos; agent-team branches get per-commit traces in `git log --graph`
|
||||
- **`base { archivesName }` in `app/build.gradle.kts`** — injects the app version into every APK/AAB filename so release artifacts are self-identifying at every stage of the pipeline
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
- **Fix: Android 14 MediaProjection grant evaporation** — missing `foregroundServiceType=mediaProjection` declaration
|
||||
- **Fix: master toggle gate broken end-to-end** — `cachedMasterEnabled` never written
|
||||
- **Fix: re-pair required after every Android Studio rebuild** — self-heal corrupted `EncryptedSharedPreferences` on master-key rotation
|
||||
- **Fix: voice response text cleared on Stop** — `interruptSpeaking()` was wiping `responseText`; now freezes on-screen
|
||||
- **Fix: auto-scroll didn't follow streaming tokens** — added `LaunchedEffect(length)` driver
|
||||
- **Fix: STT bubble split user gaze top↔bottom** — moved to in-flow position between waveform and response
|
||||
- **Fix: sphere too small in voice mode** — Compose `weight()` divides *remaining* space; bumped sphere weight to 1.5f vs response 1f for ~60% share
|
||||
- **Fix: `install.sh` restart was a no-op on already-active services** — explicit `systemctl --user restart` detection
|
||||
- **Fix: flavored APK upload paths in CI** — `apk/*/debug/*.apk` glob matches both `googlePlay` and `sideload` flavor directories
|
||||
- **Fix: `BIND_ACCESSIBILITY_SERVICE` as `uses-permission`** — lint `ProtectedPermissions` violation; already declared correctly on the `<service>` tag
|
||||
- **Fix: `AutoDisableWorker.notify()` lint `MissingPermission`** — suppression with documented helper gate
|
||||
- **Fix: stray pairing rate-limit blocks surviving relay restart** — `/pairing/register` now clears all blocks on success
|
||||
- **Fix: `MissingFeature` newline not treated as sentence boundary in voice TTS chunker**
|
||||
- **Fix: `ChevronRight` icon missing** — reverted to `AutoMirrored.Filled.ChevronRight`
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation & Skills
|
||||
|
||||
- **Installer README + DEVLOG update** — canonical update cycle documented top-to-bottom
|
||||
- **`docs/spec.md` + `docs/decisions.md`** — bridge pipeline, safety rails architecture, two-flavor rationale
|
||||
- **`/hermes-relay-self-setup` skill** — single-source agent-readable install recipe (dual-mode: pre-install via raw URL, post-install via slash command)
|
||||
- **`/hermes-relay-pair` skill** — canonical category layout (`devops`), matches `metadata.hermes.category` frontmatter
|
||||
- **`user-docs` flavor comparison** — "Which build should I pick?" decision guide on the Release Tracks page
|
||||
- **Android Studio dev loop** — Bailey's testing convention documented in CLAUDE.md so future sessions don't try to `adb install` from the tool side
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Primary development by **@Codename-11** (Bailey Dixon). Implementation assisted by Claude Code on isolated feature branches with `--no-ff` merges to preserve the per-component history.
|
||||
|
||||
Dependency bumps via Dependabot: markdown-renderer, gradle-wrapper, haze, camera, kotlinx-coroutines-test.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v0.2.0...v0.3.0](https://github.com/Codename-11/hermes-relay/compare/v0.2.0...v0.3.0)
|
||||
**See also**: [RELEASE.md](https://github.com/Codename-11/hermes-relay/blob/main/RELEASE.md) for the release recipe, [CHANGELOG.md](https://github.com/Codename-11/hermes-relay/blob/main/CHANGELOG.md) for cumulative history.
|
||||
- The force-close fix means devices that previously crashed on connect after an upgrade or restore will heal their token store automatically on first launch of this build — no manual re-pair required in most cases.
|
||||
- `appVersionCode` is **13**.
|
||||
|
||||
+56
-10
@@ -12,6 +12,52 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
|
||||
- **v0.2.0** — Voice mode foundation, terminal preview, TOFU cert pinning, Paired Devices screen. [CHANGELOG](CHANGELOG.md)
|
||||
- **v0.1.0** — Chat, sessions, QR pairing, encrypted storage, Play Store submission.
|
||||
|
||||
### Desktop track (parallel lane to Android) — **experimental**
|
||||
|
||||
Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `plugin-v*`). Historical alpha prereleases used `desktop-v*`, and the installer/updater keep a migration fallback. Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-cli.yml`](.github/workflows/release-cli.yml).
|
||||
|
||||
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
|
||||
|
||||
- **`@hermes-relay/cli` v0.1** — Node thin-client at [`desktop/`](desktop/). Remote chat + pair + status + tools subcommands over the relay's `tui` WSS channel. Shares `~/.hermes/remote-sessions.json` with the Android client (pair once, both work).
|
||||
- **v0.2 — resilience + pairing UX** — multi-endpoint pairing (ADR 24: `--pair-qr` probes LAN/Tailscale/Public, strict-priority within-tier race, 4s timeout, 60s cache), reconnect-on-drop state machine (1s→30s exp backoff, 5min on 429, gate re-check post-sleep), TOFU cert pinning via pre-WS TLS probe (SPKI sha256, `sha256/<base64>` OkHttp-compatible).
|
||||
- **v0.2 — UX polish** — bare `hermes-relay` → `shell` (full Hermes CLI over PTY with `clear; exec hermes` after tmux settles); contextual connect banner (`Connected via LAN (plain) — server 0.6.0`); `status` surfaces grants + TTL + endpoint role from `auth.ok`; new `devices` subcommand talking to relay `GET/DELETE/PATCH /sessions` over HTTP.
|
||||
- **Phase B — client-side tool routing** — server-side `plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` register `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` via `tools.registry` (mirror of `android_*` pattern — **zero hermes-agent core change**). Client-side `DesktopToolRouter` attaches to the `desktop` channel, dispatches under a 30s AbortController, heartbeats `desktop.status` every 30s. One-time per-URL consent gate + `--no-tools` kill-switch.
|
||||
- **`hermes-relay daemon`** — headless WSS + tool router that keeps desktop tools serving without a visible shell. Fails closed on missing stored consent (`--allow-tools` escape hatch with an explicit `--token`). JSON-line logs by default, auto-human on TTY. Inherits transport's reconnect state machine; `setImmediate(exit)` to flush final log line before process dies.
|
||||
- **Pre-release hardening** — `hermes-relay doctor` (local diagnostic report, human + `--json`, no token leakage); `uninstall.{sh,ps1}` (3-tier: default keeps session store, `--purge` wipes it with cross-surface warning, `--service` stub); interactive first-run prompts (`resolveFirstRunUrl` — auto-picks single stored session, numbered picker for multiple, welcome banner for fresh install); version-aware install (`upgrading X → Y` readback pre-install, post-install confirmation).
|
||||
- **Self-setup skill** — [`skills/devops/hermes-relay-desktop-setup/SKILL.md`](skills/devops/hermes-relay-desktop-setup/SKILL.md) lets any Hermes agent install, pair, and troubleshoot the CLI with **live local diagnostics** via `desktop_terminal` (can read the user's Node version, PATH, binary location directly — something the Android setup skill can't match).
|
||||
|
||||
**Shipped — `desktop-v0.3.0-alpha.6` (seamless-local dev pass, done 2026-04-23):** Plan at [`docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md`](docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md). Nine features across six parallel agent workstreams, all opt-in: workspace-awareness envelope + active-editor signal (#1+#8), `hermes-relay update` self-update subcommand (#2), `desktop_open_in_editor` tool + interactive patch approval with unified-diff rendering (#3+#4), conversation picker on connect (#5), clipboard bridge + screenshot handlers (#9+#12), and a `hermes` alias so muscle-memory works without the `-relay` suffix (#13). Integration day: 2026-04-23.
|
||||
|
||||
**Active — `desktop-v0.3.0-alpha.7` (native image paste):** Plan at [`docs/plans/2026-04-23-desktop-alpha-7-native-paste.md`](docs/plans/2026-04-23-desktop-alpha-7-native-paste.md). Two-repo workstream: client slash commands `/paste` (clipboard), `/screenshot` (primary display), `/image <path>` (file) land in `hermes-relay chat`, each echoes a one-line feedback and attaches the image to the next `prompt.submit` so the vision-capable model sees it in the same turn — parity with Claude Desktop's paste UX minus OS-level Ctrl+V (terminals don't pipe image bytes to stdin). Client half is new `desktop/src/chatAttach.ts` + slash-command branches in `desktop/src/commands/chat.ts`. Server half is ONE new `@method("image.attach.bytes")` on the fork's `tui_gateway/server.py` (branch `feat/image-attach-bytes` → merged to `axiom`); the fork's existing `_enrich_with_attached_images` already handles multimodal payload plumbing and session-scoped image state, so this release is almost entirely about bridging client-captured bytes to server-side state that's been there for months. Relay channel unchanged — `tui` is a transparent RPC forwarder. Graceful fallback when hermes-host hasn't been updated yet: client catches `method not found`, prints a pointer at the axiom rollout, REPL stays alive.
|
||||
|
||||
**Active — desktop control / computer-use:** Enhanced plan at [`docs/plans/desktop-control-computer-use-enhanced.md`](docs/plans/desktop-control-computer-use-enhanced.md); earlier MVP implementation record at [`docs/plans/desktop-computer-use-mvp.md`](docs/plans/desktop-computer-use-mvp.md). Windows now has the first Tauri tray/overlay app as the primary Easy/Standard install surface: pair, start/pause daemon, Devices/Revoke, Task Log, Settings, overlay status chip, emergency stop, and bundled CLI sidecar. The existing CLI and daemon remain the primary advanced/headless surface. `desktop_computer_*` schemas are registered on the normal desktop tool channel but advertised only behind the explicit experimental computer-use flag. Host input still requires desktop-tool consent plus a visible, task-scoped assist/control grant; there is no unrestricted or silent mouse/keyboard automation.
|
||||
|
||||
**Desktop control UX direction:** Tauri v2 (Rust + static web UI) is the native shell for the polished Easy-tier experience: tray icon, always-visible overlay chip, task log, settings, and one-click pause/emergency stop. Easy tier pairs once, shows a connected/observing chip, and exposes Devices / Revoke / Task Log / Settings / Emergency Stop from the tray. Standard tier adds full tray management; Advanced tier remains CLI + daemon + JSON policy (`~/.hermes/desktop-control.json`) for operators. The default policy baseline blocks password managers, credential prompts, banking/payment/crypto surfaces, OS security/admin settings, and private-key/token material until locally overridden.
|
||||
|
||||
**Deferred to alpha.8 / alpha.9 / v1.0:**
|
||||
|
||||
- **Per-project session stickiness** — blocked on hermes-agent plugin hook that consumes the workspace envelope; premature until the envelope shape stabilizes in use.
|
||||
- **Shell-history context hook** — needs rc-file-edit install path, which our install philosophy currently avoids. Design pass required.
|
||||
- **Desktop notifications for long-running daemon work** — let daemon bake in real-world use first; latency/idle-detection thresholds best tuned with telemetry.
|
||||
- **Environment-variable passthrough** — security-sensitive; needs per-var prompt UX + threat model before shipping.
|
||||
- **Global hotkey to summon a prompt** — OS-specific helper installers; out of scope for binary-only release.
|
||||
- **Watch mode** (`hermes-relay daemon --watch`) — needs a DSL and clear safety bounds; own feature branch.
|
||||
- **Native assist/control grant modal hardening** — the tray-managed daemon now has a local grant bridge and Grant Requests view. Next pass should polish native modal behavior, notification routing, and multi-client grant ownership.
|
||||
- **Kitty / iTerm2 inline image protocols for paste feedback** — would show a thumbnail of the attached image directly in the terminal after `/paste` instead of a plain text line. Most terminals don't support them; the slash-command feedback line works anywhere. Revisit if users request it.
|
||||
|
||||
**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 self-update path polls the GitHub Releases API, prefers `cli-v*`, falls back to historical `desktop-v*` prereleases during migration, compares to `readVersion()`, and 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.
|
||||
- **Harden `release-cli.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
|
||||
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings. Prerequisite for the auto-update path.
|
||||
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
|
||||
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
|
||||
|
||||
**Docs + references:** user-docs `/desktop/` section (Overview → Installation → Pairing → Subcommands → Local tool routing → Troubleshooting → FAQ) with an `<ExperimentalBadge />` Vue component on every page. README.md landing has a dedicated "Experimental: Desktop CLI" section with the install one-liners.
|
||||
|
||||
## Current — Axiom-Labs migration
|
||||
|
||||
Moving the Play Store listing from a personal account to the DUNS-verified Axiom-Labs LLC org account. Unblocks straight-to-production rollout (no 14-day closed-testing requirement). New applicationId `com.axiomlabs.hermesrelay`; keystore identity + SHA256 fingerprint preserved. In progress — waiting on Google DUNS verification.
|
||||
@@ -38,22 +84,18 @@ Expands the bridge channel's tool surface substantially, ports reliability patte
|
||||
|
||||
Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surface focused.
|
||||
|
||||
**Unattended access mode** *(sideload-only).* Opt-in toggle on the Bridge tab that acquires `FULL_WAKE_LOCK + ACQUIRE_CAUSES_WAKEUP`, raises `SCREEN_OFF_TIMEOUT` to max while active, and requests `KeyguardManager.requestDismissKeyguard()` so the agent can drive the device while the user is away. Hard-bounded by the existing bridge auto-disable timer, fronted by a persistent foreground-service notification + status-overlay chip, and gated behind a scary opt-in dialog that explains the security model. **Documented hard limit:** Android does not let third-party apps dismiss credential locks (PIN / pattern / biometric) — the user has to set their lock screen to **None** or **Swipe** themselves for the wake to land them past the keyguard. Without that, the screen wakes but stays on the lock screen, and the bridge gracefully reports `keyguard_blocked`. Auto-revokes when the pairing token expires or the device leaves WiFi (failsafe).
|
||||
**Unattended access mode** *(sideload-only).* ~~Opt-in toggle on the Bridge tab that acquires `FULL_WAKE_LOCK + ACQUIRE_CAUSES_WAKEUP`, raises `SCREEN_OFF_TIMEOUT` to max while active, and requests `KeyguardManager.requestDismissKeyguard()` so the agent can drive the device while the user is away.~~ **SHIPPED in v0.4.1** — see [`CHANGELOG.md`](CHANGELOG.md#041---unreleased). Final shape: opt-in toggle on the Bridge tab (sideload-only) that acquires `SCREEN_BRIGHT_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP | ON_AFTER_RELEASE` per bridge action, calls `KeyguardManager.requestDismissKeyguard()` via the registered MainActivity host, and reports `keyguard_blocked` (HTTP 423) when a credential lock blocks the action. Hard-bounded by the existing bridge auto-disable timer; persistent foreground-service notification + amber "Unattended ON" status-overlay chip stay visible while active; first-enable shows a scary dialog explaining the security model and credential-lock limitation. The original spec mentioned a WiFi-disconnect failsafe — rejected during implementation because Tailscale / VPN invalidates the "leaving WiFi = leaving LAN" assumption; the existing relay-disconnect detection (master toggle drops on disconnect → `UnattendedAccessManager.release()`) plus the auto-disable timer cover that surface.
|
||||
|
||||
**Voice intent local dispatch loop.** The v0.4 voice intent handler builds `bridge.command` envelopes and routes them through the `ChannelMultiplexer` → WSS → relay → back-to-phone path, which the relay correctly rejects with `ignoring unexpected bridge.command from phone` (the wire protocol is server→phone only by design). Voice intents are phone-local, so the dispatch should be local: extend `BridgeCommandHandler` with a `handleLocalCommand(envelope)` entry point that runs the existing `when(path)` dispatch + the full Tier 5 safety check pipeline (blocklist → destructive verb modal → action executor) in-process, and have `RealVoiceBridgeIntentHandler.dispatch()` call it instead of `multiplexer.send()`. Single source of truth for "bridge command → action" preserved; safety modals still fire for destructive verbs; no WSS round-trip for an action that's happening on the same device. Caught by Bailey's on-device test 2026-04-14 after the multiplexer-wiring fix unblocked the dispatch path.
|
||||
|
||||
**Tiered permission checklist with JIT permission errors** *(sideload-only sections gated on `BuildFlavor.SIDELOAD`).* Extend `BridgePermissionChecklist.kt` from the current 4-row design to a tiered surface with explicit grant affordances for every dangerous permission Hermes-Relay actually declares:
|
||||
**~~Tiered permission checklist with JIT permission errors~~ — shipped on `feature/tiered-permissions` (v0.4.1).** See [CHANGELOG.md](CHANGELOG.md) under `[Unreleased] → v0.4.1 Bridge fast-follows` for the landed surface. Original scope:
|
||||
|
||||
- **Core bridge** (both flavors) — Accessibility, Screen Capture, Overlay, **Notifications (Android 13+, currently missing)**
|
||||
- **Notification companion** (both flavors, optional) — Notification Listener
|
||||
- **Voice & camera** (both flavors) — Microphone, Camera
|
||||
- **Sideload features** (sideload-only, optional) — Contacts, SMS, Phone, Location
|
||||
- Tiered checklist with sideload-only sections gated on `BuildFlavor.SIDELOAD` (Core bridge / Notification companion / Voice & camera / Sideload features), Optional pills, runtime-permission launchers, ON_RESUME re-probes — done.
|
||||
- JIT permission-denied surfacing — bridge tool error envelope carries canonical `code` + `permission` aliases, Python `ResolveResult` types in `plugin/tools/resolve_result.py`, agent-tool wrappers upgrade `permission_denied` responses to structured LLM-readable envelopes, voice-mode JIT chip deep-links to `Settings.ACTION_APPLICATION_DETAILS_SETTINGS` for the running package — done.
|
||||
|
||||
Each row uses `rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission)` for runtime grants and `ACTION_APPLICATION_DETAILS_SETTINGS` deep-links for special perms. Status re-probes on `Lifecycle.Event.ON_RESUME` (same pattern as the existing 4 rows). Optional perms get an "Optional" badge so users don't feel pressured to grant the full set.
|
||||
**Voice intent → server session sync.** ✅ **Shipped 2026-04-16** — see [CHANGELOG `[Unreleased]`](CHANGELOG.md#unreleased) for the implementation. Picked option (d) (not in the original menu): synthesize OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from local voice-intent traces and pass them under a new `messages` field on the existing `/v1/runs` and `/api/sessions/{id}/chat/stream` payloads. LLMs are trained on this exact shape so they read it as natural conversation history rather than a system-prompt side note (lower retry risk than option (b)). Zero server changes (option (a) avoided), no double-dispatch (option (c) avoided). Idempotency via a `syncedToServer` flag on each trace.
|
||||
|
||||
**JIT permission-denied surfacing.** When a tool fails because of a missing runtime permission (e.g., `resolveContactPhone` returns null because `READ_CONTACTS` is denied), the failure path returns a structured `permission_denied` error code instead of generic "I couldn't find...". The voice flow speaks **"I need Contacts permission to look up Sam — open Settings to grant"** with a tap-target that deep-links to the app's permission page. The chat flow surfaces the same structured error to the agent so the LLM can recommend the fix in plain language instead of hallucinating about why the tool failed. Requires (a) splitting the resolver return type into a `sealed class ResolveResult<T> { Found / NotFound / PermissionDenied(perm, reason) }`, (b) adding a `code` field to bridge tool error envelopes, (c) the agent tool wrapper interpreting `code: permission_denied` and feeding it back to the LLM with context.
|
||||
|
||||
**Voice intent → server session sync.** Voice intents currently dispatch in-process (good for latency) and append a **local-only** trace to chat history (good for visual continuity), but the server-side session never sees them — so the gateway LLM has no memory of prior voice actions when the user follows up via text or voice. Symptom: user says "open Chrome" via voice (works), then says "did that work?" → LLM responds "I have no prior context for what you're asking about". Fix needs one of: (a) a new gateway endpoint `POST /api/sessions/{id}/messages` that injects a message into the session log without firing an LLM completion (cheap, server-side change in hermes-agent), (b) a fait-accompli prompt prefix where the next chat message includes a synthetic system note `[Earlier in this session, the user used voice intent to: open Chrome]` (no server change, prompt-side only), or (c) routing voice intents through `chatVm.sendMessage()` in addition to the local dispatch with an idempotency token so the LLM's server-side bridge tools don't double-fire. Pick one after measuring the LLM's tendency to retry actions when given fait-accompli context. Caught by Bailey's on-device test 2026-04-14: "The chat is resetting on voice or with our tools?" — actually voice intents bypass chat entirely, but the user-visible effect is the same.
|
||||
**Original problem statement (preserved for context):** Voice intents currently dispatch in-process (good for latency) and append a **local-only** trace to chat history (good for visual continuity), but the server-side session never sees them — so the gateway LLM has no memory of prior voice actions when the user follows up via text or voice. Symptom: user says "open Chrome" via voice (works), then says "did that work?" → LLM responds "I have no prior context for what you're asking about". Caught by Bailey's on-device test 2026-04-14: "The chat is resetting on voice or with our tools?" — actually voice intents bypass chat entirely, but the user-visible effect is the same.
|
||||
|
||||
**Gateway slash-command preprocessor — bootstrap middleware (Option B scope).** Built-in Hermes slash commands (`/model`, `/new`, `/retry`, the 29 in `hermes_cli/commands.py::GATEWAY_KNOWN_COMMANDS`) are intercepted by in-process platform adapters like Discord, Telegram, Slack, etc. — all of which route inbound `MessageEvent`s through `GatewayRouter._handle_message` at `gateway/run.py:2645–2929`, where a ~300-line dispatch chain mutates router-owned state (`_session_model_overrides`, `_agent_cache`). But `APIServerAdapter` **does not connect to the router** — it's intentionally excluded from the router notification path (see comment at `run.py:3148`), calls `_run_agent` directly, and **creates a fresh agent per request with no persistent session state**. This is the design intent of the OpenAI-compatible endpoint, not an oversight. The practical effect: on `POST /v1/runs` and `POST /v1/chat/completions`, slash commands pass through to the LLM verbatim; the LLM hallucinates a plausible-sounding but wrong reply ("`/model` is a client-side command"); the user is confused. Caught by Bailey on 2026-04-15 during a Hermes chat test from the Android app.
|
||||
|
||||
@@ -73,6 +115,10 @@ Each row uses `rememberLauncherForActivityResult(ActivityResultContracts.Request
|
||||
|
||||
Shape subject to change. Each theme needs a separate design + plan pass before implementation; file design notes as research matures.
|
||||
|
||||
### Desktop thin-client — Phase B (client-side tool routing)
|
||||
|
||||
v0.1 ships a remote-chat CLI. Phase B is the bigger win: **per-tool dispatch routing** so file/terminal/browser tools run against the user's machine while state tools (memory, skills, sessions, cron) stay on the server. Design detailed in the vault under `Axiom-Vault/3. System/Projects/Hermes-Relay/Desktop Client.md`. Key insertion point is hermes-agent `model_tools.py::handle_function_call()` (~line 517) — before `registry.dispatch()`, consult a session-scoped routing table populated by a relay handshake extension where the client advertises which tools it can service. Isomorphic to how `android_*` tools already flow through the `bridge.command` channel. Proposed branch: `fork/tool-relay` on the hermes-agent fork; upstream issue to open before merging. Blocked on: (a) the handshake extension in `plugin/relay/auth.py` to carry the advertised-tools list, (b) a new `desktop.command` channel mirroring `bridge.command` semantics, (c) the upstream PR conversation.
|
||||
|
||||
### Observability & introspection
|
||||
- Real-time accessibility event streaming for reactive workflows (`android_events`, `android_event_stream`)
|
||||
- On-device text-to-speech through the phone's system speaker for hands-free responses (distinct from the in-app voice mode)
|
||||
|
||||
@@ -6,6 +6,63 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Hands-free agentic voice backlog
|
||||
|
||||
Goal: make Hermes usable for hands-free work without leaving the operator blind
|
||||
to tool state, safety prompts, or the current task.
|
||||
|
||||
- **Waveform output-start sync** — current input waveform timing feels good, but
|
||||
the agent-output waveform can unfold and begin movement before audible speech
|
||||
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
|
||||
gate the unfolded Speaking waveform on the first real playback frame/audio
|
||||
amplitude. Processing can stay as the folded circular spinner until output is
|
||||
actually audible.
|
||||
- **Voice command layer** — reserve local commands that bypass normal agent
|
||||
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
|
||||
overlay", "return to Hermes", and "new chat". These should work while the
|
||||
agent is thinking, speaking, or using tools.
|
||||
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
|
||||
short status updates such as "I'm checking the relay logs" or "I found an
|
||||
error" without waiting for final assistant text. Long tool calls should emit
|
||||
periodic, low-noise progress updates.
|
||||
- **Realtime tool timeline parity** — the voice overlay should render the same
|
||||
live thinking blocks, streaming assistant text, and tool call progress as the
|
||||
normal chat surface without requiring exit/reload.
|
||||
- **Hands-free confirmation flow** — risky actions need first-class spoken and
|
||||
visual confirmation: "yes", "no", "cancel", "confirm", plus a visible and
|
||||
audible countdown for destructive actions.
|
||||
- **Voice session memory/status** — add a compact "where are we?" summary for
|
||||
the current voice task: active objective, last tool result, pending next step,
|
||||
and whether the agent is waiting on the user.
|
||||
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
|
||||
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
|
||||
spoken tool progress, confirmations, and overlay availability.
|
||||
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
|
||||
is solved. The target path is proper AEC, playback-ducking, and a rule that
|
||||
output audio can never become a user turn.
|
||||
- **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,
|
||||
and quality/latency evaluation before promotion beyond Experimental.
|
||||
- **Realtime-native Hermes bridge prototype** — first relay-brokered slice
|
||||
implemented in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
Remaining work: let OpenAI/xAI realtime sessions own more of the live speech
|
||||
turn while still proxying every tool, confirmation, memory, and Android bridge
|
||||
action through Hermes/relay safety.
|
||||
|
||||
---
|
||||
|
||||
## Research / open questions
|
||||
|
||||
### Proper Hermes plugin / skill / tool distribution
|
||||
|
||||
+61
-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
|
||||
@@ -109,6 +106,19 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Structural guard: the sideload flavor is distributed via GitHub Releases /
|
||||
// F-Droid / ADB and must NEVER be uploaded to Play Console (it declares the
|
||||
// unattended Device Control surface Play forbids). gradle-play-publisher
|
||||
// generates a publish task per variant, so the aggregate `publishReleaseBundle`
|
||||
// would otherwise try BOTH flavors. Disabling sideload here means only
|
||||
// `publishGooglePlayReleaseBundle` can ever reach Play — see the `play { }`
|
||||
// block below and .github/workflows/release-android.yml.
|
||||
playConfigs {
|
||||
register("sideload") {
|
||||
enabled.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("boolean", "DEV_MODE", "true")
|
||||
@@ -153,6 +163,23 @@ android {
|
||||
kotlin.srcDirs("src/androidTest/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
// JVM unit tests run against the stubbed Android SDK jar, where every
|
||||
// platform API method throws RuntimeException("... not mocked") by
|
||||
// default. With returnDefaultValues = true, those stubs instead
|
||||
// return the Java defaults (0 / null / false / empty). This unblocks
|
||||
// tests that exercise production code calling android.util.Log (which
|
||||
// UnattendedAccessManager does defensively in catch blocks) without
|
||||
// needing every test to mockkStatic(Log::class). Regression discovered
|
||||
// when v0.5.0 CI release caught UnattendedAccessManagerTest's
|
||||
// acquireForAction_uninitialized + refreshKeyguardState_threwException
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Google Play Publisher — optional automated upload to Play Console.
|
||||
@@ -193,6 +220,7 @@ dependencies {
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.process)
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
@@ -204,10 +232,21 @@ dependencies {
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.sse)
|
||||
|
||||
// Media3 ExoPlayer — gapless TTS queue playback (replaces MediaPlayer in VoicePlayer)
|
||||
implementation(libs.media3.exoplayer)
|
||||
|
||||
// android-vad Silero — on-device VAD for barge-in (B2)
|
||||
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
|
||||
implementation(libs.android.vad.silero)
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
|
||||
// Coil 3 — async image loading for generated images in chat
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// QR Code scanning (ML Kit + CameraX)
|
||||
implementation(libs.mlkit.barcode)
|
||||
implementation(libs.camera.core)
|
||||
@@ -237,8 +276,13 @@ 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
|
||||
// across priority groups against real local sockets so the behavior we
|
||||
// validate matches on-device.
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
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.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the v0.4.1 [BridgeMasterToggle] polish: tapping
|
||||
* the Switch to ON while accessibility hasn't been granted must route
|
||||
* through the new [BridgeMasterToggle.onAccessibilityNeeded] callback
|
||||
* instead of silently flipping the toggle on.
|
||||
*
|
||||
* The Switch is intentionally *not* `enabled = false` when accessibility
|
||||
* is missing — a disabled Switch swallows taps silently on Android, which
|
||||
* reads as a broken control to users. Instead the onCheckedChange handler
|
||||
* short-circuits through onAccessibilityNeeded so BridgeScreen can surface
|
||||
* an "Open Settings" snackbar.
|
||||
*/
|
||||
class BridgeMasterToggleTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun switch_whenAccessibilityMissing_togglingOn_callsOnAccessibilityNeeded() {
|
||||
var toggleCalls = 0
|
||||
var toggleLastValue: Boolean? = null
|
||||
var needsCalls = 0
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeMasterToggle(
|
||||
enabled = false,
|
||||
status = null,
|
||||
accessibilityGranted = false,
|
||||
onToggle = { wantsOn ->
|
||||
toggleCalls++
|
||||
toggleLastValue = wantsOn
|
||||
},
|
||||
onAccessibilityNeeded = { needsCalls++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The Switch is the only toggleable node in this composable — find it
|
||||
// without relying on an accessibility label (the design spec doesn't
|
||||
// currently give the Switch one) to avoid flakiness if the label
|
||||
// changes.
|
||||
composeTestRule.onNode(isToggleable()).assertIsOff()
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"onAccessibilityNeeded should fire exactly once when user taps " +
|
||||
"to enable without accessibility permission",
|
||||
1,
|
||||
needsCalls,
|
||||
)
|
||||
assertEquals(
|
||||
"onToggle must NOT fire on the blocked-enable path — otherwise " +
|
||||
"callers would see a phantom enable event even though a11y " +
|
||||
"isn't granted",
|
||||
0,
|
||||
toggleCalls,
|
||||
)
|
||||
assertEquals(
|
||||
"sanity: onToggle lastValue should remain untouched",
|
||||
null,
|
||||
toggleLastValue,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switch_whenAccessibilityGrantedAndOn_togglingOff_callsOnToggleFalse() {
|
||||
// Complementary path: when accessibility IS granted and the Switch
|
||||
// is currently checked, flipping it off must go through onToggle
|
||||
// (not onAccessibilityNeeded). Locks in that the new conditional
|
||||
// didn't accidentally hijack the normal toggle-off path.
|
||||
var toggleCalls = 0
|
||||
var toggleLastValue: Boolean? = null
|
||||
var needsCalls = 0
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeMasterToggle(
|
||||
enabled = true,
|
||||
status = null,
|
||||
accessibilityGranted = true,
|
||||
onToggle = { wantsOn ->
|
||||
toggleCalls++
|
||||
toggleLastValue = wantsOn
|
||||
},
|
||||
onAccessibilityNeeded = { needsCalls++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertEquals("onToggle must fire exactly once", 1, toggleCalls)
|
||||
assertFalse(
|
||||
"toggling a checked switch should pass wantsOn=false",
|
||||
toggleLastValue!!,
|
||||
)
|
||||
assertEquals(
|
||||
"onAccessibilityNeeded must NOT fire on the normal toggle-off path",
|
||||
0,
|
||||
needsCalls,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switch_whenAccessibilityGrantedAndOff_togglingOn_callsOnToggleTrue() {
|
||||
var toggleCalls = 0
|
||||
var toggleLastValue: Boolean? = null
|
||||
var needsCalls = 0
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeMasterToggle(
|
||||
enabled = false,
|
||||
status = null,
|
||||
accessibilityGranted = true,
|
||||
onToggle = { wantsOn ->
|
||||
toggleCalls++
|
||||
toggleLastValue = wantsOn
|
||||
},
|
||||
onAccessibilityNeeded = { needsCalls++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertEquals("onToggle must fire exactly once", 1, toggleCalls)
|
||||
assertTrue(
|
||||
"toggling an unchecked switch with a11y granted should pass wantsOn=true",
|
||||
toggleLastValue!!,
|
||||
)
|
||||
assertEquals(
|
||||
"onAccessibilityNeeded must NOT fire when a11y is already granted",
|
||||
0,
|
||||
needsCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
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.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the v0.4.1 [UnattendedAccessRow] `masterEnabled`
|
||||
* gate. When the master Agent Control switch is off, the unattended-access
|
||||
* Switch must be disabled and the subtitle must advise the user to enable
|
||||
* the master switch first — otherwise they'd flip unattended on and see
|
||||
* nothing happen (the wake-lock acquire path short-circuits on master-off
|
||||
* regardless of this flag).
|
||||
*/
|
||||
class UnattendedAccessRowTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun masterDisabled_switchIsDisabled() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun masterDisabled_subtitleExplainsWhy() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Requires Agent Control", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("enable the master switch above first", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun masterEnabled_switchIsInteractive() {
|
||||
// Regression: don't accidentally disable the Switch in the common path.
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun masterEnabled_offSubtitle_doesNotMentionMasterRequirement() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The off-but-master-on subtitle is the "actions only land when the
|
||||
// screen is already on" copy, NOT the master-gated one.
|
||||
composeTestRule
|
||||
.onNodeWithText("bridge actions only land", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies the voice overlay renders exactly one transcript row per turn
|
||||
* even when [VoiceUiState.transcribedText] and [VoiceUiState.responseText]
|
||||
* are populated alongside the same content in [ChatMessage]s.
|
||||
*
|
||||
* Pre-fix bug: the overlay rendered from THREE sources — `transcribedText`
|
||||
* (a top "YOU" row), `responseText` (a `StreamingResponseRow`), and
|
||||
* `transcriptMessages` (the scrolling chat-history list). During a voice
|
||||
* turn ChatViewModel committed the user's send and streamed the assistant
|
||||
* reply into its own message flow, so the same content ended up in both
|
||||
* `transcribedText`/`responseText` AND in `transcriptMessages` — every
|
||||
* turn appeared twice on screen.
|
||||
*
|
||||
* Fix: the overlay consumes only `transcriptMessages` now. This test asserts
|
||||
* that even when the other two fields are set, the on-screen count of each
|
||||
* turn's text is exactly one.
|
||||
*/
|
||||
class VoiceModeOverlayTranscriptTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun userAndAgentTurns_renderExactlyOnce_evenWithLegacyFieldsSet() {
|
||||
val userText = "Hello agent"
|
||||
val agentText = "Hi there"
|
||||
|
||||
val messages = listOf(
|
||||
ChatMessage(
|
||||
id = "u-1",
|
||||
role = MessageRole.USER,
|
||||
content = userText,
|
||||
timestamp = 1L,
|
||||
isStreaming = false,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "a-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = agentText,
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Speaking,
|
||||
// Legacy fields — if the overlay still read these,
|
||||
// each turn's text would appear twice.
|
||||
transcribedText = userText,
|
||||
responseText = agentText,
|
||||
interactionMode = InteractionMode.TapToTalk,
|
||||
),
|
||||
onMicTap = {},
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
transcriptMessages = messages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val userOccurrences = composeTestRule
|
||||
.onAllNodesWithText(userText, substring = false)
|
||||
.fetchSemanticsNodes()
|
||||
.size
|
||||
val agentOccurrences = composeTestRule
|
||||
.onAllNodesWithText(agentText, substring = false)
|
||||
.fetchSemanticsNodes()
|
||||
.size
|
||||
|
||||
assertEquals(
|
||||
"user turn must render exactly once (no double-entry from transcribedText)",
|
||||
1,
|
||||
userOccurrences,
|
||||
)
|
||||
assertEquals(
|
||||
"agent turn must render exactly once (no double-entry from responseText)",
|
||||
1,
|
||||
agentOccurrences,
|
||||
)
|
||||
}
|
||||
}
|
||||
+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>
|
||||
|
||||
@@ -17,7 +17,12 @@ import com.hermesandroid.relay.network.models.Envelope
|
||||
typealias LocalBridgeDispatcher = suspend (Envelope) -> LocalDispatchResult
|
||||
|
||||
/** @see com.hermesandroid.relay.voice.VoiceIntentResultCallback in the sideload flavor. */
|
||||
typealias VoiceIntentResultCallback = (intentLabel: String, result: LocalDispatchResult) -> Unit
|
||||
typealias VoiceIntentResultCallback = (
|
||||
intentLabel: String,
|
||||
result: LocalDispatchResult,
|
||||
androidToolName: String?,
|
||||
androidToolArgsJson: String,
|
||||
) -> Unit
|
||||
|
||||
/** @see com.hermesandroid.relay.voice.VoiceIntentCountdownCallback in the sideload flavor. */
|
||||
typealias VoiceIntentCountdownCallback = (intentLabel: String, durationMs: Long) -> Unit
|
||||
|
||||
@@ -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,22 @@
|
||||
<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. -->
|
||||
<!-- Turn-complete chat notification (TurnCompleteNotifier) — runtime-requested
|
||||
on API 33+ from the Chat Settings toggle. Lives in main (not just the
|
||||
sideload overlay) so the googlePlay flavor can notify too. -->
|
||||
<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" />
|
||||
<!-- Opt-in "Keep connected in background" (GatewayKeepAliveService). In main
|
||||
(not the sideload overlay) so the googlePlay flavor ships it too — the
|
||||
Home-Assistant-class persistent-connection use case Play permits. The
|
||||
specialUse type requires a one-time Play Console foreground-service
|
||||
declaration at submission. (Also already present in the sideload overlay
|
||||
for the device-control bridge service; the merger dedups.) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<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 +37,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 +55,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,45 +67,19 @@
|
||||
</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). -->
|
||||
<!-- Opt-in "Keep connected in background" — holds the gateway chat
|
||||
socket open while backgrounded. In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
<service
|
||||
android:name=".bridge.BridgeForegroundService"
|
||||
android:name=".network.GatewayKeepAliveService"
|
||||
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." />
|
||||
android:value="Keeps the user's chat connection to their Hermes agent open while the app is backgrounded, only when the user has explicitly enabled 'Keep connected in background'." />
|
||||
</service>
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -239,6 +239,166 @@
|
||||
}
|
||||
};
|
||||
|
||||
// ── Scroll shims + gesture ────────────────────────────────────────
|
||||
// xterm.js ships a scrollback buffer (scrollback: 10000 above) but
|
||||
// has no built-in mobile touch-to-scroll — its input handlers are
|
||||
// designed around mouse/wheel events, and it sets touch-action on
|
||||
// its root to swallow gestures for selection. That leaves the
|
||||
// scrollback unreachable on phones unless we add a translator.
|
||||
//
|
||||
// Strategy: one finger, mostly-vertical drag → convert delta to
|
||||
// line-scroll via term.scrollLines(). The threshold keeps short
|
||||
// taps (and their tiny jitter) from triggering scroll; the axis
|
||||
// dominance check lets users still long-press for selection or
|
||||
// horizontal-swipe for future features without false positives.
|
||||
// Every scroll intent on this terminal — gesture, toolbar button,
|
||||
// whatever — gets funneled through a synthetic WheelEvent dispatched
|
||||
// on xterm's render root. This is deliberately NOT a direct call to
|
||||
// term.scrollLines(), because that skips xterm's own buffer + mouse-
|
||||
// mode routing. Letting xterm handle the wheel gives us all three
|
||||
// correct behaviors for free:
|
||||
//
|
||||
// 1. Main buffer (at the shell prompt): xterm scrolls its own
|
||||
// 10k-line scrollback locally — same as our first version.
|
||||
// 2. Alternate buffer + TUI has enabled mouse tracking
|
||||
// (claude-code, hermes TUI, tmux with `mouse on`, less, vim
|
||||
// with `set mouse=a` — basically any modern TUI; it's what
|
||||
// makes hover-to-scroll work on desktop): xterm encodes the
|
||||
// wheel as an SGR mouse-wheel escape (\e[<64;col;rowM for
|
||||
// up / <65 for down) and forwards it to the PTY, so the TUI
|
||||
// scrolls its own content natively.
|
||||
// 3. Alternate buffer + TUI has NOT enabled mouse tracking
|
||||
// (rare on modern TUIs; mostly old curses apps): xterm
|
||||
// ignores the wheel — safe no-op, no garbage injected.
|
||||
const wheelTarget = function () {
|
||||
return document.querySelector('.xterm-screen') ||
|
||||
document.querySelector('.xterm') ||
|
||||
document.getElementById('terminal');
|
||||
};
|
||||
const dispatchWheel = function (deltaY) {
|
||||
const target = wheelTarget();
|
||||
if (!target) {
|
||||
console.warn('scrollTerminal: no wheel target element found');
|
||||
return;
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
// Mouse position matters for SGR wheel encoding — use the
|
||||
// viewport center so TUIs that care (tmux split-pane) hit the
|
||||
// right pane instead of always the top-left cell.
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
const evt = new WheelEvent('wheel', {
|
||||
deltaY: deltaY,
|
||||
deltaMode: 0, // DOM_DELTA_PIXEL
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: clientX,
|
||||
clientY: clientY,
|
||||
});
|
||||
const altBuffer = (function () {
|
||||
try { return term.buffer.active.type === 'alternate'; }
|
||||
catch (_) { return false; }
|
||||
})();
|
||||
// Visible in logcat via TerminalWebView's onConsoleMessage —
|
||||
// confirms the wheel fired and on which buffer. If scroll is
|
||||
// not working in a TUI, this is the first thing to check:
|
||||
// no line here = gesture path broken; line present but no
|
||||
// TUI response = TUI hasn't enabled mouse tracking.
|
||||
console.log('dispatchWheel: deltaY=' + deltaY +
|
||||
' altBuffer=' + altBuffer +
|
||||
' target=' + (target.className || target.id));
|
||||
target.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
const lineHeightPx = function () {
|
||||
const size = term.options.fontSize || 13;
|
||||
const lh = term.options.lineHeight || 1.15;
|
||||
return Math.max(10, size * lh);
|
||||
};
|
||||
|
||||
window.scrollTerminalLines = function (lines) {
|
||||
const n = Math.round(lines);
|
||||
if (n === 0) return;
|
||||
dispatchWheel(n * lineHeightPx());
|
||||
};
|
||||
window.scrollTerminalPages = function (pages) {
|
||||
const n = Math.round(pages);
|
||||
if (n === 0) return;
|
||||
dispatchWheel(n * lineHeightPx() * Math.max(1, term.rows - 2));
|
||||
};
|
||||
// Jump-to-bottom only makes sense in the main buffer (alt buffer is
|
||||
// always "at the bottom" — the TUI owns every visible row). In alt
|
||||
// buffer we simply no-op rather than guess what "bottom" means for
|
||||
// whichever app is running.
|
||||
window.scrollTerminalToBottom = function () {
|
||||
try {
|
||||
if (term.buffer.active.type === 'alternate') return;
|
||||
term.scrollToBottom();
|
||||
} catch (_) {}
|
||||
};
|
||||
window.scrollTerminalToTop = function () {
|
||||
try {
|
||||
if (term.buffer.active.type === 'alternate') return;
|
||||
term.scrollToTop();
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
(function installTouchScroll() {
|
||||
const root = document.getElementById('terminal');
|
||||
if (!root) return;
|
||||
let touchId = null;
|
||||
let startY = 0;
|
||||
let accumulated = 0;
|
||||
// lineHeightPx() is defined at module scope above — it already
|
||||
// tracks setFontSize() via term.options and returns a fresh
|
||||
// value per call, so we just use it directly here.
|
||||
const onStart = function (ev) {
|
||||
if (ev.touches.length !== 1) { touchId = null; return; }
|
||||
touchId = ev.touches[0].identifier;
|
||||
startY = ev.touches[0].clientY;
|
||||
accumulated = 0;
|
||||
};
|
||||
const onMove = function (ev) {
|
||||
if (touchId === null) return;
|
||||
let t = null;
|
||||
for (let i = 0; i < ev.touches.length; i++) {
|
||||
if (ev.touches[i].identifier === touchId) { t = ev.touches[i]; break; }
|
||||
}
|
||||
if (!t) return;
|
||||
const dy = t.clientY - startY;
|
||||
// Only fire once past a small deadzone so long-press+select
|
||||
// isn't stolen from xterm.
|
||||
if (Math.abs(dy) < 12) return;
|
||||
const lh = lineHeightPx();
|
||||
const lines = Math.trunc((dy - accumulated) / lh);
|
||||
if (lines !== 0) {
|
||||
// Route through the shared shim so alt-buffer detection
|
||||
// kicks in — TUIs (claude-code, hermes, vim, less) live
|
||||
// in the alt buffer and need real input events, while
|
||||
// the shell's main buffer uses local scrollback.
|
||||
// Finger down = older content, so we flip the sign.
|
||||
window.scrollTerminalLines(-lines);
|
||||
accumulated += lines * lh;
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
const onEnd = function (ev) {
|
||||
for (let i = 0; i < ev.changedTouches.length; i++) {
|
||||
if (ev.changedTouches[i].identifier === touchId) {
|
||||
touchId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// passive:false is required because we preventDefault above to
|
||||
// stop the browser from also hijacking the gesture for refresh
|
||||
// or selection.
|
||||
root.addEventListener('touchstart', onStart, { passive: true });
|
||||
root.addEventListener('touchmove', onMove, { passive: false });
|
||||
root.addEventListener('touchend', onEnd, { passive: true });
|
||||
root.addEventListener('touchcancel', onEnd, { passive: true });
|
||||
})();
|
||||
|
||||
// Refit on any container size change. ResizeObserver is more reliable
|
||||
// than `window.resize` on Android WebView — the window doesn't always
|
||||
// fire `resize` when Compose resizes the parent View, so the initial
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
v0.4.0 — Bridge Feature Expansion
|
||||
v1.0.0 - The 1.0 release
|
||||
|
||||
Bridge Channel
|
||||
• Long-press and drag gestures via ActionExecutor
|
||||
• Clipboard read/write over bridge protocol
|
||||
• Intent send (open URLs, share, launch activities)
|
||||
• Location, contacts, call, and SMS bridge commands
|
||||
• Multi-window screen reading in ScreenReader
|
||||
• Macro recording and playback support
|
||||
• Expanded BridgeCommandHandler path inventory
|
||||
Standard path
|
||||
* Chat, Manage, and voice now work on a plain Hermes agent — no relay
|
||||
plugin required. The plugin is optional and only adds power tools.
|
||||
|
||||
Chat
|
||||
* New gateway transport streams the agent's reasoning live, so the
|
||||
Thinking block fills in during generation instead of after.
|
||||
* Warm-start + opt-in "Keep connected in background" make returning to a
|
||||
conversation fast.
|
||||
* Attachments at desktop parity: images, PDFs, and files upload over the
|
||||
gateway. If a connection can't carry a file, you'll see a notice
|
||||
instead of a silent drop.
|
||||
* Steer a running turn, edit & resend your messages, watch subagent
|
||||
lanes, and a context-window meter — plus turn-complete notifications.
|
||||
* Tap an image to open it full-screen (pinch to zoom); save or share
|
||||
images and other attachments.
|
||||
* Redesigned input bar: pill field, one morphing Send/Voice/Stop button.
|
||||
|
||||
Profiles
|
||||
* Switch the whole agent — model, persona, and skills — per conversation.
|
||||
The drawer scopes to the active profile, and switching is ephemeral: it
|
||||
never changes your server's default agent.
|
||||
|
||||
Manage
|
||||
* Models, provider keys, profiles + SOUL.md, and a skills hub — parity
|
||||
with the desktop dashboard. Cached for instant cold-launch.
|
||||
|
||||
Voice
|
||||
• Voice-to-bridge intent routing (sideload)
|
||||
• VoiceBridgeIntentHandler with per-flavor factory
|
||||
• VoiceIntentClassifier regex phone-control detection
|
||||
• Local chat trace for voice actions
|
||||
* Realtime Agent keeps one session across turns; long runs continue in
|
||||
the background and are spoken when ready.
|
||||
|
||||
Safety
|
||||
• Tiered permission system per bridge command category
|
||||
• JIT error surfacing for missing permissions
|
||||
• BridgeSafetyManager blocklist + destructive-verb confirmation
|
||||
• Auto-disable timer for bridge sessions
|
||||
|
||||
Notifications
|
||||
• HermesNotificationCompanion — opt-in NotificationListenerService
|
||||
• Notification forwarding to agent via ChannelMultiplexer
|
||||
• Bounded relay-side deque (100 entries)
|
||||
|
||||
Prior releases
|
||||
• v0.3.0 — Bridge channel, voice mode, notification companion, two build flavors
|
||||
• v0.2.0 — Voice foundation, terminal preview, TOFU cert pinning
|
||||
• v0.1.0 — Chat, sessions, QR pairing, encrypted storage
|
||||
Polish
|
||||
* Seamless LAN/Tailscale handoffs (no chat reload), slide-down status
|
||||
toasts, and a broad round of fixes.
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.ComposeUiFlags
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
|
||||
class HermesRelayApp : Application() {
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun attachBaseContext(base: android.content.Context?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
|
||||
}
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
/**
|
||||
* Coil's singleton image loader for the whole app. Registering the OkHttp
|
||||
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
|
||||
* generated-image link in a chat reply) load, rather than relying on
|
||||
* artifact auto-registration. Crossfade for a clean fade-in.
|
||||
*/
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
.components { add(OkHttpNetworkFetcherFactory()) }
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// Compose's adaptive refresh-rate hint path on API 35 can emit
|
||||
// `setRequestedFrameRate frameRate=NaN` from inside AndroidComposeView
|
||||
// on every draw pass. Disable ARR globally until the upstream fix lands.
|
||||
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
|
||||
}
|
||||
instance = this
|
||||
AppAnalytics.initialize(this)
|
||||
// A8 — wire the bridge-gesture wake-lock wrapper so
|
||||
// ActionExecutor.tap/tapText/typeText/swipe/scroll can hold
|
||||
// a partial wake lock while dispatching.
|
||||
WakeLockManager.initialize(this)
|
||||
// v0.4.1 — sideload-only "unattended access" mode wiring.
|
||||
// Initialization is flavor-agnostic (the manager defaults to
|
||||
// disabled and only activates when the user opts in via the
|
||||
// sideload-gated Bridge tab toggle), so the call here is safe
|
||||
// to run on both flavors. The googlePlay flavor never reaches
|
||||
// an enable path so the wake-lock is never built or acquired.
|
||||
UnattendedAccessManager.initialize(this)
|
||||
// v0.4.1 polish — process-wide foreground/background signal
|
||||
// used by BridgeViewModel to suppress the WindowManager chip
|
||||
// while the user is inside Hermes-Relay (the in-app
|
||||
// UnattendedGlobalBanner covers that case). Idempotent.
|
||||
AppForegroundTracker.initialize()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -17,6 +17,9 @@ import androidx.core.animation.doOnEnd
|
||||
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.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
@@ -35,7 +38,7 @@ class MainActivity : ComponentActivity() {
|
||||
// We do NOT call MediaProjectionHolder directly from here. On Android
|
||||
// 14+, getMediaProjection() must run from inside a foreground service
|
||||
// that has already called startForeground(type=mediaProjection), and
|
||||
// that startForeground call must happen AFTER consent. So we hand the
|
||||
// that startForeground call must happen AFT consent. So we hand the
|
||||
// result off to BridgeForegroundService, which:
|
||||
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
|
||||
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
|
||||
@@ -49,6 +52,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)
|
||||
@@ -87,13 +94,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 ===
|
||||
@@ -132,12 +141,42 @@ class MainActivity : ComponentActivity() {
|
||||
NavRouteRequest.tryRequest(route)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
// access manager only attempts dismiss when an activity is
|
||||
// registered AND the user has opted in.
|
||||
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.
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// === PHASE3-bridge-ui-followup: clear MediaProjection requester ===
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -140,8 +140,11 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
fun ok(data: Map<String, Any?> = mapOf("ok" to true)): ActionResult =
|
||||
ActionResult(ok = true, data = data)
|
||||
|
||||
fun failure(message: String): ActionResult =
|
||||
ActionResult(ok = false, error = message)
|
||||
fun failure(
|
||||
message: String,
|
||||
data: Map<String, Any?> = emptyMap(),
|
||||
): ActionResult =
|
||||
ActionResult(ok = false, data = data, error = message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +167,11 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
if (dispatched) {
|
||||
ActionResult.ok(mapOf("x" to x, "y" to y, "duration_ms" to durationMs))
|
||||
} else {
|
||||
ActionResult.failure("gesture dispatch failed or was cancelled")
|
||||
// v0.4.1: classify keyguard-blocked failures so the LLM sees
|
||||
// a structured error_code instead of a generic message.
|
||||
ActionResult.failure(
|
||||
classifyGestureFailure("gesture dispatch failed or was cancelled")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +426,7 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
)
|
||||
)
|
||||
} else {
|
||||
ActionResult.failure("swipe gesture dispatch failed")
|
||||
ActionResult.failure(classifyGestureFailure("swipe gesture dispatch failed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +482,7 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
)
|
||||
)
|
||||
} else {
|
||||
ActionResult.failure("drag gesture dispatch failed")
|
||||
ActionResult.failure(classifyGestureFailure("drag gesture dispatch failed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +628,9 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
)
|
||||
)
|
||||
} else {
|
||||
ActionResult.failure("long_press gesture dispatch failed or was cancelled")
|
||||
ActionResult.failure(
|
||||
classifyGestureFailure("long_press gesture dispatch failed or was cancelled")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,6 +1095,44 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1 — surface a keyguard-aware error string when a gesture
|
||||
* dispatch failure coincides with a locked device. Called from
|
||||
* tap / swipe / long_press / drag failure paths so the LLM sees a
|
||||
* structured `keyguard_blocked` error_code (via
|
||||
* BridgeCommandHandler.classifyBridgeError) instead of the
|
||||
* generic "gesture dispatch failed" message.
|
||||
*
|
||||
* Heuristic only: a failed gesture on an unlocked device returns
|
||||
* the generic message; on a locked device we attribute the failure
|
||||
* to the keyguard. The unattended-access pre-gate in
|
||||
* BridgeCommandHandler.dispatch already short-circuits with a
|
||||
* dedicated `keyguard_blocked` response when the user has opted
|
||||
* in, so this path mostly fires when unattended is OFF and the
|
||||
* agent calls a gesture against a sleeping device.
|
||||
*/
|
||||
fun classifyGestureFailure(genericMessage: String): String {
|
||||
val km = try {
|
||||
service.getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
val locked = try {
|
||||
km?.isKeyguardLocked == true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
return if (locked) {
|
||||
"$genericMessage — the device keyguard appears to be active, " +
|
||||
"which may have blocked the gesture. If unattended-access " +
|
||||
"mode is enabled and the user has a credential lock " +
|
||||
"(PIN / pattern / biometric), Android does not allow " +
|
||||
"third-party apps to dismiss the lock."
|
||||
} else {
|
||||
genericMessage
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tier C: location / contacts / call / SMS ─────────────────────────
|
||||
//
|
||||
// All four methods below are sideload-only tools (see Tier C in the
|
||||
@@ -1578,13 +1625,22 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
*/
|
||||
suspend fun sendSms(to: String, body: String): ActionResult {
|
||||
if (to.isBlank()) {
|
||||
return ActionResult.failure("send_sms: recipient must be non-blank")
|
||||
return ActionResult.failure(
|
||||
"send_sms: recipient must be non-blank",
|
||||
mapOf("status" to "failed", "reason" to "invalid_recipient"),
|
||||
)
|
||||
}
|
||||
if (body.isEmpty()) {
|
||||
return ActionResult.failure("send_sms: body must be non-empty")
|
||||
return ActionResult.failure(
|
||||
"send_sms: body must be non-empty",
|
||||
mapOf("status" to "failed", "reason" to "invalid_schema"),
|
||||
)
|
||||
}
|
||||
if (!to.matches(Regex("^[+0-9 ()\\-.]{2,}$"))) {
|
||||
return ActionResult.failure("send_sms: recipient contains invalid characters")
|
||||
return ActionResult.failure(
|
||||
"send_sms: recipient contains invalid characters",
|
||||
mapOf("status" to "failed", "reason" to "invalid_recipient"),
|
||||
)
|
||||
}
|
||||
|
||||
val ctx: Context = service
|
||||
@@ -1592,7 +1648,12 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return ActionResult.failure(
|
||||
"Grant SMS permission in Settings > Apps > Hermes-Relay > Permissions"
|
||||
"Grant SMS permission in Settings > Apps > Hermes-Relay > Permissions",
|
||||
mapOf(
|
||||
"status" to "blocked",
|
||||
"reason" to "permission_denied",
|
||||
"required_permission" to Manifest.permission.SEND_SMS,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1608,7 +1669,10 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
null
|
||||
}
|
||||
if (smsManager == null) {
|
||||
return ActionResult.failure("SmsManager unavailable on this device")
|
||||
return ActionResult.failure(
|
||||
"SmsManager unavailable on this device",
|
||||
mapOf("status" to "failed", "reason" to "sms_manager_unavailable"),
|
||||
)
|
||||
}
|
||||
|
||||
// Pick one intent action value — the receiver identifies us by
|
||||
@@ -1664,7 +1728,10 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
ContextCompat.registerReceiver(ctx, receiver, filter, flags)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "registerReceiver for SMS_SENT threw: ${t.message}")
|
||||
return ActionResult.failure("sms receiver registration failed: ${t.message}")
|
||||
return ActionResult.failure(
|
||||
"sms receiver registration failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "receiver_registration_failed"),
|
||||
)
|
||||
}
|
||||
|
||||
// One PendingIntent per part — SmsManager fires the broadcast with
|
||||
@@ -1697,12 +1764,20 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
} catch (se: SecurityException) {
|
||||
try { ctx.unregisterReceiver(receiver) } catch (_: Throwable) { }
|
||||
return ActionResult.failure(
|
||||
"SMS permission revoked or restricted — re-grant in system Settings"
|
||||
"SMS permission revoked or restricted — re-grant in system Settings",
|
||||
mapOf(
|
||||
"status" to "blocked",
|
||||
"reason" to "permission_denied",
|
||||
"required_permission" to Manifest.permission.SEND_SMS,
|
||||
),
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
try { ctx.unregisterReceiver(receiver) } catch (_: Throwable) { }
|
||||
Log.w(TAG, "sendTextMessage threw: ${t.message}")
|
||||
return ActionResult.failure("send_sms failed: ${t.message}")
|
||||
return ActionResult.failure(
|
||||
"send_sms failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "android_exception"),
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the receiver to complete — with a 15s cap. If the radio
|
||||
@@ -1713,18 +1788,28 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
|
||||
if (result == null) {
|
||||
return ActionResult.failure(
|
||||
"send_sms timeout after ${SEND_SMS_TIMEOUT_MS}ms — carrier never acked"
|
||||
"send_sms timeout after ${SEND_SMS_TIMEOUT_MS}ms — carrier never acked",
|
||||
mapOf(
|
||||
"status" to "timeout",
|
||||
"reason" to "carrier_ack_timeout",
|
||||
"android_result" to "timeout",
|
||||
"parts" to expectedParts,
|
||||
),
|
||||
)
|
||||
}
|
||||
return if (result == android.app.Activity.RESULT_OK) {
|
||||
ActionResult.ok(
|
||||
mapOf(
|
||||
"status" to "sent",
|
||||
"android_result" to "RESULT_OK",
|
||||
"to" to to,
|
||||
"length" to body.length,
|
||||
"parts" to expectedParts,
|
||||
"summary" to "SMS sent to $to ($expectedParts part(s))",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val androidResult = smsResultName(result)
|
||||
val reason = when (result) {
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> "generic failure"
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> "no service"
|
||||
@@ -1732,8 +1817,25 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> "radio off (airplane mode?)"
|
||||
else -> "result code $result"
|
||||
}
|
||||
ActionResult.failure("send_sms failed: $reason")
|
||||
ActionResult.failure(
|
||||
"send_sms failed: $reason",
|
||||
mapOf(
|
||||
"status" to "failed",
|
||||
"reason" to reason,
|
||||
"android_result" to androidResult,
|
||||
"parts" to expectedParts,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun smsResultName(result: Int): String = when (result) {
|
||||
android.app.Activity.RESULT_OK -> "RESULT_OK"
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> "RESULT_ERROR_GENERIC_FAILURE"
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> "RESULT_ERROR_NO_SERVICE"
|
||||
SmsManager.RESULT_ERROR_NULL_PDU -> "RESULT_ERROR_NULL_PDU"
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> "RESULT_ERROR_RADIO_OFF"
|
||||
else -> "RESULT_$result"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.bridge.BridgeSafetyManager
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -54,10 +56,20 @@ import kotlinx.serialization.json.put
|
||||
* "destructive_verbs_count": 12,
|
||||
* "auto_disable_minutes": 30,
|
||||
* "auto_disable_at_ms": null
|
||||
* },
|
||||
* "unattended": {
|
||||
* "supported": true,
|
||||
* "enabled": false,
|
||||
* "credential_lock_detected": true
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `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
|
||||
* compatibility with any consumer that hasn't been updated to read the
|
||||
@@ -176,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.
|
||||
@@ -245,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 {
|
||||
@@ -262,6 +282,32 @@ class BridgeStatusReporter(
|
||||
}
|
||||
})
|
||||
|
||||
// v0.4.1: unattended-access state so the agent can decide
|
||||
// upfront whether commands will reach apps with the screen
|
||||
// off (instead of finding out reactively via the
|
||||
// keyguard_blocked error_code after a failed command).
|
||||
// `supported` is false on googlePlay — that flavor has no
|
||||
// wake-lock path and the user can't opt in even if they
|
||||
// wanted to. `credential_lock_detected` reflects whether
|
||||
// a PIN/pattern/biometric lock is currently configured;
|
||||
// when both `enabled=true` and this is true, commands
|
||||
// will wake the screen but stop at the lock screen.
|
||||
put("unattended", buildJsonObject {
|
||||
put("supported", deviceControlSupported)
|
||||
put(
|
||||
"enabled",
|
||||
if (deviceControlSupported) UnattendedAccessManager.enabled.value else false,
|
||||
)
|
||||
put(
|
||||
"credential_lock_detected",
|
||||
if (deviceControlSupported) {
|
||||
UnattendedAccessManager.credentialLockDetected.value
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// ── Legacy top-level fields (backwards compat) ────────
|
||||
// Kept so pre-phase3-status consumers still see the
|
||||
// same fields they're already parsing. New consumers
|
||||
@@ -269,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())
|
||||
}
|
||||
)
|
||||
|
||||
+7
-8
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
|
||||
*
|
||||
* # Master enable / disable
|
||||
*
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes Relay` is
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
|
||||
* the hard switch — if it's off we never receive events. On top of that the
|
||||
* user can flip a soft master in Settings (`bridge_master_enabled`); when
|
||||
* that's false we still run (Android requires it to stay connected) but we
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Duplex audio capture for voice barge-in (plan unit B3).
|
||||
*
|
||||
* While TTS is playing, this listener continuously pulls 32 ms / 512-sample
|
||||
* PCM frames off the microphone and feeds them to [VadEngine]. It emits two
|
||||
* SharedFlows that B4 will wire into the voice state machine:
|
||||
*
|
||||
* - [maybeSpeech] fires on the **first** positive raw-VAD frame — before the
|
||||
* second-layer debouncer latches. B4 uses this to softly [VoicePlayer.duck]
|
||||
* the TTS so the user's voice has acoustic headroom while we decide whether
|
||||
* to cut off.
|
||||
*
|
||||
* - [bargeInDetected] fires when [VadEngine] confirms speech post-hysteresis.
|
||||
* B4 uses this to call `interruptSpeaking()` and flip state to Listening.
|
||||
*
|
||||
* ### Acoustic echo cancellation
|
||||
*
|
||||
* We configure [AudioRecord] with [MediaRecorder.AudioSource.VOICE_COMMUNICATION]
|
||||
* so the platform's voice-call AEC pipeline is in play, and additionally try
|
||||
* to attach [AcousticEchoCanceler] + [NoiseSuppressor] keyed to the ExoPlayer
|
||||
* audio session id so TTS audio is cancelled from the mic stream specifically.
|
||||
* Without AEC, the device's own speaker output would trip the VAD the moment
|
||||
* TTS started and we'd interrupt ourselves.
|
||||
*
|
||||
* The ExoPlayer audio session id is not stable at the moment we want to start
|
||||
* listening — Media3 allocates the underlying AudioTrack lazily on first
|
||||
* playback, and callers may hit [start] before that's happened (e.g. the very
|
||||
* first sentence of a turn). We poll [audioSessionIdProvider] for up to 1 s
|
||||
* before giving up on AEC and proceeding with the mic-hardware AEC alone.
|
||||
* See the `AEC_SESSION_POLL_*` constants below.
|
||||
*
|
||||
* ### Graceful degradation
|
||||
*
|
||||
* - `AudioRecord.getState() != STATE_INITIALIZED` → log WARN, emit nothing,
|
||||
* [stop] remains safe to call. Typical cause: RECORD_AUDIO denied at runtime
|
||||
* or another app holding the mic.
|
||||
* - `AcousticEchoCanceler.isAvailable() == false` → log INFO, proceed without.
|
||||
* Many mid-range and older devices lack the effect; the VAD still works with
|
||||
* the mic-hardware AEC from VOICE_COMMUNICATION alone (at the cost of some
|
||||
* false positives during loud TTS).
|
||||
*
|
||||
* ### Testability seam
|
||||
*
|
||||
* The hot path is abstracted behind [AudioFrameSource]. Production code uses
|
||||
* [AudioRecordSource]; unit tests inject a deterministic fake. This keeps the
|
||||
* test on the JVM unit test path with no Robolectric or `android.jar` shim,
|
||||
* matching the [VadEngine] test pattern.
|
||||
*
|
||||
* ### Thread model
|
||||
*
|
||||
* [start] launches a single reader coroutine on [Dispatchers.IO]. The reader
|
||||
* blocks on [AudioFrameSource.read], then synchronously invokes
|
||||
* [VadEngine.analyze] on the same dispatcher — VadEngine is synchronous,
|
||||
* allocation-free, and callers promise single-threaded access. Flow emissions
|
||||
* use [MutableSharedFlow] with `extraBufferCapacity = 1` so slow subscribers
|
||||
* drop events instead of backpressuring the audio loop.
|
||||
*/
|
||||
class BargeInListener internal constructor(
|
||||
private val audioSource: AudioFrameSource,
|
||||
private val vadEngine: VadEngine,
|
||||
private val audioSessionIdProvider: () -> Int,
|
||||
private val readerDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BargeInListener"
|
||||
|
||||
/** Bytes per PCM sample at [AudioFormat.ENCODING_PCM_16BIT]. */
|
||||
private const val BYTES_PER_SAMPLE = 2
|
||||
|
||||
/** Frames of buffering on the [AudioRecord] side. 4× keeps read() from
|
||||
* ever racing the DMA when the reader coroutine is scheduled with a
|
||||
* brief delay (GC pause, dispatcher contention). */
|
||||
private const val AUDIO_BUFFER_FRAMES = 4
|
||||
|
||||
/** ExoPlayer may return `0` for its audio session id until its
|
||||
* AudioTrack is first allocated (on playback start). Poll the
|
||||
* provider briefly before giving up on AEC and proceeding without. */
|
||||
private const val AEC_SESSION_POLL_INTERVAL_MS = 50L
|
||||
private const val AEC_SESSION_POLL_TIMEOUT_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Factory for the production path. Builds an [AudioRecordSource] from
|
||||
* a `Context` and wires it to the listener. The returned listener has
|
||||
* no allocated `AudioRecord` yet — that happens inside [start].
|
||||
*/
|
||||
fun create(
|
||||
context: Context,
|
||||
vadEngine: VadEngine,
|
||||
audioSessionIdProvider: () -> Int,
|
||||
): BargeInListener = BargeInListener(
|
||||
audioSource = AudioRecordSource(context.applicationContext),
|
||||
vadEngine = vadEngine,
|
||||
audioSessionIdProvider = audioSessionIdProvider,
|
||||
)
|
||||
}
|
||||
|
||||
private val _bargeInDetected = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
/** Fires post-hysteresis when [VadEngine] confirms the user is speaking. */
|
||||
val bargeInDetected: SharedFlow<Unit> = _bargeInDetected.asSharedFlow()
|
||||
|
||||
private val _maybeSpeech = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
/** Fires on the first positive raw VAD frame, before hysteresis latches. */
|
||||
val maybeSpeech: SharedFlow<Unit> = _maybeSpeech.asSharedFlow()
|
||||
|
||||
private val _aecAttached = MutableStateFlow(false)
|
||||
/** True once [AcousticEchoCanceler] has been attached for the current
|
||||
* listen session. Exposed for observability and B5's compatibility hint. */
|
||||
val aecAttached: StateFlow<Boolean> = _aecAttached.asStateFlow()
|
||||
|
||||
// Reused across every read() call so the hot loop never allocates a
|
||||
// fresh buffer. Length matches the VAD engine's contract (512 samples).
|
||||
private val frameBuffer: ShortArray = ShortArray(VadEngine.FRAME_SIZE_SAMPLES)
|
||||
|
||||
@Volatile private var readerJob: Job? = null
|
||||
@Volatile private var aec: AcousticEchoCanceler? = null
|
||||
@Volatile private var noiseSuppressor: NoiseSuppressor? = null
|
||||
|
||||
/**
|
||||
* Allocate the audio pipeline and begin reading frames into [vadEngine].
|
||||
*
|
||||
* Launches on the supplied [scope] so the reader coroutine dies with its
|
||||
* owner (the ViewModel scope in B4). [start] is not suspend in the usual
|
||||
* "blocks until ready" sense — it returns as soon as the reader job is
|
||||
* launched; the AEC attach happens lazily inside the coroutine so a
|
||||
* caller waiting on the first [maybeSpeech] / [bargeInDetected] emission
|
||||
* is not gated on an AudioTrack that hasn't been allocated yet.
|
||||
*
|
||||
* Idempotent: calling [start] again while a previous session is still
|
||||
* active is a no-op with a WARN log — B4 is expected to bracket each
|
||||
* listen session with a matching [stop].
|
||||
*/
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (readerJob?.isActive == true) {
|
||||
Log.w(TAG, "start() called while a reader is already active — ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioSource.initialize()) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"AudioFrameSource failed to initialize " +
|
||||
"(missing RECORD_AUDIO permission or mic busy) — listener inactive",
|
||||
)
|
||||
_aecAttached.value = false
|
||||
return
|
||||
}
|
||||
|
||||
_aecAttached.value = false
|
||||
readerJob = scope.launch(readerDispatcher) {
|
||||
try {
|
||||
try {
|
||||
audioSource.start()
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.start failed: ${t.message}")
|
||||
return@launch
|
||||
}
|
||||
Log.i(TAG, "Barge-in AudioRecord reader started")
|
||||
maybeAttachEffects()
|
||||
|
||||
while (isActive) {
|
||||
val read = try {
|
||||
audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.read failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (read <= 0) {
|
||||
// Negative values are AudioRecord error codes; 0 means
|
||||
// no data yet. Either way, yield briefly and retry
|
||||
// rather than spinning — but don't swallow the
|
||||
// cancellation check for too long.
|
||||
delay(5)
|
||||
continue
|
||||
}
|
||||
if (read < VadEngine.FRAME_SIZE_SAMPLES) {
|
||||
// Short read — skip this frame rather than feeding
|
||||
// the VAD a partially-populated buffer. This is rare;
|
||||
// AudioRecord.read(…, SIZE_IN_SHORTS) normally fills
|
||||
// the requested length when state is correct.
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isActive) break
|
||||
|
||||
val result = try {
|
||||
vadEngine.analyze(frameBuffer)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "VadEngine.analyze failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (result.probability > 0f) {
|
||||
_maybeSpeech.tryEmit(Unit)
|
||||
}
|
||||
if (result.isSpeech) {
|
||||
_bargeInDetected.tryEmit(Unit)
|
||||
}
|
||||
// Give the dispatcher a chance to observe cancellation
|
||||
// between frames. Production-side the AudioRecord.read
|
||||
// call already blocks until a frame is available, so
|
||||
// this is effectively free; test-side it prevents the
|
||||
// reader from monopolizing the test scheduler on fakes
|
||||
// that return data synchronously.
|
||||
yield()
|
||||
}
|
||||
} finally {
|
||||
// Release effects + AudioRecord in the reverse of attach order
|
||||
// so the AudioSessionId is still valid when AEC teardown runs.
|
||||
releaseEffects()
|
||||
runCatching { audioSource.stop() }
|
||||
runCatching { audioSource.release() }
|
||||
_aecAttached.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the reader loop and release the mic + effects. Safe to call
|
||||
* repeatedly and safe to call before [start]. Returns immediately; the
|
||||
* actual release happens in the reader coroutine's `finally` block, which
|
||||
* is typically a single frame later.
|
||||
*/
|
||||
fun stop(): Job? {
|
||||
val job = readerJob
|
||||
if (job?.isActive == true) {
|
||||
Log.i(TAG, "Stopping barge-in AudioRecord reader")
|
||||
}
|
||||
job?.cancel()
|
||||
readerJob = null
|
||||
return job
|
||||
}
|
||||
|
||||
private suspend fun maybeAttachEffects() {
|
||||
val sessionId = awaitNonZeroSessionId()
|
||||
if (sessionId == 0) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"AEC not attached — ExoPlayer audio session id was still 0 " +
|
||||
"after ${AEC_SESSION_POLL_TIMEOUT_MS}ms poll; continuing " +
|
||||
"without effects (mic-hardware AEC from VOICE_COMMUNICATION " +
|
||||
"still in play)",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
try {
|
||||
val created = AcousticEchoCanceler.create(sessionId)
|
||||
if (created != null) {
|
||||
created.enabled = true
|
||||
aec = created
|
||||
_aecAttached.value = true
|
||||
Log.i(TAG, "AcousticEchoCanceler attached to session=$sessionId")
|
||||
} else {
|
||||
Log.i(TAG, "AcousticEchoCanceler.create returned null; continuing without")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AcousticEchoCanceler attach failed: ${t.message}")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "AEC unavailable on this device; continuing without echo cancellation")
|
||||
}
|
||||
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
try {
|
||||
val ns = NoiseSuppressor.create(sessionId)
|
||||
if (ns != null) {
|
||||
ns.enabled = true
|
||||
noiseSuppressor = ns
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "NoiseSuppressor attach failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitNonZeroSessionId(): Int {
|
||||
val immediate = audioSessionIdProvider()
|
||||
if (immediate != 0) return immediate
|
||||
|
||||
var waited = 0L
|
||||
while (waited < AEC_SESSION_POLL_TIMEOUT_MS) {
|
||||
delay(AEC_SESSION_POLL_INTERVAL_MS)
|
||||
waited += AEC_SESSION_POLL_INTERVAL_MS
|
||||
val id = audioSessionIdProvider()
|
||||
if (id != 0) return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun releaseEffects() {
|
||||
aec?.let {
|
||||
runCatching { it.enabled = false }
|
||||
runCatching { it.release() }
|
||||
}
|
||||
aec = null
|
||||
noiseSuppressor?.let {
|
||||
runCatching { it.enabled = false }
|
||||
runCatching { it.release() }
|
||||
}
|
||||
noiseSuppressor = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal seam over [AudioRecord] so the audio-source pipeline can be
|
||||
* replaced with a deterministic fake in unit tests. Implementations are
|
||||
* not thread-safe — callers promise single-threaded access from the
|
||||
* reader coroutine.
|
||||
*/
|
||||
internal interface AudioFrameSource {
|
||||
/**
|
||||
* Allocate underlying native resources. Returns true on success.
|
||||
* Returning false from here short-circuits the listener without any
|
||||
* downstream state flapping.
|
||||
*/
|
||||
fun initialize(): Boolean
|
||||
|
||||
/** Begin streaming frames. Must be preceded by a successful [initialize]. */
|
||||
fun start()
|
||||
|
||||
/** Read up to [sizeInShorts] samples into [buffer]; returns the number
|
||||
* of samples actually read (possibly 0 or negative for error states). */
|
||||
fun read(buffer: ShortArray, sizeInShorts: Int): Int
|
||||
|
||||
/** Stop streaming. May be called multiple times. */
|
||||
fun stop()
|
||||
|
||||
/** Release native resources. After this, the source is dead. */
|
||||
fun release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Real [AudioRecord]-backed frame source. Configures 16 kHz mono 16-bit
|
||||
* PCM with [MediaRecorder.AudioSource.VOICE_COMMUNICATION] so the mic
|
||||
* hardware AEC is engaged.
|
||||
*
|
||||
* The [Context] parameter is currently unused — [AudioRecord] doesn't
|
||||
* need one — but we take it to keep the production factory signature
|
||||
* symmetric with the rest of the audio stack (e.g. [VoiceRecorder])
|
||||
* and to leave room for future permission-probe / audio-focus hooks
|
||||
* without a constructor signature change.
|
||||
*/
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
private class AudioRecordSource(context: Context) : AudioFrameSource {
|
||||
private var record: AudioRecord? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun initialize(): Boolean {
|
||||
val sampleRate = 16_000
|
||||
val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
|
||||
val minBytes = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding)
|
||||
if (minBytes <= 0) {
|
||||
Log.w(TAG, "AudioRecord.getMinBufferSize returned $minBytes — aborting")
|
||||
return false
|
||||
}
|
||||
val ourBytes =
|
||||
VadEngine.FRAME_SIZE_SAMPLES * BYTES_PER_SAMPLE * AUDIO_BUFFER_FRAMES
|
||||
val bufferBytes = max(minBytes, ourBytes)
|
||||
|
||||
val r = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
encoding,
|
||||
bufferBytes,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioRecord constructor threw: ${t.message}")
|
||||
return false
|
||||
}
|
||||
|
||||
if (r.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.w(TAG, "AudioRecord state=${r.state} (expected STATE_INITIALIZED)")
|
||||
runCatching { r.release() }
|
||||
return false
|
||||
}
|
||||
|
||||
record = r
|
||||
return true
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
record?.startRecording()
|
||||
}
|
||||
|
||||
override fun read(buffer: ShortArray, sizeInShorts: Int): Int {
|
||||
val r = record ?: return -1
|
||||
return r.read(buffer, 0, sizeInShorts)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
runCatching { record?.stop() }
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
runCatching { record?.release() }
|
||||
record = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
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
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Small streaming PCM sink for the realtime voice dev testbench.
|
||||
*
|
||||
* The relay sends mono 16-bit little-endian PCM chunks over the websocket. This
|
||||
* writes them directly to an AudioTrack so the Android Studio dev build can
|
||||
* hear provider output without waiting for an encoded file.
|
||||
*/
|
||||
class RealtimePcmPlayer(context: Context? = null) {
|
||||
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() = synchronized(trackLock) { audioTrack != null }
|
||||
|
||||
val audioSessionId: Int
|
||||
get() = synchronized(trackLock) { audioTrack?.audioSessionId ?: 0 }
|
||||
|
||||
fun write(pcm: ByteArray, sampleRate: Int): Float {
|
||||
if (pcm.isEmpty()) return 0f
|
||||
val level = computePcm16LeRms(pcm)
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
synchronized(writeLock) {
|
||||
synchronized(trackLock) {
|
||||
releaseTrackLocked(reason = "stop")
|
||||
currentSampleRate = 0
|
||||
estimatedPlaybackEndAtMs = 0L
|
||||
}
|
||||
}
|
||||
_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)
|
||||
synchronized(trackLock) {
|
||||
currentVolume = clamped
|
||||
try { audioTrack?.setVolume(clamped) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun duck() {
|
||||
setVolume(0.3f)
|
||||
}
|
||||
|
||||
fun unduck() {
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
releaseTrackLocked(reason = "sample rate changed")
|
||||
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val bufferSize = RealtimePcmBufferPolicy.streamBufferSize(
|
||||
minBufferBytes = minBuffer,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
val format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build()
|
||||
|
||||
val track = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAudioFormat(format)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
AudioTrack(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
)
|
||||
}
|
||||
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
throw IllegalStateException("AudioTrack failed to initialize")
|
||||
}
|
||||
|
||||
requestAudioFocusLocked()
|
||||
audioTrack = track
|
||||
currentSampleRate = sampleRate
|
||||
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
|
||||
|
||||
var sumSquares = 0.0
|
||||
var samples = 0
|
||||
var index = 0
|
||||
while (index < usable) {
|
||||
val low = pcm[index].toInt() and 0xff
|
||||
val high = pcm[index + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt()
|
||||
val normalized = sample / Short.MAX_VALUE.toDouble()
|
||||
sumSquares += normalized * normalized
|
||||
samples++
|
||||
index += 2
|
||||
}
|
||||
if (samples == 0) return 0f
|
||||
|
||||
val rms = sqrt(sumSquares / samples)
|
||||
val lifted = sqrt((rms / 0.28).coerceIn(0.0, 1.0))
|
||||
return if (lifted.isNaN() || lifted.isInfinite()) 0f else lifted.toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 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(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val targetBytes = ((sampleRate * durationMs) / 1000L * 2L)
|
||||
.toInt()
|
||||
.coerceAtLeast(minBuffer)
|
||||
|
||||
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(targetBytes)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (out.size() < targetBytes) {
|
||||
val read = recorder.read(
|
||||
buffer,
|
||||
0,
|
||||
min(buffer.size, targetBytes - out.size()),
|
||||
)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.konovalov.vad.silero.VadSilero
|
||||
import com.konovalov.vad.silero.config.FrameSize
|
||||
import com.konovalov.vad.silero.config.Mode
|
||||
import com.konovalov.vad.silero.config.SampleRate
|
||||
|
||||
/**
|
||||
* Voice activity detection engine for barge-in (plan unit B2).
|
||||
*
|
||||
* Wraps the upstream `com.github.gkonovalov.android-vad:silero` Silero VAD
|
||||
* behind a project-internal interface so callers never see the library type —
|
||||
* swapping to `vad-webrtc` or another backend later is a single-file change
|
||||
* here, not a fan-out edit across [com.hermesandroid.relay.audio.BargeInListener]
|
||||
* (B3) and [com.hermesandroid.relay.viewmodel.VoiceViewModel] (B4).
|
||||
*
|
||||
* ### Frame contract
|
||||
*
|
||||
* - 16 kHz mono 16-bit PCM.
|
||||
* - Exactly **512 samples** per call (32 ms at 16 kHz). This is the smallest
|
||||
* Silero-supported frame size at 16 kHz per the library's public
|
||||
* `supportedParameters` map (512, 1024, 1536); 512 keeps latency tight.
|
||||
* The plan document references 640 samples — that was the previous library
|
||||
* constraint before the Silero 2.0 series dropped 160/320/640/1024 in
|
||||
* favour of 512/1024/1536. Use 512.
|
||||
* - [analyze] is synchronous. Cost is one ONNX forward pass + a couple of
|
||||
* counter increments. Callers (B3) feed frames in a tight loop; any heap
|
||||
* allocation beyond the returned [VadResult] is avoided.
|
||||
*
|
||||
* ### Two-layer hysteresis
|
||||
*
|
||||
* 1. **Library layer** — Silero's own `isSpeech()` already applies an
|
||||
* attack/release window driven by `speechDurationMs`/`silenceDurationMs`
|
||||
* ([SENSITIVITY_PROFILES]). This handles per-frame wobble from the DNN.
|
||||
* 2. **Our layer** — on top, we require `consecutiveSpeechFrames` successive
|
||||
* post-library `true` returns before [VadResult.isSpeech] flips to `true`.
|
||||
* This is the "2–3 consecutive speech frames" debouncer from the plan.
|
||||
*
|
||||
* The two layers compose: library filters per-frame noise, ours defends
|
||||
* against short false-positive bursts (~40 ms) that slip through.
|
||||
*
|
||||
* ### Sensitivity semantics
|
||||
*
|
||||
* [BargeInSensitivity.Off] short-circuits: [analyze] always returns
|
||||
* `isSpeech=false` without touching the model. Useful as a "disable without
|
||||
* flipping the master enabled toggle" UI affordance.
|
||||
*/
|
||||
class VadEngine @VisibleForTesting internal constructor(
|
||||
private val client: VadClient,
|
||||
sampleRate: Int,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Production constructor. Builds a real Silero-backed [VadClient].
|
||||
*
|
||||
* @param sampleRate currently pinned to 16000 — other rates are not
|
||||
* supported by this engine (and the plan standardises on 16 kHz mic
|
||||
* capture in B3).
|
||||
*/
|
||||
constructor(context: Context, sampleRate: Int = 16_000) : this(
|
||||
client = SileroVadClient(context.applicationContext, sampleRate.toSampleRate()),
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
|
||||
init {
|
||||
require(sampleRate == 16_000) {
|
||||
"VadEngine currently supports only 16 kHz sample rate; got $sampleRate"
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var sensitivity: BargeInSensitivity = BargeInSensitivity.Default
|
||||
|
||||
@Volatile
|
||||
private var profile: SensitivityProfile = SENSITIVITY_PROFILES.getValue(BargeInSensitivity.Default)
|
||||
.also { client.applyProfile(it) }
|
||||
|
||||
// Rolling counters for the second-layer "N consecutive speech frames"
|
||||
// hysteresis. These are touched only from [analyze], which callers drive
|
||||
// single-threaded from B3's audio-read loop, so no synchronization is
|
||||
// required beyond reading the latest [profile] volatile.
|
||||
private var consecutiveSpeechCount: Int = 0
|
||||
private var debounced: Boolean = false
|
||||
|
||||
/**
|
||||
* Analyze one frame of 16-bit PCM audio.
|
||||
*
|
||||
* @param frame exactly [FRAME_SIZE_SAMPLES] (512) samples at 16 kHz. Any
|
||||
* other size is rejected by the Silero backend.
|
||||
* @return a [VadResult] whose [VadResult.isSpeech] incorporates both the
|
||||
* library's internal attack/release and our N-consecutive debouncer;
|
||||
* [VadResult.probability] is a coarse 0f/1f signal derived from the
|
||||
* pre-debounce library decision (Silero's public API exposes only the
|
||||
* boolean, not the raw confidence).
|
||||
*/
|
||||
fun analyze(frame: ShortArray): VadResult {
|
||||
if (sensitivity == BargeInSensitivity.Off) {
|
||||
return VadResult.NOT_SPEECH
|
||||
}
|
||||
|
||||
val rawSpeech = client.isSpeech(frame)
|
||||
|
||||
if (rawSpeech) {
|
||||
if (consecutiveSpeechCount < profile.consecutiveSpeechFrames) {
|
||||
consecutiveSpeechCount++
|
||||
}
|
||||
if (consecutiveSpeechCount >= profile.consecutiveSpeechFrames) {
|
||||
debounced = true
|
||||
}
|
||||
} else {
|
||||
consecutiveSpeechCount = 0
|
||||
debounced = false
|
||||
}
|
||||
|
||||
return if (debounced) {
|
||||
VadResult(isSpeech = true, probability = 1f)
|
||||
} else {
|
||||
VadResult(isSpeech = false, probability = if (rawSpeech) 1f else 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a sensitivity preset. Updates the library's attack/release
|
||||
* durations and our debouncer's `consecutive` count. Safe to call from
|
||||
* the UI thread; takes effect on the next [analyze].
|
||||
*/
|
||||
fun setSensitivity(sensitivity: BargeInSensitivity) {
|
||||
this.sensitivity = sensitivity
|
||||
val newProfile = SENSITIVITY_PROFILES.getValue(sensitivity)
|
||||
profile = newProfile
|
||||
client.applyProfile(newProfile)
|
||||
// Reset the second-layer debouncer so a sensitivity change doesn't
|
||||
// latch a stale speech count from the previous profile.
|
||||
consecutiveSpeechCount = 0
|
||||
debounced = false
|
||||
}
|
||||
|
||||
/** Release the underlying ONNX session and native resources. */
|
||||
fun close() {
|
||||
client.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Samples per analyze-call at 16 kHz (32 ms). */
|
||||
const val FRAME_SIZE_SAMPLES: Int = 512
|
||||
|
||||
/**
|
||||
* Sensitivity → `(libraryMode, speechDurationMs, silenceDurationMs,
|
||||
* consecutiveSpeechFrames)` map. Tunings come from the B2 unit spec
|
||||
* in `docs/plans/2026-04-17-voice-barge-in.md`.
|
||||
*
|
||||
* The Silero library does not accept an arbitrary threshold float
|
||||
* — it hardcodes one per [Mode]. So we lean on [Mode] for threshold
|
||||
* and use `speech/silenceDurationMs` for the library-layer
|
||||
* attack/release, with our own `consecutiveSpeechFrames` for the
|
||||
* second-layer debouncer.
|
||||
*
|
||||
* Mode mapping (more aggressive = lower threshold = more sensitive):
|
||||
* - [BargeInSensitivity.Low] → [Mode.VERY_AGGRESSIVE] (high thr)
|
||||
* - [BargeInSensitivity.Default] → [Mode.AGGRESSIVE]
|
||||
* - [BargeInSensitivity.High] → [Mode.NORMAL] (lowest thr)
|
||||
*
|
||||
* NOTE: "aggressive" in the Silero library refers to how aggressively
|
||||
* it rejects non-speech (higher threshold), so Low-sensitivity UX
|
||||
* maps to the MORE aggressive library mode.
|
||||
*/
|
||||
internal val SENSITIVITY_PROFILES: Map<BargeInSensitivity, SensitivityProfile> = mapOf(
|
||||
BargeInSensitivity.Off to SensitivityProfile(
|
||||
mode = Mode.VERY_AGGRESSIVE,
|
||||
attackMs = 0,
|
||||
releaseMs = 0,
|
||||
consecutiveSpeechFrames = Int.MAX_VALUE,
|
||||
),
|
||||
BargeInSensitivity.Low to SensitivityProfile(
|
||||
mode = Mode.VERY_AGGRESSIVE,
|
||||
attackMs = 80,
|
||||
releaseMs = 300,
|
||||
consecutiveSpeechFrames = 3,
|
||||
),
|
||||
BargeInSensitivity.Default to SensitivityProfile(
|
||||
mode = Mode.AGGRESSIVE,
|
||||
attackMs = 50,
|
||||
releaseMs = 250,
|
||||
consecutiveSpeechFrames = 2,
|
||||
),
|
||||
BargeInSensitivity.High to SensitivityProfile(
|
||||
mode = Mode.NORMAL,
|
||||
attackMs = 30,
|
||||
releaseMs = 200,
|
||||
consecutiveSpeechFrames = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal seam over the Silero library so unit tests can replace the
|
||||
* native ONNX-backed client with a deterministic fake. Not exposed
|
||||
* publicly — callers always go through [VadEngine].
|
||||
*/
|
||||
internal interface VadClient {
|
||||
fun isSpeech(frame: ShortArray): Boolean
|
||||
fun applyProfile(profile: SensitivityProfile)
|
||||
fun close()
|
||||
}
|
||||
|
||||
internal data class SensitivityProfile(
|
||||
val mode: Mode,
|
||||
val attackMs: Int,
|
||||
val releaseMs: Int,
|
||||
val consecutiveSpeechFrames: Int,
|
||||
)
|
||||
|
||||
private class SileroVadClient(
|
||||
context: Context,
|
||||
sampleRate: SampleRate,
|
||||
) : VadClient {
|
||||
// Built lazily with a default profile so construction doesn't race
|
||||
// with an initial [applyProfile] call from [VadEngine.init].
|
||||
private val vad: VadSilero = VadSilero(
|
||||
context = context,
|
||||
sampleRate = sampleRate,
|
||||
frameSize = FrameSize.FRAME_SIZE_512,
|
||||
mode = Mode.AGGRESSIVE,
|
||||
speechDurationMs = 50,
|
||||
silenceDurationMs = 250,
|
||||
)
|
||||
|
||||
override fun isSpeech(frame: ShortArray): Boolean = vad.isSpeech(frame)
|
||||
|
||||
override fun applyProfile(profile: SensitivityProfile) {
|
||||
vad.mode = profile.mode
|
||||
vad.speechDurationMs = profile.attackMs
|
||||
vad.silenceDurationMs = profile.releaseMs
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
vad.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a single [VadEngine.analyze] call.
|
||||
*
|
||||
* [isSpeech] is the post-debounce decision callers should act on.
|
||||
* [probability] is a best-effort confidence hint — Silero's public API
|
||||
* exposes only a boolean, so we surface a coarse 0f/1f until we swap to a
|
||||
* backend that gives us the raw score.
|
||||
*/
|
||||
data class VadResult(
|
||||
val isSpeech: Boolean,
|
||||
val probability: Float,
|
||||
) {
|
||||
companion object {
|
||||
internal val NOT_SPEECH = VadResult(isSpeech = false, probability = 0f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toSampleRate(): SampleRate = when (this) {
|
||||
8_000 -> SampleRate.SAMPLE_RATE_8K
|
||||
16_000 -> SampleRate.SAMPLE_RATE_16K
|
||||
else -> error("Unsupported sample rate: $this")
|
||||
}
|
||||
@@ -1,30 +1,59 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import android.content.Context
|
||||
import android.media.audiofx.Visualizer
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
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
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Plays a TTS audio file emitted by the relay's `/voice/synthesize` endpoint
|
||||
* Plays TTS audio files emitted by the relay's `/voice/synthesize` endpoint
|
||||
* and exposes a live [amplitude] flow for the MorphingSphere / UI meter.
|
||||
*
|
||||
* Backed by a single Media3 [ExoPlayer] that lives for the lifetime of this
|
||||
* [VoicePlayer] instance. [play] appends a new [MediaItem] to the player's
|
||||
* queue so adjacent TTS sentences play back-to-back without the per-file
|
||||
* codec re-init seam that the old `MediaPlayer` implementation produced.
|
||||
* This is the foundation of the Wave 1 gapless-playback work in the voice
|
||||
* quality pass plan — V5 in `docs/plans/2026-04-16-voice-quality-pass.md`.
|
||||
*
|
||||
* Amplitude is computed from [Visualizer] PCM waveform RMS — one waveform
|
||||
* snapshot is captured every ~40 ms (the visualizer's default capture rate)
|
||||
* and reduced to a 0..1 float. On devices that don't allow Visualizer
|
||||
* construction (missing MODIFY_AUDIO_SETTINGS or OEM quirks) we log and
|
||||
* continue with amplitude pinned at 0 rather than crashing the voice session.
|
||||
*
|
||||
* One player instance owns at most one active playback. Calling [play] again
|
||||
* while something is playing stops the previous file first.
|
||||
* The Visualizer is attached exactly once against the ExoPlayer's
|
||||
* [ExoPlayer.getAudioSessionId]. There is a known gotcha where re-attaching
|
||||
* the Visualizer on every track transition invalidates the session id — the
|
||||
* single-attach lifecycle here sidesteps it entirely.
|
||||
*
|
||||
* @param context used for [ExoPlayer.Builder]. Application context is fine;
|
||||
* the player holds no view references.
|
||||
* @param exoPlayerFactory seam for unit tests — production defaults to a
|
||||
* real Media3 `ExoPlayer.Builder` with `setHandleAudioBecomingNoisy`.
|
||||
* Tests inject a MockK mock directly to avoid
|
||||
* `mockkConstructor(ExoPlayer.Builder::class)`, which fails on the
|
||||
* JVM unit test classpath because Media3's `Builder` static init
|
||||
* chain pulls in android.os.Looper etc. that aren't shadowed there.
|
||||
*/
|
||||
class VoicePlayer {
|
||||
@OptIn(UnstableApi::class)
|
||||
class VoicePlayer(
|
||||
context: Context,
|
||||
exoPlayerFactory: (Context) -> ExoPlayer = ::defaultExoPlayer,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoicePlayer"
|
||||
@@ -34,99 +63,259 @@ class VoicePlayer {
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
// Mirrors the most recent value passed to [setVolume] / [duck] / [unduck].
|
||||
// ExoPlayer's own `volume` getter is the source of truth for the audio
|
||||
// pipeline, but we keep a local copy so callers can introspect current
|
||||
// ducking state without racing the underlying ExoPlayer thread, and so
|
||||
// future reconfig paths (reconstruct ExoPlayer, swap sink, etc.) can
|
||||
// re-apply the same volume without losing the caller's intent.
|
||||
@Volatile private var currentVolume: Float = 1f
|
||||
|
||||
// Tracked via Player.Listener.onIsPlayingChanged so awaitCompletion can
|
||||
// suspend on the combined (isPlaying, mediaItemCount) signal without
|
||||
// polling the player from arbitrary threads.
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
|
||||
// Logical count of media items still owned by this playback turn. ExoPlayer
|
||||
// retains played playlist items after STATE_ENDED, so this cannot mirror
|
||||
// mediaItemCount blindly at end-of-queue.
|
||||
private val _queueCount = MutableStateFlow(0)
|
||||
|
||||
private var visualizer: Visualizer? = null
|
||||
private var completionListener: (() -> Unit)? = 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
|
||||
if (!isPlaying) _amplitude.value = 0f
|
||||
// Lazily attach the Visualizer the first time playback
|
||||
// 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) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
// Refresh on every transition — covers auto-advance drain
|
||||
// at end-of-queue and explicit seekToNext paths.
|
||||
_queueCount.value = exoPlayer.mediaItemCount
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
when (state) {
|
||||
Player.STATE_ENDED -> {
|
||||
// Media3 keeps consumed playlist entries around. Clear
|
||||
// them here so awaitCompletion observes a true drain and
|
||||
// voice mode can leave Speaking when the last TTS chunk ends.
|
||||
exoPlayer.clearMediaItems()
|
||||
_queueCount.value = 0
|
||||
_isPlaying.value = false
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
if (exoPlayer.mediaItemCount == 0) {
|
||||
_queueCount.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback of [audioFile]. Returns immediately; completion is
|
||||
* delivered via [awaitCompletion]. If another file is already playing,
|
||||
* [stop]s it first.
|
||||
* Append [audioFile] to the ExoPlayer queue. If the player is idle, also
|
||||
* [ExoPlayer.prepare] and [ExoPlayer.play]. Non-blocking — completion is
|
||||
* delivered via [awaitCompletion], which now observes the entire queue
|
||||
* rather than a single file.
|
||||
*/
|
||||
fun play(audioFile: File) {
|
||||
if (mediaPlayer != null) {
|
||||
Log.w(TAG, "play called while another file is playing — stopping first")
|
||||
stop()
|
||||
val wasIdle = exoPlayer.mediaItemCount == 0 &&
|
||||
exoPlayer.playbackState != Player.STATE_READY &&
|
||||
exoPlayer.playbackState != Player.STATE_BUFFERING
|
||||
exoPlayer.addMediaItem(MediaItem.fromUri(audioFile.toUri()))
|
||||
_queueCount.value = exoPlayer.mediaItemCount
|
||||
if (wasIdle) {
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.play()
|
||||
} else if (!exoPlayer.isPlaying && exoPlayer.playWhenReady.not()) {
|
||||
// Queue had drained but player wasn't torn down — restart.
|
||||
exoPlayer.play()
|
||||
}
|
||||
|
||||
val player = MediaPlayer()
|
||||
try {
|
||||
player.setDataSource(audioFile.absolutePath)
|
||||
player.prepare()
|
||||
player.setOnCompletionListener {
|
||||
_amplitude.value = 0f
|
||||
completionListener?.invoke()
|
||||
}
|
||||
player.setOnErrorListener { _, what, extra ->
|
||||
Log.e(TAG, "MediaPlayer error: what=$what extra=$extra")
|
||||
_amplitude.value = 0f
|
||||
completionListener?.invoke()
|
||||
true
|
||||
}
|
||||
player.start()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MediaPlayer setup failed: ${e.message}")
|
||||
try { player.release() } catch (_: Exception) { /* ignore */ }
|
||||
throw e
|
||||
}
|
||||
|
||||
mediaPlayer = player
|
||||
attachVisualizer(player)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend until the current playback completes or errors. Cancellable —
|
||||
* if the caller cancels, playback is left running (use [stop] for a
|
||||
* hard teardown).
|
||||
* Suspend until the ExoPlayer queue is drained AND playback has stopped.
|
||||
*
|
||||
* **Semantic change from the old MediaPlayer implementation.** Previously
|
||||
* this returned when the *current file* completed. Now it returns when
|
||||
* the entire logical queue has been consumed — i.e. `_queueCount == 0 &&
|
||||
* !isPlaying`. This matches the gapless-playback model where adjacent
|
||||
* sentences play back-to-back from the same ExoPlayer, and it's exactly
|
||||
* what the V4 prefetch pipelining rewrite needs (synth worker can enqueue
|
||||
* N+1 while play worker is still awaiting queue-drain on N).
|
||||
*
|
||||
* If a caller appends new items to the queue while this is suspended,
|
||||
* the wait extends through the new items as well.
|
||||
*
|
||||
* Cancellable. If the caller cancels, playback is left running — use
|
||||
* [stop] for a hard teardown.
|
||||
*/
|
||||
suspend fun awaitCompletion(): Unit = suspendCancellableCoroutine { cont ->
|
||||
if (mediaPlayer == null) {
|
||||
cont.resume(Unit)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
completionListener = {
|
||||
completionListener = null
|
||||
if (cont.isActive) cont.resume(Unit)
|
||||
}
|
||||
cont.invokeOnCancellation {
|
||||
completionListener = null
|
||||
}
|
||||
suspend fun awaitCompletion() {
|
||||
// Fast-path: already idle.
|
||||
if (_queueCount.value == 0 && !_isPlaying.value) return
|
||||
combine(_queueCount, _isPlaying) { count, playing -> count == 0 && !playing }
|
||||
.first { drained -> drained }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard teardown: stop playback, release visualizer + player, reset
|
||||
* amplitude. Safe to call repeatedly.
|
||||
* Hard teardown of the current playback session. Clears the queue,
|
||||
* stops ExoPlayer, releases the Visualizer, and resets amplitude.
|
||||
* The ExoPlayer itself is kept alive for reuse — the next [play] call
|
||||
* will re-prepare it. Safe to call repeatedly.
|
||||
*/
|
||||
fun stop() {
|
||||
completionListener = null
|
||||
exoPlayer.clearMediaItems()
|
||||
exoPlayer.stop()
|
||||
_queueCount.value = 0
|
||||
_isPlaying.value = false
|
||||
|
||||
visualizer?.let { v ->
|
||||
try { v.enabled = false } catch (_: Exception) { /* ignore */ }
|
||||
try { v.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
visualizer = null
|
||||
|
||||
mediaPlayer?.let { p ->
|
||||
try {
|
||||
if (p.isPlaying) p.stop()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
try { p.reset() } catch (_: Exception) { /* ignore */ }
|
||||
try { p.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
mediaPlayer = null
|
||||
visualizerAttached = false
|
||||
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* True if there's an active [MediaPlayer]. Doesn't check `isPlaying` —
|
||||
* that would race with the completion listener.
|
||||
* True if the ExoPlayer has any queued media items (playing or paused
|
||||
* mid-queue). Matches the old semantic of "there's audio in flight".
|
||||
*/
|
||||
fun isPlaying(): Boolean = mediaPlayer != null
|
||||
fun isPlaying(): Boolean = _queueCount.value > 0
|
||||
|
||||
private fun attachVisualizer(player: MediaPlayer) {
|
||||
/**
|
||||
* Current ExoPlayer audio session id. Returns `0` until the underlying
|
||||
* [android.media.AudioTrack] has been allocated — Media3 defers that
|
||||
* allocation to first playback on most devices. Callers that need a
|
||||
* non-zero session id (barge-in's [android.media.audiofx.AcousticEchoCanceler]
|
||||
* attach path in [com.hermesandroid.relay.audio.BargeInListener]) should
|
||||
* poll this property briefly rather than assume it's hot-ready at
|
||||
* [VoicePlayer] construction time.
|
||||
*
|
||||
* **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() = cachedAudioSessionId
|
||||
|
||||
/**
|
||||
* Set the playback volume of the underlying ExoPlayer.
|
||||
*
|
||||
* **Barge-in use case.** The barge-in pipeline (see
|
||||
* `docs/plans/2026-04-17-voice-barge-in.md`, unit B6) runs the mic
|
||||
* through a Silero VAD while TTS plays. On a *single* "maybe speech"
|
||||
* frame — one positive frame that hasn't yet passed the hysteresis
|
||||
* debounce — we soft-duck via [duck] instead of hard-stopping. If the
|
||||
* speech is confirmed (enough consecutive positive frames pass the
|
||||
* debounce), [VoiceViewModel] calls the hard-stop path
|
||||
* (`interruptSpeaking()`); if the frame was a false positive, a
|
||||
* watchdog re-calls [unduck] to restore full volume. The result is a
|
||||
* fast-reacting but false-positive-tolerant interruption feel.
|
||||
*
|
||||
* @param volume linear gain in the range `0f..1f`; values outside this
|
||||
* range are clamped. Forwarded verbatim to `ExoPlayer.volume`.
|
||||
*/
|
||||
fun setVolume(volume: Float) {
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
currentVolume = clamped
|
||||
exoPlayer.volume = clamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-duck TTS to 30% of full volume. See [setVolume] for context —
|
||||
* used by barge-in on a single VAD positive frame, before the
|
||||
* hysteresis debounce confirms an actual interruption.
|
||||
*/
|
||||
fun duck() {
|
||||
setVolume(0.3f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore TTS to full volume. Pair with [duck]; safe to call even if
|
||||
* not currently ducked.
|
||||
*/
|
||||
fun unduck() {
|
||||
setVolume(1.0f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully release the underlying ExoPlayer. Call when the owning scope is
|
||||
* being destroyed; the VoicePlayer instance is unusable after this.
|
||||
*/
|
||||
fun release() {
|
||||
stop()
|
||||
exoPlayer.release()
|
||||
}
|
||||
|
||||
private fun attachVisualizer(audioSessionId: Int) {
|
||||
if (audioSessionId == 0) {
|
||||
// ExoPlayer returns 0 before the audio track is allocated; retry
|
||||
// on the next playback-start event.
|
||||
return
|
||||
}
|
||||
try {
|
||||
val viz = Visualizer(player.audioSessionId)
|
||||
val viz = Visualizer(audioSessionId)
|
||||
viz.captureSize = VISUALIZER_SIZE_BYTES.coerceIn(
|
||||
Visualizer.getCaptureSizeRange()[0],
|
||||
Visualizer.getCaptureSizeRange()[1],
|
||||
@@ -154,13 +343,16 @@ class VoicePlayer {
|
||||
)
|
||||
viz.enabled = true
|
||||
visualizer = viz
|
||||
visualizerAttached = true
|
||||
} catch (e: Exception) {
|
||||
// Some devices refuse Visualizer (MODIFY_AUDIO_SETTINGS denied,
|
||||
// OEM restrictions). Fall back to flat-zero amplitude rather
|
||||
// than killing the voice session.
|
||||
// than killing the voice session. Mark as "attached" so we don't
|
||||
// keep retrying on every isPlaying transition.
|
||||
Log.w(TAG, "Visualizer unavailable — amplitude stuck at 0: ${e.message}")
|
||||
_amplitude.value = 0f
|
||||
visualizer = null
|
||||
visualizerAttached = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,3 +381,14 @@ class VoicePlayer {
|
||||
else normalized.coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production ExoPlayer factory — used as the default for [VoicePlayer].
|
||||
* Split out as a top-level function so unit tests can swap it for a
|
||||
* MockK mock without touching Media3's `Builder` class loader.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun defaultExoPlayer(context: Context): ExoPlayer =
|
||||
ExoPlayer.Builder(context)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
@@ -2,60 +2,47 @@ package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures the user's voice into an `.m4a` (AAC-in-MP4) file for V2a voice
|
||||
* mode. The relay's `/voice/transcribe` endpoint feeds this to whisper-1 via
|
||||
* OpenAI, which accepts m4a/mp4 natively.
|
||||
* Captures the user's voice as 16 kHz mono PCM and writes a `.wav` file for
|
||||
* the relay STT endpoint. The raw PCM is retained for the server-mediated
|
||||
* `/voice/realtime/{session}` path so the main voice UI can send the same utterance
|
||||
* through the realtime websocket without opening a second microphone stream.
|
||||
*
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter) —
|
||||
* driven by polling `MediaRecorder.maxAmplitude` every ~16 ms. The polling
|
||||
* coroutine runs on the caller-supplied [scope] so it dies with the owning
|
||||
* ViewModel.
|
||||
*
|
||||
* One recorder instance owns at most one active recording at a time. Calling
|
||||
* [startRecording] again while a recording is in flight will stop the
|
||||
* previous one first. [stopRecording] is safe to call when nothing is
|
||||
* running (it just returns the last file, or throws if there never was one).
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter). The
|
||||
* value is computed from the same PCM frames that are written to disk, which
|
||||
* keeps legacy STT fallback and realtime voice testing on a single capture
|
||||
* path.
|
||||
*/
|
||||
class VoiceRecorder(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@Suppress("UNUSED_PARAMETER") private val scope: kotlinx.coroutines.CoroutineScope,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceRecorder"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val BIT_RATE = 64_000
|
||||
private const val AMPLITUDE_POLL_MS = 16L
|
||||
private const val BYTES_PER_SAMPLE = 2
|
||||
private const val CHANNEL_COUNT = 1
|
||||
private const val MAX_AMPLITUDE_SHORT = 32_767f
|
||||
private const val MAX_PCM_BYTES = 25 * 1024 * 1024
|
||||
|
||||
// Perceptual amplitude mapping constants. Raw PCM peak values from
|
||||
// MediaRecorder.maxAmplitude for a phone at arm's length:
|
||||
// silence / ambient : 100..500 (≤0.015 of max)
|
||||
// quiet speech : 500..3000 (0.015..0.09)
|
||||
// normal speech : 3000..8000 (0.09..0.24)
|
||||
// loud speech : 8000..18000 (0.24..0.55)
|
||||
// shout / clipping : 18000..32767 (0.55..1.0)
|
||||
//
|
||||
// Linear 0..1 puts normal conversation between 0.09 and 0.24 — the
|
||||
// meter barely moves. Subtract a noise floor, rescale into the
|
||||
// speech-ceiling window, then apply a sqrt curve so quiet speech
|
||||
// still registers visually without drowning loud speech at the top.
|
||||
// Keep the perceptual curve from the previous MediaRecorder-backed
|
||||
// implementation so the on-screen meter feels the same.
|
||||
private const val NOISE_FLOOR = 0.01f
|
||||
private const val SPEECH_CEILING = 0.35f
|
||||
}
|
||||
@@ -63,162 +50,231 @@ class VoiceRecorder(
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
val sampleRate: Int get() = SAMPLE_RATE
|
||||
|
||||
private val bufferLock = Any()
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var currentOutputFile: File? = null
|
||||
private var pollJob: Job? = null
|
||||
private var readThread: Thread? = null
|
||||
private var readDone: CountDownLatch? = null
|
||||
private var pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
private var lastPcmBytes: ByteArray = ByteArray(0)
|
||||
|
||||
/**
|
||||
* Begin a new recording. Returns the output [File] that will receive the
|
||||
* audio once [stopRecording] is called. Throws on permission failure or
|
||||
* encoder init failure — callers should catch and surface to the UI.
|
||||
* Begin a new recording. Returns the output [File] that will contain WAV
|
||||
* audio once [stopRecording] is called.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startRecording(): File {
|
||||
// Defensive: if a recording is somehow still running, tear it down
|
||||
// before starting a new one. MediaRecorder transitions are strict.
|
||||
if (mediaRecorder != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight — stopping it first")
|
||||
if (audioRecord != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight; stopping it first")
|
||||
try {
|
||||
stopRecording()
|
||||
} catch (_: Exception) {
|
||||
// Swallow — we're about to overwrite state anyway.
|
||||
releaseRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.m4a")
|
||||
currentOutputFile = outFile
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
|
||||
|
||||
val recorder = buildRecorder()
|
||||
try {
|
||||
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
recorder.setAudioSamplingRate(SAMPLE_RATE)
|
||||
recorder.setAudioEncodingBitRate(BIT_RATE)
|
||||
recorder.setAudioChannels(1)
|
||||
recorder.setOutputFile(outFile.absolutePath)
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, "MediaRecorder failed to start: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.wav")
|
||||
currentOutputFile = outFile
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
stopRequested.set(false)
|
||||
_amplitude.value = 0f
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
throw IllegalStateException("AudioRecord failed to initialize")
|
||||
}
|
||||
|
||||
try {
|
||||
recorder.startRecording()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MediaRecorder setup failed: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
}
|
||||
|
||||
mediaRecorder = recorder
|
||||
startAmplitudePolling()
|
||||
audioRecord = recorder
|
||||
val done = CountDownLatch(1)
|
||||
readDone = done
|
||||
readThread = Thread(
|
||||
{
|
||||
readPcmLoop(recorder, minBuffer)
|
||||
done.countDown()
|
||||
},
|
||||
"HermesVoiceRecorder",
|
||||
).also { it.start() }
|
||||
return outFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the active recording, flush the encoder, and return the completed
|
||||
* output [File]. Safe to call when nothing is recording — in that case
|
||||
* it returns the last file produced, or throws if there never was one.
|
||||
* Stop the active recording, write the WAV container, and return it.
|
||||
*/
|
||||
fun stopRecording(): File {
|
||||
val file = currentOutputFile
|
||||
?: throw IllegalStateException("stopRecording called with no active recording")
|
||||
|
||||
stopAmplitudePolling()
|
||||
|
||||
val recorder = mediaRecorder
|
||||
if (recorder != null) {
|
||||
val record = audioRecord
|
||||
stopRequested.set(true)
|
||||
if (record != null) {
|
||||
try {
|
||||
recorder.stop()
|
||||
record.stop()
|
||||
} catch (e: IllegalStateException) {
|
||||
// MediaRecorder.stop throws if called before any audio was
|
||||
// captured (sub-300ms recordings). Treat as recoverable —
|
||||
// the output file may be 0 bytes but the caller can check.
|
||||
Log.w(TAG, "MediaRecorder.stop threw — recording may be empty: ${e.message}")
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "MediaRecorder.stop runtime error: ${e.message}")
|
||||
} finally {
|
||||
releaseRecorder()
|
||||
Log.w(TAG, "AudioRecord.stop threw; recording may be empty: ${e.message}")
|
||||
}
|
||||
}
|
||||
readDone?.await(1, TimeUnit.SECONDS)
|
||||
releaseRecorder()
|
||||
|
||||
val pcm = synchronized(bufferLock) {
|
||||
pcmBuffer.toByteArray().also { lastPcmBytes = it }
|
||||
}
|
||||
writeWav(file, pcm)
|
||||
_amplitude.value = 0f
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a recording is currently active. Cheap — just checks whether
|
||||
* we have a live [MediaRecorder] reference.
|
||||
*/
|
||||
fun isRecording(): Boolean = mediaRecorder != null
|
||||
fun isRecording(): Boolean = audioRecord != null && !stopRequested.get()
|
||||
|
||||
fun lastPcmBytes(): ByteArray = synchronized(bufferLock) {
|
||||
lastPcmBytes.copyOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release any recorder resources without returning a file. Safe fallback
|
||||
* for error paths where the output file is known-invalid.
|
||||
* Release any recorder resources without returning a file.
|
||||
*/
|
||||
fun cancel() {
|
||||
stopAmplitudePolling()
|
||||
mediaRecorder?.let { r ->
|
||||
try {
|
||||
r.stop()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
stopRequested.set(true)
|
||||
audioRecord?.let { record ->
|
||||
try { record.stop() } catch (_: Exception) { }
|
||||
}
|
||||
readDone?.await(500, TimeUnit.MILLISECONDS)
|
||||
releaseRecorder()
|
||||
currentOutputFile?.let { f ->
|
||||
try { f.delete() } catch (_: Exception) { /* ignore */ }
|
||||
currentOutputFile?.let { file ->
|
||||
try { file.delete() } catch (_: Exception) { }
|
||||
}
|
||||
currentOutputFile = null
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer.reset()
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
mediaRecorder?.let { r ->
|
||||
try { r.reset() } catch (_: Exception) { /* ignore */ }
|
||||
try { r.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
mediaRecorder = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun buildRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
private fun startAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
val recorder = mediaRecorder ?: break
|
||||
val raw = try {
|
||||
recorder.maxAmplitude
|
||||
} catch (e: IllegalStateException) {
|
||||
// Recorder torn down under us — exit quietly.
|
||||
break
|
||||
private fun readPcmLoop(record: AudioRecord, minBuffer: Int) {
|
||||
val buffer = ByteArray(minBuffer)
|
||||
while (!stopRequested.get()) {
|
||||
val read = try {
|
||||
record.read(buffer, 0, buffer.size)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AudioRecord.read failed: ${e.message}")
|
||||
break
|
||||
}
|
||||
if (read > 0) {
|
||||
synchronized(bufferLock) {
|
||||
if (pcmBuffer.size() + read <= MAX_PCM_BYTES) {
|
||||
pcmBuffer.write(buffer, 0, read)
|
||||
} else {
|
||||
stopRequested.set(true)
|
||||
}
|
||||
}
|
||||
val raw01 = (raw.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
delay(AMPLITUDE_POLL_MS)
|
||||
updateAmplitude(buffer, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
private fun updateAmplitude(buffer: ByteArray, read: Int) {
|
||||
var peak = 0
|
||||
var index = 0
|
||||
val usable = read - (read % BYTES_PER_SAMPLE)
|
||||
while (index < usable) {
|
||||
val low = buffer[index].toInt() and 0xff
|
||||
val high = buffer[index + 1].toInt()
|
||||
val sample = (high shl 8) or low
|
||||
val abs = kotlin.math.abs(sample.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
|
||||
if (abs > peak) peak = abs
|
||||
index += BYTES_PER_SAMPLE
|
||||
}
|
||||
val raw01 = (peak.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
audioRecord?.let { record ->
|
||||
try { record.release() } catch (_: Exception) { }
|
||||
}
|
||||
audioRecord = null
|
||||
readThread = null
|
||||
readDone = null
|
||||
}
|
||||
|
||||
private fun writeWav(file: File, pcm: ByteArray) {
|
||||
try {
|
||||
file.outputStream().use { out ->
|
||||
out.write(wavHeader(pcm.size))
|
||||
out.write(pcm)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw IOException("Failed to write WAV recording: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun wavHeader(pcmBytes: Int): ByteArray {
|
||||
val totalDataLen = pcmBytes + 36
|
||||
val byteRate = SAMPLE_RATE * CHANNEL_COUNT * BYTES_PER_SAMPLE
|
||||
return ByteArray(44).also { header ->
|
||||
fun ascii(offset: Int, value: String) {
|
||||
value.encodeToByteArray().copyInto(header, offset)
|
||||
}
|
||||
fun leInt(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
header[offset + 2] = ((value shr 16) and 0xff).toByte()
|
||||
header[offset + 3] = ((value shr 24) and 0xff).toByte()
|
||||
}
|
||||
fun leShort(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
}
|
||||
|
||||
ascii(0, "RIFF")
|
||||
leInt(4, totalDataLen)
|
||||
ascii(8, "WAVE")
|
||||
ascii(12, "fmt ")
|
||||
leInt(16, 16)
|
||||
leShort(20, 1)
|
||||
leShort(22, CHANNEL_COUNT)
|
||||
leInt(24, SAMPLE_RATE)
|
||||
leInt(28, byteRate)
|
||||
leShort(32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
|
||||
leShort(34, 16)
|
||||
ascii(36, "data")
|
||||
leInt(40, pcmBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,32 @@ package com.hermesandroid.relay.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -33,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.
|
||||
*
|
||||
@@ -57,17 +74,196 @@ sealed class AuthState {
|
||||
class AuthManager(
|
||||
private val context: Context,
|
||||
private val multiplexer: ChannelMultiplexer,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
/**
|
||||
* Multi-connection: the id of the [com.hermesandroid.relay.data.Connection]
|
||||
* this AuthManager is bound to. Drives which EncryptedSharedPreferences
|
||||
* file the underlying [SessionTokenStore] reads/writes.
|
||||
*
|
||||
* Defaults to [CONNECTION_ID_LEGACY] so the pre-multi-connection call site
|
||||
* in `ConnectionViewModel` still compiles. Worker B removes the default
|
||||
* and passes a real connection id when they wire the active connection
|
||||
* through.
|
||||
*/
|
||||
private val connectionId: String = CONNECTION_ID_LEGACY,
|
||||
/**
|
||||
* Exact EncryptedSharedPreferences filename for this connection. New
|
||||
* connections use the deterministic id-derived name, but the migrated
|
||||
* legacy connection intentionally keeps [Connection.LEGACY_TOKEN_STORE_KEY].
|
||||
*/
|
||||
private val tokenStoreKey: String? = null,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
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')
|
||||
|
||||
/**
|
||||
* Sentinel [connectionId] meaning "bind this AuthManager to the legacy
|
||||
* single-connection EncryptedSharedPreferences file
|
||||
* ([Connection.LEGACY_TOKEN_STORE_KEY])". Used as the default ctor arg
|
||||
* so existing call sites don't need to change until Worker B threads
|
||||
* a real connection id through.
|
||||
*/
|
||||
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
|
||||
* can delete the per-device route list before deleting the token
|
||||
* store backing file.
|
||||
*/
|
||||
suspend fun readStoredDeviceId(context: Context, tokenStoreKey: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val appContext = context.applicationContext
|
||||
val primary = KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
primary.getString(KEY_DEVICE_ID)
|
||||
?: if (tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
runCatching {
|
||||
LegacyEncryptedPrefsTokenStore(appContext).getString(KEY_DEVICE_ID)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* exercisable from a pure JVM unit test without constructing an
|
||||
* Android [Context] / [kotlinx.coroutines.CoroutineScope].
|
||||
*
|
||||
* Defensive rules, in order:
|
||||
* - Non-[JsonObject] entries (stray strings, numbers) are dropped.
|
||||
* - An entry missing `name` is dropped — the picker has no label
|
||||
* to render for it.
|
||||
* - `model` defaults to `"unknown"` so a profile without a model
|
||||
* still renders as a selectable chip (server misconfiguration,
|
||||
* but we don't want to silently drop the only profile).
|
||||
* - `description` defaults to `""`.
|
||||
* - `system_message` is passed through as-is, including JSON `null`.
|
||||
* A null or missing value means "this profile has no SOUL.md on
|
||||
* disk — fall back to the personality/default system prompt at
|
||||
* send time". Kept separate from an empty string so ChatViewModel
|
||||
* can cleanly detect "no override" via `systemMessage?.isNotBlank()`.
|
||||
* - `gateway_running`, `has_soul`, `skill_count` (v0.7.0 runtime
|
||||
* metadata) are optional on the wire. Missing / malformed values
|
||||
* fall back to `false` / `false` / `0` so older relays stay
|
||||
* compatible and bad server data can't crash the pairing handshake.
|
||||
* - `api_server_*` metadata is optional. When present, it lets the
|
||||
* client route chat through a profile's isolated Hermes API
|
||||
* server without exposing that profile server's key.
|
||||
*/
|
||||
fun parseAgentProfiles(array: JsonArray): List<Profile> {
|
||||
return array.mapNotNull { entry ->
|
||||
val obj = entry as? JsonObject ?: return@mapNotNull null
|
||||
val name = obj["name"]?.jsonPrimitive?.contentOrNull
|
||||
?: return@mapNotNull null
|
||||
val model = obj["model"]?.jsonPrimitive?.contentOrNull
|
||||
?: "unknown"
|
||||
val description = obj["description"]?.jsonPrimitive?.contentOrNull
|
||||
?: ""
|
||||
val systemMessage = obj["system_message"]?.jsonPrimitive?.contentOrNull
|
||||
val gatewayRunning = obj["gateway_running"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val hasSoul = obj["has_soul"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val skillCount = obj["skill_count"]
|
||||
?.jsonPrimitive?.intOrNull ?: 0
|
||||
val apiServerEnabled = obj["api_server_enabled"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val apiServerUrl = obj["api_server_url"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerHost = obj["api_server_host"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerPort = obj["api_server_port"]
|
||||
?.jsonPrimitive?.intOrNull
|
||||
val apiServerKeyPresent = obj["api_server_key_present"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
Profile(
|
||||
name = name,
|
||||
model = model,
|
||||
description = description,
|
||||
systemMessage = systemMessage,
|
||||
gatewayRunning = gatewayRunning,
|
||||
hasSoul = hasSoul,
|
||||
skillCount = skillCount,
|
||||
apiServerEnabled = apiServerEnabled,
|
||||
apiServerUrl = apiServerUrl,
|
||||
apiServerHost = apiServerHost,
|
||||
apiServerPort = apiServerPort,
|
||||
apiServerKeyPresent = apiServerKeyPresent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -77,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
|
||||
@@ -92,8 +330,26 @@ class AuthManager(
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// 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.
|
||||
// Both encrypted backends decrypt their Tink keyset eagerly on
|
||||
// construction, so a corrupt file can throw AEADBadTagException
|
||||
// here. KeystoreTokenStore.tryCreate already degrades to null;
|
||||
// the legacy store self-heals its file in its constructor. If
|
||||
// even that rebuild fails (a fundamentally broken keystore),
|
||||
// fall back to a non-persistent store rather than force-close —
|
||||
// the user re-pairs, but the app stays up.
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context) ?: LegacyEncryptedPrefsTokenStore(context)
|
||||
KeystoreTokenStore.tryCreate(context, tokenPrefsName)
|
||||
?: runCatching {
|
||||
LegacyEncryptedPrefsTokenStore(context, tokenPrefsName)
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Legacy token store unavailable (${e.message}) — using in-memory fallback; re-pair required")
|
||||
InMemoryTokenStore()
|
||||
}
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
@@ -109,13 +365,24 @@ class AuthManager(
|
||||
*/
|
||||
private fun migrateFromLegacyIfNeeded(picked: SessionTokenStore) {
|
||||
if (picked is LegacyEncryptedPrefsTokenStore) return
|
||||
// Multi-connection: only the legacy connection inherits from the pre-
|
||||
// multi-connection `hermes_companion_auth` file. A freshly-minted
|
||||
// per-connection store must NOT be seeded from the legacy file or
|
||||
// we'd copy connection 0's token into every new connection.
|
||||
if (connectionId != CONNECTION_ID_LEGACY) return
|
||||
val legacy = try {
|
||||
LegacyEncryptedPrefsTokenStore(context)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
|
||||
val keysToMigrate = listOf(KEY_SESSION_TOKEN, KEY_DEVICE_ID, KEY_API_KEY, KEY_PAIRED_META)
|
||||
val keysToMigrate = listOf(
|
||||
KEY_SESSION_TOKEN,
|
||||
KEY_REFRESH_TOKEN,
|
||||
KEY_DEVICE_ID,
|
||||
KEY_API_KEY,
|
||||
KEY_PAIRED_META,
|
||||
)
|
||||
var migrated = false
|
||||
for (k in keysToMigrate) {
|
||||
val existing = legacy.getString(k) ?: continue
|
||||
@@ -207,8 +474,37 @@ class AuthManager(
|
||||
*/
|
||||
private var pendingGrants: Map<String, Long>? = null
|
||||
|
||||
private val _profiles = MutableStateFlow<List<String>>(emptyList())
|
||||
val profiles: StateFlow<List<String>> = _profiles.asStateFlow()
|
||||
/**
|
||||
* Optional endpoint-candidate list from the pairing QR (ADR 24, v3+).
|
||||
* Persisted via [PairingPreferences.setDeviceEndpoints] once the server
|
||||
* confirms the pair in `auth.ok` so the reachability probe + network-aware
|
||||
* switch (Kt-Probe) can read them on subsequent connects.
|
||||
*
|
||||
* `null` means "no endpoint list to persist on this pair" — either a v1/v2
|
||||
* QR hit the legacy path without going through [setPendingEndpoints], or
|
||||
* we're in a session-token refresh where the endpoint list doesn't change.
|
||||
* Either way, we leave the previously-persisted list untouched.
|
||||
*/
|
||||
private var pendingEndpoints: List<EndpointCandidate>? = null
|
||||
|
||||
/**
|
||||
* Server-advertised agent profiles from the `auth.ok` payload's
|
||||
* `profiles` field. Each entry corresponds to a named agent config in
|
||||
* the server's `~/.hermes/config.yaml` (see Hermes's `_load_profiles`).
|
||||
*
|
||||
* This replaces the old `_sessionLabels` field (2026-04-18, Pass 2).
|
||||
* The previous code parsed each entry as a raw [String] via
|
||||
* `it.jsonPrimitive.content`, which blew up silently on the real
|
||||
* object-shaped payload the server actually sends — so the list was
|
||||
* always empty in practice. [parseAgentProfiles] is the structured
|
||||
* replacement.
|
||||
*
|
||||
* Exposed via [com.hermesandroid.relay.viewmodel.ConnectionViewModel.agentProfiles]
|
||||
* to the profile picker UI. Empty when unpaired or when the server
|
||||
* returned no `profiles` entry.
|
||||
*/
|
||||
private val _agentProfiles = MutableStateFlow<List<Profile>>(emptyList())
|
||||
val agentProfiles: StateFlow<List<Profile>> = _agentProfiles.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether an API key is currently stored. Updated reactively by
|
||||
@@ -221,6 +517,12 @@ class AuthManager(
|
||||
init {
|
||||
// Register as system channel handler for auth messages
|
||||
multiplexer.registerHandler("system", this)
|
||||
// Also listen on the "pairing" channel for server-initiated
|
||||
// pushes — currently just profiles.updated (v0.7.1+ relay).
|
||||
// Routed through the same onMessage dispatcher which switches
|
||||
// by envelope type so adding new pairing.* events later is a
|
||||
// one-line change in [onMessage].
|
||||
multiplexer.registerHandler("pairing", this)
|
||||
|
||||
// Check for existing session token off main thread
|
||||
scope.launch {
|
||||
@@ -237,7 +539,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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +626,9 @@ class AuthManager(
|
||||
*/
|
||||
suspend fun getOrCreateDeviceId(): String = getDeviceId()
|
||||
|
||||
/** Existing device ID without creating a new one. */
|
||||
suspend fun getExistingDeviceId(): String? = store().getString(KEY_DEVICE_ID)
|
||||
|
||||
/**
|
||||
* Set the TTL the user picked at [SessionTtlPickerDialog]. `0` → never,
|
||||
* `null` → defer to server default. Persisted across [authenticate]
|
||||
@@ -341,6 +648,21 @@ class AuthManager(
|
||||
pendingGrants = grants
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage the endpoint-candidate list parsed from the pairing QR (ADR 24).
|
||||
* Consumed in [handleAuthOk] — after the server confirms the pair we
|
||||
* persist the list under the current device id via
|
||||
* [PairingPreferences.setDeviceEndpoints].
|
||||
*
|
||||
* Safe to call with `null` or an empty list — either clears any staged
|
||||
* value without persisting. Pair-code re-sends from an existing
|
||||
* [AuthState.Paired] state never reach this setter, so session-token
|
||||
* refreshes don't clobber the persisted list.
|
||||
*/
|
||||
fun setPendingEndpoints(endpoints: List<EndpointCandidate>?) {
|
||||
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send auth envelope when connection is established.
|
||||
*
|
||||
@@ -363,12 +685,17 @@ class AuthManager(
|
||||
val deviceId = getDeviceId()
|
||||
val payload = when (currentState) {
|
||||
is AuthState.Paired -> {
|
||||
val refreshToken = store().getString(KEY_REFRESH_TOKEN)
|
||||
Log.i(
|
||||
TAG,
|
||||
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…)"
|
||||
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…, " +
|
||||
"refresh=${!refreshToken.isNullOrBlank()})"
|
||||
)
|
||||
buildJsonObject {
|
||||
put("session_token", currentState.token)
|
||||
if (!refreshToken.isNullOrBlank()) {
|
||||
put("refresh_token", refreshToken)
|
||||
}
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
}
|
||||
@@ -452,6 +779,7 @@ class AuthManager(
|
||||
scope.launch {
|
||||
val s = store()
|
||||
s.remove(KEY_SESSION_TOKEN)
|
||||
s.remove(KEY_REFRESH_TOKEN)
|
||||
s.remove(KEY_PAIRED_META)
|
||||
if (relayUrl != null) {
|
||||
certPinStore.removePinFor(relayUrl)
|
||||
@@ -464,9 +792,58 @@ class AuthManager(
|
||||
when (envelope.type) {
|
||||
"auth.ok" -> handleAuthOk(envelope)
|
||||
"auth.fail" -> handleAuthFail(envelope)
|
||||
// `profiles.updated` push — sent by the v0.7.1+ relay on
|
||||
// the "pairing" channel whenever its in-memory profile
|
||||
// snapshot changes (file-watcher, SIGHUP, or a manual
|
||||
// /api/profiles/refresh). The array shape mirrors the
|
||||
// `profiles` field of auth.ok, so we parse with the exact
|
||||
// same [parseAgentProfiles] helper.
|
||||
"profiles.updated" -> handleProfilesUpdated(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumer for the pairing-channel `profiles.updated` envelope.
|
||||
* Re-uses [parseAgentProfiles] because the wire shape of the
|
||||
* `profiles` array exactly matches the auth.ok embedded form —
|
||||
* the whole point of the push is that the app keeps a single
|
||||
* parser regardless of which entry point the list arrives on.
|
||||
*
|
||||
* Emits a one-shot [profilesUpdatedEvents] event so the UI layer
|
||||
* can show a brief "Profiles updated" snackbar. The event is only
|
||||
* fired when the list actually changed (different names or count)
|
||||
* — an idempotent push that matches the cached state is silent.
|
||||
*/
|
||||
private fun handleProfilesUpdated(envelope: Envelope) {
|
||||
val array = envelope.profiles ?: run {
|
||||
Log.w(TAG, "handleProfilesUpdated: envelope has no `profiles` array")
|
||||
return
|
||||
}
|
||||
val parsed = parseAgentProfiles(array)
|
||||
val prev = _agentProfiles.value
|
||||
_agentProfiles.value = parsed
|
||||
|
||||
// Did anything meaningful change? We treat "same names" as
|
||||
// "nothing to announce" — the dashboard can emit these pushes
|
||||
// liberally and we don't want to spam the user with toasts.
|
||||
val prevNames = prev.map { it.name }.toSet()
|
||||
val nextNames = parsed.map { it.name }.toSet()
|
||||
if (prevNames != nextNames || prev.size != parsed.size) {
|
||||
_profilesUpdatedEvents.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot signal emitted every time a `profiles.updated` envelope
|
||||
* materially changes the profile list. UI collects it via
|
||||
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel.profilesUpdatedEvents]
|
||||
* to show a transient snackbar.
|
||||
*/
|
||||
private val _profilesUpdatedEvents =
|
||||
kotlinx.coroutines.flow.MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||
val profilesUpdatedEvents: kotlinx.coroutines.flow.SharedFlow<Unit> =
|
||||
_profilesUpdatedEvents.asSharedFlow()
|
||||
|
||||
fun regeneratePairingCode() {
|
||||
_pairingCode.value = generatePairingCode()
|
||||
}
|
||||
@@ -475,6 +852,7 @@ class AuthManager(
|
||||
scope.launch {
|
||||
val s = store()
|
||||
s.remove(KEY_SESSION_TOKEN)
|
||||
s.remove(KEY_REFRESH_TOKEN)
|
||||
s.remove(KEY_PAIRED_META)
|
||||
_authState.value = AuthState.Unpaired
|
||||
_currentPairedSession.value = null
|
||||
@@ -491,16 +869,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
|
||||
@@ -523,6 +901,14 @@ class AuthManager(
|
||||
if (token != null) {
|
||||
val s = store()
|
||||
s.putString(KEY_SESSION_TOKEN, token)
|
||||
val refreshToken = payload["refresh_token"]
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (refreshToken != null) {
|
||||
s.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
Log.i(TAG, "handleAuthOk: stored rotated refresh token")
|
||||
}
|
||||
_authState.value = AuthState.Paired(token)
|
||||
Log.i(TAG, "handleAuthOk: Paired(token=${token.take(8)}…)")
|
||||
// Server-issued code is one-shot — drop it once the
|
||||
@@ -565,19 +951,55 @@ class AuthManager(
|
||||
_currentPairedSession.value = paired
|
||||
persistPairedSession(paired)
|
||||
|
||||
// Pending TTL/grants are consumed — the server has
|
||||
// either honored or overridden them and the next
|
||||
// ADR 24 — persist the pairing's endpoint-candidate list
|
||||
// so the reachability probe + network-aware switch
|
||||
// (Kt-Probe) can load it on subsequent connects without
|
||||
// re-scanning the original QR. Keyed by the stable
|
||||
// device id so multiple paired devices coexist cleanly.
|
||||
//
|
||||
// Leave the previously-persisted list untouched when
|
||||
// nothing was staged (session-token refresh path, or
|
||||
// a legacy caller that didn't set endpoints).
|
||||
pendingEndpoints?.let { endpoints ->
|
||||
try {
|
||||
val deviceId = getDeviceId()
|
||||
PairingPreferences.setDeviceEndpoints(
|
||||
context,
|
||||
deviceId,
|
||||
endpoints,
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"handleAuthOk: persisted ${endpoints.size} endpoint(s) for device=$deviceId " +
|
||||
"roles=${endpoints.map { it.role }}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleAuthOk: endpoint persistence failed — continuing without; " +
|
||||
"reachability probe will fall back to the single active endpoint. " +
|
||||
"reason=${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pending TTL/grants/endpoints are consumed — the server
|
||||
// has either honored or overridden them and the next
|
||||
// auth round-trip should not resend stale values.
|
||||
pendingTtlSeconds = null
|
||||
pendingGrants = null
|
||||
pendingEndpoints = null
|
||||
}
|
||||
|
||||
val profilesArray = payload["profiles"]?.jsonArray
|
||||
if (profilesArray != null) {
|
||||
_profiles.value = profilesArray.map { it.jsonPrimitive.content }
|
||||
_agentProfiles.value = parseAgentProfiles(profilesArray)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
// Replaces a silent `e.printStackTrace()` — that stack-trace-only
|
||||
// handler is exactly why the broken `_sessionLabels` parser
|
||||
// (stringifying object entries) sat undetected for so long.
|
||||
Log.w(TAG, "auth.ok parse failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,13 +1010,36 @@ 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) {
|
||||
Log.w(TAG, "handleAuthFail: exception parsing payload", e)
|
||||
clearPendingPairContextAfterAuthFailure("parse failure")
|
||||
_authState.value = AuthState.Failed("Authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingPairContextAfterAuthFailure(reason: String) {
|
||||
if (serverIssuedCode == null) return
|
||||
serverIssuedCode = null
|
||||
pendingTtlSeconds = null
|
||||
pendingGrants = null
|
||||
pendingEndpoints = null
|
||||
_pairingCode.value = generatePairingCode()
|
||||
Log.i(
|
||||
TAG,
|
||||
"auth.fail consumed server-issued pairing code; " +
|
||||
"cleared pending pair context so reconnects stop (reason=$reason)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map common relay `auth.fail` reasons to user-friendly short strings.
|
||||
* The wizard VerifyStep surfaces the returned text directly, so this is
|
||||
|
||||
@@ -18,10 +18,11 @@ import kotlinx.serialization.Serializable
|
||||
* @property expiresAt epoch seconds at which the server says the session
|
||||
* expires — or `null` when the user chose "never expire" at the
|
||||
* TTL picker. `null` is a first-class value, not a missing field.
|
||||
* @property grants per-channel expiry map. Keys: `"chat"`, `"terminal"`,
|
||||
* `"bridge"` (and any future channel the server adds). Values are
|
||||
* epoch seconds or `null` for "never". Missing channels = server
|
||||
* didn't grant that channel to this device.
|
||||
* @property grants per-channel expiry map. Known keys include `"chat"`,
|
||||
* `"terminal"`, `"bridge"`, `"tui"`, `"voice:config"`,
|
||||
* `"voice:stt"`, and `"voice:tts"`; future server-defined keys are
|
||||
* tolerated. Values are epoch seconds or `null` for "never".
|
||||
* Missing channels = server didn't grant that channel to this device.
|
||||
* @property transportHint the transport the server advises the phone to
|
||||
* use, for UX labeling only. `"wss"` / `"ws"` / `null` when the
|
||||
* server didn't provide a hint.
|
||||
|
||||
@@ -67,13 +67,17 @@ interface SessionTokenStore {
|
||||
class KeystoreTokenStore private constructor(
|
||||
private val context: Context,
|
||||
private val wantsStrongBox: Boolean,
|
||||
override val hasHardwareBackedStorage: Boolean
|
||||
override val hasHardwareBackedStorage: Boolean,
|
||||
private val prefsName: String,
|
||||
) : SessionTokenStore {
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. Built lazily via [buildPrefs] so the constructor can't
|
||||
// throw — [tryCreate] still controls the "is this device usable at all"
|
||||
// decision via its init probe below.
|
||||
// file is deleted. This field initializer runs [buildPrefs] eagerly, so it
|
||||
// CAN throw (e.g. AEADBadTagException on a corrupt keyset) — but the
|
||||
// constructor is private and only reachable via [tryCreate], which wraps
|
||||
// construction in try/catch and degrades to the legacy store. The
|
||||
// directly-constructed legacy path self-heals instead; see
|
||||
// [LegacyEncryptedPrefsTokenStore.buildPrefsResilient].
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
@@ -89,7 +93,7 @@ class KeystoreTokenStore private constructor(
|
||||
val masterKey = builder.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
prefsName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
@@ -114,16 +118,24 @@ class KeystoreTokenStore private constructor(
|
||||
prefs.edit().clear().apply()
|
||||
} catch (_: Exception) { /* expected on a wedged file */ }
|
||||
try {
|
||||
context.deleteSharedPreferences(PREFS_NAME)
|
||||
context.deleteSharedPreferences(prefsName)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($PREFS_NAME) failed: ${e.message}")
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
|
||||
}
|
||||
prefs = buildPrefs()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KeystoreTokenStore"
|
||||
private const val PREFS_NAME = "hermes_companion_auth_hw"
|
||||
|
||||
/**
|
||||
* Default EncryptedSharedPreferences filename. Pre-multi-connection
|
||||
* installs have all their auth state in this single file —
|
||||
* connection 0 re-uses it as-is via
|
||||
* [Connection.LEGACY_TOKEN_STORE_KEY] so no user-visible migration is
|
||||
* needed.
|
||||
*/
|
||||
const val DEFAULT_PREFS_NAME = "hermes_companion_auth_hw"
|
||||
|
||||
/**
|
||||
* Try to build a [KeystoreTokenStore] on the current device. Returns
|
||||
@@ -131,13 +143,22 @@ class KeystoreTokenStore private constructor(
|
||||
* AndroidKeystore implementations — we don't want the app to brick
|
||||
* itself trying to create a master key).
|
||||
*
|
||||
* [prefsName] selects the EncryptedSharedPreferences file — defaults
|
||||
* to [DEFAULT_PREFS_NAME] for backwards compat with the
|
||||
* single-connection call sites. Multi-connection callers pass a
|
||||
* per-connection filename built via
|
||||
* [com.hermesandroid.relay.data.Connection.buildTokenStoreKey].
|
||||
*
|
||||
* Also fires a one-shot read probe so a pre-corrupted file from a
|
||||
* previous install gets healed during construction rather than on the
|
||||
* first user-driven read. The probe routes through the instance's
|
||||
* own [getString], so if it throws, [resetPrefs] runs and we end up
|
||||
* with a fresh empty prefs file — not a permanently broken store.
|
||||
*/
|
||||
fun tryCreate(context: Context): KeystoreTokenStore? {
|
||||
fun tryCreate(
|
||||
context: Context,
|
||||
prefsName: String = DEFAULT_PREFS_NAME,
|
||||
): KeystoreTokenStore? {
|
||||
return try {
|
||||
val wantsStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
||||
context.packageManager.hasSystemFeature(
|
||||
@@ -147,6 +168,7 @@ class KeystoreTokenStore private constructor(
|
||||
context = context.applicationContext,
|
||||
wantsStrongBox = wantsStrongBox,
|
||||
hasHardwareBackedStorage = wantsStrongBox,
|
||||
prefsName = prefsName,
|
||||
)
|
||||
// Force a read so a wedged file from a prior install heals
|
||||
// here rather than at the first user-visible call.
|
||||
@@ -224,7 +246,10 @@ class KeystoreTokenStore private constructor(
|
||||
* (b) migration source for reading existing session tokens out of the legacy
|
||||
* prefs on first launch after the update.
|
||||
*/
|
||||
class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
class LegacyEncryptedPrefsTokenStore(
|
||||
context: Context,
|
||||
private val prefsName: String = LEGACY_PREFS_NAME,
|
||||
) : SessionTokenStore {
|
||||
|
||||
companion object {
|
||||
const val LEGACY_PREFS_NAME = "hermes_companion_auth"
|
||||
@@ -235,7 +260,38 @@ class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. See [KeystoreTokenStore.resetPrefs] for the rationale.
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
//
|
||||
// Built via [buildPrefsResilient] so a corrupt keyset can't crash the
|
||||
// constructor. Unlike [KeystoreTokenStore], this class is `new`-ed
|
||||
// directly (it's the fallback when KeystoreTokenStore.tryCreate returns
|
||||
// null, and the migration source), so there's no tryCreate-style guard
|
||||
// upstream — the healing has to live here.
|
||||
private var prefs: SharedPreferences = buildPrefsResilient()
|
||||
|
||||
/**
|
||||
* Build the encrypted prefs, healing a corrupted keyset on the way.
|
||||
*
|
||||
* [EncryptedSharedPreferences.create] decrypts the Tink keyset eagerly, so
|
||||
* a stale/corrupt legacy file throws [javax.crypto.AEADBadTagException]
|
||||
* (AES-GCM tag mismatch) right here in the constructor. This is the classic
|
||||
* post-upgrade / post-restore failure: the encrypted blob persists but the
|
||||
* hardware master key it was sealed against is gone or rotated. Delete the
|
||||
* file and rebuild a fresh keyset against the current master key rather
|
||||
* than letting the exception escape and force-close the app — the token in
|
||||
* the unreadable file was lost anyway, so the user simply re-pairs.
|
||||
*/
|
||||
private fun buildPrefsResilient(): SharedPreferences =
|
||||
try {
|
||||
buildPrefs()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial legacy prefs build failed — wiping corrupted file and rebuilding: ${e.message}")
|
||||
try {
|
||||
appContext.deleteSharedPreferences(prefsName)
|
||||
} catch (e2: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e2.message}")
|
||||
}
|
||||
buildPrefs()
|
||||
}
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(appContext)
|
||||
@@ -243,7 +299,7 @@ class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
LEGACY_PREFS_NAME,
|
||||
prefsName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
@@ -255,9 +311,9 @@ class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
prefs.edit().clear().apply()
|
||||
} catch (_: Exception) { /* expected on a wedged file */ }
|
||||
try {
|
||||
appContext.deleteSharedPreferences(LEGACY_PREFS_NAME)
|
||||
appContext.deleteSharedPreferences(prefsName)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($LEGACY_PREFS_NAME) failed: ${e.message}")
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
|
||||
}
|
||||
prefs = buildPrefs()
|
||||
}
|
||||
@@ -320,3 +376,24 @@ class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-resort implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Non-persistent [SessionTokenStore]. Used only when BOTH the Keystore and the
|
||||
* (self-healing) legacy encrypted store fail to construct — i.e. the device's
|
||||
* AndroidKeystore is so broken it can't even build a fresh key. Tokens live for
|
||||
* the process lifetime only, so the user re-pairs on the next cold start, but
|
||||
* the app stays up instead of force-closing. See [AuthManager.store].
|
||||
*/
|
||||
class InMemoryTokenStore : SessionTokenStore {
|
||||
private val map = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
override val hasHardwareBackedStorage: Boolean = false
|
||||
override fun getString(key: String): String? = map[key]
|
||||
override fun putString(key: String, value: String) { map[key] = value }
|
||||
override fun remove(key: String) { map.remove(key) }
|
||||
override fun contains(key: String): Boolean = map.containsKey(key)
|
||||
override fun clearAll() { map.clear() }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* v0.4.1 polish — cross-layer tracker that coordinates "agent run
|
||||
* completed → auto-return to Hermes-Relay" across two independent
|
||||
* completion signals:
|
||||
*
|
||||
* 1. **Chat-tab SSE `run.completed`** — fires fast (<50ms after the
|
||||
* server emits the event) but ONLY when the active chat is driven
|
||||
* from the phone's own Chat tab. Discord-originated runs, CLI
|
||||
* runs, or any other frontend never deliver SSE to the phone.
|
||||
*
|
||||
* 2. **Bridge-idle timer** — fires [IDLE_AUTO_RETURN_MS] after the
|
||||
* last bridge command dispatch. Works for ANY frontend because
|
||||
* the phone sees all bridge traffic by definition. This is the
|
||||
* catch-all fallback for Discord / CLI / Slack / external clients.
|
||||
*
|
||||
* Whichever signal arrives first wins. The other is cancelled.
|
||||
*
|
||||
* # The problem
|
||||
*
|
||||
* `android_return_to_hermes` is an LLM-called tool — the agent is
|
||||
* prompted to call it as the FINAL step of any phone-control task, but
|
||||
* LLMs forget, especially on longer runs or when the frontend isn't
|
||||
* the phone itself. When that happens the phone is left on Starbucks /
|
||||
* Chrome / wherever, and the user has to manually switch back to see
|
||||
* the agent's response.
|
||||
*
|
||||
* # Wiring (two callers)
|
||||
*
|
||||
* Bridge-side (works for all frontends):
|
||||
* - `BridgeCommandHandler.dispatch()` → [onBridgeCommandActivity]
|
||||
* on every non-polling command (resets the idle timer).
|
||||
* - `BridgeCommandHandler.respond()` → [markForegroundChanged]
|
||||
* on successful `/open_app` / `/send_intent` (arms the idle timer).
|
||||
* - `BridgeCommandHandler.respond()` → [markReturnedToHermes] on
|
||||
* successful `/return_to_hermes` (cancels the idle timer).
|
||||
*
|
||||
* Chat-side (fast path when applicable):
|
||||
* - `ChatViewModel.onCompleteCb` → [notifyRunCompleted] on SSE
|
||||
* `run.completed`.
|
||||
*
|
||||
* Either path converges on the same [autoReturnCallback], which
|
||||
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel] wires to a
|
||||
* local `/return_to_hermes` dispatch.
|
||||
*
|
||||
* # Why a singleton
|
||||
*
|
||||
* Chat and Bridge live in separate ViewModels owned by the same
|
||||
* Activity. Plumbing a cross-VM reference through RelayApp just for
|
||||
* this coordination is heavier than the process-scoped state needs.
|
||||
*
|
||||
* # Concurrency
|
||||
*
|
||||
* Flag mutations are `@Volatile`; the idle timer Job is cancel-safe
|
||||
* under race. Multiple concurrent `/open_app` responses during a run
|
||||
* all call [markForegroundChanged] → the Job is cancel-and-restart,
|
||||
* so the timer always extends to IDLE_AUTO_RETURN_MS from the most
|
||||
* recent activity. Spurious double calls to [notifyRunCompleted]
|
||||
* after the flag is cleared are no-ops.
|
||||
*/
|
||||
object BridgeRunTracker {
|
||||
|
||||
private const val TAG = "BridgeRunTracker"
|
||||
|
||||
/**
|
||||
* How long to wait after the last bridge command before assuming
|
||||
* the agent run is done and firing an auto-return.
|
||||
*
|
||||
* 12s is long enough to cover slow LLM reasoning gaps between
|
||||
* tool calls (image-analysis turns can take 5–10s on claude-opus
|
||||
* and similar), but short enough that the user isn't stranded for
|
||||
* an obvious delay after a forgotten return. Callers can adjust
|
||||
* via [configureIdleTimeout] if their agent's pacing differs.
|
||||
*/
|
||||
private const val IDLE_AUTO_RETURN_MS: Long = 12_000L
|
||||
|
||||
private val timerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@Volatile
|
||||
private var idleTimeoutMs: Long = IDLE_AUTO_RETURN_MS
|
||||
|
||||
@Volatile
|
||||
private var foregroundChangedDuringRun: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var autoReturnCallback: (() -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var idleJob: Job? = null
|
||||
|
||||
/**
|
||||
* Flag that the bridge moved the foreground app away from
|
||||
* Hermes-Relay during the currently-active agent run, and arm the
|
||||
* idle-timer auto-return fallback. Idempotent — repeated calls
|
||||
* within one run extend the idle window to
|
||||
* [IDLE_AUTO_RETURN_MS] from the most recent call.
|
||||
*
|
||||
* Called by [BridgeCommandHandler.respond] on successful dispatch
|
||||
* of paths in its `foregroundShiftingPaths` set.
|
||||
*/
|
||||
fun markForegroundChanged() {
|
||||
foregroundChangedDuringRun = true
|
||||
armIdleTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the idle timer without changing the flag. Called from
|
||||
* [BridgeCommandHandler.dispatch] on every non-polling bridge
|
||||
* command so a long multi-step run keeps the timer alive —
|
||||
* otherwise a slow LLM reasoning gap would trigger a premature
|
||||
* auto-return mid-run.
|
||||
*
|
||||
* No-op when the flag is false: we only care about idle tracking
|
||||
* when there's a pending return to fire, and we don't want every
|
||||
* /screen poll to spin up a coroutine during runs that never
|
||||
* touched the foreground.
|
||||
*/
|
||||
fun onBridgeCommandActivity() {
|
||||
if (foregroundChangedDuringRun) {
|
||||
armIdleTimer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the foreground-changed flag because we're back on Hermes
|
||||
* and cancel any pending idle timer. Called by
|
||||
* [BridgeCommandHandler.respond] on successful dispatch of
|
||||
* `/return_to_hermes` — whether the agent called the tool
|
||||
* explicitly, the idle timer fired, or the Chat SSE path fired.
|
||||
* Without this, an LLM-initiated return would leave the flag set
|
||||
* and fire a redundant (though harmless) auto-return on
|
||||
* `run.completed`.
|
||||
*/
|
||||
fun markReturnedToHermes() {
|
||||
foregroundChangedDuringRun = false
|
||||
idleJob?.cancel()
|
||||
idleJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only override for the idle timeout. Kept internal so
|
||||
* production code isn't tempted to fiddle with it from UI.
|
||||
*/
|
||||
internal fun configureIdleTimeout(ms: Long) {
|
||||
idleTimeoutMs = ms
|
||||
}
|
||||
|
||||
private fun armIdleTimer() {
|
||||
idleJob?.cancel()
|
||||
idleJob = timerScope.launch {
|
||||
delay(idleTimeoutMs)
|
||||
// Re-check flag after the delay — it may have been cleared
|
||||
// by Chat SSE or an explicit /return_to_hermes while we
|
||||
// were asleep. If still set, fire auto-return.
|
||||
if (foregroundChangedDuringRun) {
|
||||
foregroundChangedDuringRun = false
|
||||
val cb = autoReturnCallback
|
||||
if (cb != null) {
|
||||
Log.i(TAG, "idle-timer expired + bridge touched foreground — firing auto-return")
|
||||
runCatching { cb() }.onFailure {
|
||||
Log.w(TAG, "idle-timer auto-return callback threw: ${it.message}")
|
||||
}
|
||||
} else {
|
||||
Log.v(TAG, "idle-timer expired but no auto-return callback registered")
|
||||
}
|
||||
}
|
||||
idleJob = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the auto-return action. Called once from
|
||||
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel] init
|
||||
* with a lambda that fires a local `/return_to_hermes` command
|
||||
* via `BridgeCommandHandler.handleLocalCommand`.
|
||||
*
|
||||
* Overwrites any previously-registered callback — intended to be
|
||||
* called exactly once per process lifetime, but idempotent if a
|
||||
* future caller needs to re-register (e.g. tests).
|
||||
*/
|
||||
fun registerAutoReturnCallback(callback: () -> Unit) {
|
||||
autoReturnCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by `ChatViewModel.onCompleteCb` when the SSE
|
||||
* `run.completed` event fires. If the bridge moved foreground
|
||||
* during this run AND an auto-return callback is registered, fire
|
||||
* it. Clears the flag regardless so the next run starts clean.
|
||||
*
|
||||
* The callback itself is responsible for deciding whether
|
||||
* `/return_to_hermes` is actually needed (e.g. skip if the current
|
||||
* foreground is already Hermes because the user manually
|
||||
* navigated back). This tracker only answers "did we touch the
|
||||
* foreground during this run?"
|
||||
*/
|
||||
fun notifyRunCompleted() {
|
||||
// Cancel the idle timer — SSE beat it to the punch, no point
|
||||
// letting it fire a duplicate callback.
|
||||
idleJob?.cancel()
|
||||
idleJob = null
|
||||
if (foregroundChangedDuringRun) {
|
||||
foregroundChangedDuringRun = false
|
||||
val cb = autoReturnCallback
|
||||
if (cb != null) {
|
||||
Log.i(TAG, "run.completed + bridge touched foreground — firing auto-return")
|
||||
runCatching { cb() }.onFailure {
|
||||
Log.w(TAG, "auto-return callback threw: ${it.message}")
|
||||
}
|
||||
} else {
|
||||
Log.v(TAG, "run.completed + bridge touched foreground, but no callback registered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only reset. */
|
||||
internal fun reset() {
|
||||
foregroundChangedDuringRun = false
|
||||
autoReturnCallback = null
|
||||
idleJob?.cancel()
|
||||
idleJob = null
|
||||
}
|
||||
}
|
||||
@@ -110,10 +110,26 @@ class BridgeSafetyManager(
|
||||
private val _settings = MutableStateFlow(BridgeSafetySettings())
|
||||
val settings: StateFlow<BridgeSafetySettings> = _settings.asStateFlow()
|
||||
|
||||
/**
|
||||
* "Don't ask again" set for destructive verbs. When a confirmation is
|
||||
* about to fire and the matched verb is in this set, we short-circuit
|
||||
* to Allow (and let the normal activity-log path record the action so
|
||||
* the user still has an audit trail). This does NOT bypass the master
|
||||
* blocklist or the master-disable toggle — both of those gates run in
|
||||
* [BridgeCommandHandler] before the code ever reaches [awaitConfirmation].
|
||||
*/
|
||||
private val _trustedDestructiveVerbs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val trustedDestructiveVerbs: StateFlow<Set<String>> =
|
||||
_trustedDestructiveVerbs.asStateFlow()
|
||||
|
||||
/** True once the DataStore collector has ticked at least once. */
|
||||
@Volatile
|
||||
private var settingsHydrated: Boolean = false
|
||||
|
||||
/** True once the trusted-verbs collector has ticked at least once. */
|
||||
@Volatile
|
||||
private var trustedHydrated: Boolean = false
|
||||
|
||||
/**
|
||||
* Pending confirmation requests keyed by a monotonic id. The overlay's
|
||||
* Allow / Deny callbacks complete the deferred by looking up the id the
|
||||
@@ -145,6 +161,12 @@ class BridgeSafetyManager(
|
||||
settingsHydrated = true
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
prefsRepo.trustedDestructiveVerbs.collect { latest ->
|
||||
_trustedDestructiveVerbs.value = latest
|
||||
trustedHydrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blocklist ────────────────────────────────────────────────────────
|
||||
@@ -197,12 +219,32 @@ class BridgeSafetyManager(
|
||||
val timeoutMs = snapshot.confirmationTimeoutSeconds * 1000L
|
||||
val requestId = nextRequestId.incrementAndGet()
|
||||
|
||||
val matchedVerb = text?.let { firstMatchedVerb(it, snapshot.destructiveVerbs) }.orEmpty()
|
||||
|
||||
// "Don't ask again" short-circuit. If the user has previously
|
||||
// allowed this verb with the trust checkbox ticked, skip the
|
||||
// modal entirely and return allow. We intentionally only short-
|
||||
// circuit when the matched verb is non-empty — routes like /call
|
||||
// and /send_sms whose confirm text doesn't match any user verb
|
||||
// (verb = "") always prompt so irreversible actions never bypass.
|
||||
//
|
||||
// This path is NOT consulted before the master blocklist or the
|
||||
// master-disable toggle — both of those live in BridgeCommandHandler
|
||||
// and fail earlier, so even a trusted verb can't slip through a
|
||||
// blocklisted app or a disabled bridge.
|
||||
if (matchedVerb.isNotBlank() &&
|
||||
currentTrustedVerbs().contains(matchedVerb.lowercase())
|
||||
) {
|
||||
Log.i(TAG, "awaitConfirmation: verb '$matchedVerb' is trusted — auto-allowing")
|
||||
return true
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
val pending = PendingConfirmation(
|
||||
id = requestId,
|
||||
method = method,
|
||||
text = text.orEmpty(),
|
||||
verb = text?.let { firstMatchedVerb(it, snapshot.destructiveVerbs) }.orEmpty(),
|
||||
verb = matchedVerb,
|
||||
deferred = deferred,
|
||||
)
|
||||
pendingConfirmations[requestId] = pending
|
||||
@@ -302,6 +344,46 @@ class BridgeSafetyManager(
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add [verb] to the trusted-verb set. Idempotent; no-op on blank input.
|
||||
* Called from the overlay host when the user ticks "Don't ask again"
|
||||
* and taps Allow. Verbs are persisted lowercase/trimmed by the
|
||||
* underlying repository.
|
||||
*/
|
||||
fun trustDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
scope.launch {
|
||||
runCatching { prefsRepo.addTrustedDestructiveVerb(normalized) }
|
||||
.onFailure { Log.w(TAG, "trustDestructiveVerb('$verb') failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the trusted-verb set. Called from [BridgeScreen]'s "Reset"
|
||||
* affordance after the user confirms. After this, every destructive
|
||||
* verb prompts again.
|
||||
*/
|
||||
fun clearTrustedDestructiveVerbs() {
|
||||
scope.launch {
|
||||
runCatching { prefsRepo.clearTrustedDestructiveVerbs() }
|
||||
.onFailure { Log.w(TAG, "clearTrustedDestructiveVerbs failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentTrustedVerbs(): Set<String> {
|
||||
if (trustedHydrated) return _trustedDestructiveVerbs.value
|
||||
return try {
|
||||
val first = prefsRepo.trustedDestructiveVerbs.first()
|
||||
_trustedDestructiveVerbs.value = first
|
||||
trustedHydrated = true
|
||||
first
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "currentTrustedVerbs: DataStore read failed — using empty", t)
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentSettings(): BridgeSafetySettings {
|
||||
// Prefer the cached value once the DataStore collector has ticked
|
||||
// at least once. Before that, fall back to a one-shot read of
|
||||
|
||||
@@ -44,6 +44,17 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* in the gesture layer while the modal is a modal rectangle that
|
||||
* intercepts touches only when present.
|
||||
*
|
||||
* # Foreground gating (v0.4.1 polish)
|
||||
*
|
||||
* Chip visibility is gated on the app being backgrounded — while
|
||||
* Hermes-Relay is foregrounded, the in-app `UnattendedGlobalBanner`
|
||||
* handles user visibility and this chip hides. The gating lives in
|
||||
* [com.hermesandroid.relay.viewmodel.BridgeViewModel]'s chip collector,
|
||||
* which combines the user preferences with
|
||||
* [com.hermesandroid.relay.util.AppForegroundTracker.isForeground]
|
||||
* before calling [setChipVisible]. The confirmation modal path is
|
||||
* unaffected — destructive-verb prompts always need to show.
|
||||
*
|
||||
* # Lifecycle plumbing for ComposeView
|
||||
*
|
||||
* `ComposeView` attached via `WindowManager` does not automatically get
|
||||
@@ -78,6 +89,7 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
appContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
|
||||
private var chipView: View? = null
|
||||
private var chipUnattended: Boolean = false
|
||||
private val activeConfirmations = ConcurrentHashMap<Long, View>()
|
||||
|
||||
// ── Status chip ──────────────────────────────────────────────────────
|
||||
@@ -86,18 +98,34 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
* Show or hide the floating status chip. No-op if the overlay
|
||||
* permission hasn't been granted — [BridgeSafetySettingsScreen] is
|
||||
* responsible for walking the user through the grant flow.
|
||||
*
|
||||
* v0.4.1: when [unattended] is true the chip renders in an amber
|
||||
* "Unattended ON" variant so the user (or anyone glancing at the
|
||||
* device) can tell at a glance the agent is permitted to wake the
|
||||
* screen and drive the device while no one is watching.
|
||||
*/
|
||||
@SuppressLint("InflateParams")
|
||||
fun setChipVisible(visible: Boolean) {
|
||||
fun setChipVisible(visible: Boolean, unattended: Boolean = false) {
|
||||
if (!visible) {
|
||||
chipView?.let {
|
||||
runCatching { wm.removeView(it) }
|
||||
.onFailure { Log.w(TAG, "removeView(chip) failed", it) }
|
||||
}
|
||||
chipView = null
|
||||
chipUnattended = false
|
||||
return
|
||||
}
|
||||
if (chipView != null) return // already showing
|
||||
// If the chip is already showing AND the unattended flag matches,
|
||||
// nothing to do. If the flag differs we need to redraw, so tear
|
||||
// down + rebuild — ComposeView arguments aren't reactive to
|
||||
// external state mutation here, and the chip is a tiny view so
|
||||
// the rebuild is cheap.
|
||||
if (chipView != null && chipUnattended == unattended) return
|
||||
if (chipView != null && chipUnattended != unattended) {
|
||||
runCatching { wm.removeView(chipView) }
|
||||
.onFailure { Log.w(TAG, "removeView(chip rebuild) failed", it) }
|
||||
chipView = null
|
||||
}
|
||||
if (!Settings.canDrawOverlays(appContext)) {
|
||||
Log.w(TAG, "setChipVisible: SYSTEM_ALERT_WINDOW not granted — skipping chip")
|
||||
return
|
||||
@@ -105,7 +133,7 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
setContent {
|
||||
MaterialTheme { BridgeStatusOverlayChip() }
|
||||
MaterialTheme { BridgeStatusOverlayChip(unattended = unattended) }
|
||||
}
|
||||
}
|
||||
attachLifecycle(compose)
|
||||
@@ -132,6 +160,7 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
}
|
||||
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
|
||||
chipView = compose
|
||||
chipUnattended = unattended
|
||||
}
|
||||
|
||||
// ── Confirmation modal ───────────────────────────────────────────────
|
||||
@@ -155,11 +184,22 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
method = request.method,
|
||||
verb = request.verb,
|
||||
fullText = request.text,
|
||||
onAllow = {
|
||||
onAllow = { trustVerb ->
|
||||
// Persist the "don't ask again" choice BEFORE
|
||||
// dismissing so a slow write can't race a
|
||||
// follow-up command that arrives while we're
|
||||
// still tearing down the overlay. trustVerb is
|
||||
// already gated by the dialog on verb.isNotBlank,
|
||||
// so passing it through straight is safe.
|
||||
if (trustVerb && request.verb.isNotBlank()) {
|
||||
BridgeSafetyManager.peek()
|
||||
?.trustDestructiveVerb(request.verb)
|
||||
}
|
||||
onResult(true)
|
||||
dismissConfirmation(request.id)
|
||||
},
|
||||
onDeny = {
|
||||
// Deny never writes trust — denying isn't consent.
|
||||
onResult(false)
|
||||
dismissConfirmation(request.id)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* v0.4.1 — sideload-only "unattended access" mode.
|
||||
*
|
||||
* # What this does
|
||||
*
|
||||
* When the user opts in via the Bridge tab toggle, this manager:
|
||||
*
|
||||
* 1. Acquires a screen-bright wake lock (`SCREEN_BRIGHT_WAKE_LOCK |
|
||||
* ACQUIRE_CAUSES_WAKEUP | ON_AFTER_RELEASE`) inside [acquireForAction]
|
||||
* so the agent's gesture lands on a lit screen instead of vanishing
|
||||
* into a dimmed-off display.
|
||||
* 2. Calls [KeyguardManager.requestDismissKeyguard] from a host activity
|
||||
* when one is available, which silently clears None / Swipe locks and
|
||||
* reports failure for credential locks (PIN / pattern / biometric).
|
||||
* 3. Reports a [WakeOutcome.KeyguardBlocked] result to the caller when
|
||||
* the screen woke but the lock didn't clear, so [BridgeCommandHandler]
|
||||
* can surface a structured `keyguard_blocked` error_code in the
|
||||
* bridge response.
|
||||
*
|
||||
* # Wake-lock flag choice
|
||||
*
|
||||
* `SCREEN_BRIGHT_WAKE_LOCK` is technically deprecated since API 17 in
|
||||
* favor of `Window.FLAG_KEEP_SCREEN_ON` / `Activity.setTurnScreenOn(true)` —
|
||||
* but that guidance assumes you have an Activity Window to attach to.
|
||||
* We do not: this manager is invoked from a background bridge context
|
||||
* where the only "UI surface" is the optional WindowManager overlay,
|
||||
* and overlay views can't drive screen wake-up. So we use the
|
||||
* historical wake-lock path under an explicit @Suppress, with the same
|
||||
* 30-second timeout as a foreground Activity's screen-on attribute would
|
||||
* give us, ref-counted across nested commands so a tap-burst doesn't
|
||||
* thrash the lock.
|
||||
*
|
||||
* `ACQUIRE_CAUSES_WAKEUP` forces the screen to come on the moment we
|
||||
* acquire — the difference between "wake the screen" and "keep the
|
||||
* screen on if it's already on". `ON_AFTER_RELEASE` lets the screen
|
||||
* timeout naturally instead of clicking dark the instant we release,
|
||||
* which makes the user-visible behaviour feel like a normal phone
|
||||
* unlock-and-dim sequence.
|
||||
*
|
||||
* # Keyguard handling
|
||||
*
|
||||
* `KeyguardManager.requestDismissKeyguard(activity, callback)` only
|
||||
* dismisses None and Swipe locks on third-party apps — Android does
|
||||
* not let arbitrary apps walk past a credential lock (PIN / pattern /
|
||||
* biometric), and that's not a bug we can work around. The onDismissError
|
||||
* / onDismissCancelled callbacks fire with no actionable error code, so
|
||||
* we treat any non-success as `keyguard_blocked` for the purposes of
|
||||
* the bridge response.
|
||||
*
|
||||
* If no MainActivity is currently registered with [setHostActivity] (eg.
|
||||
* the user is on the home launcher with the app fully backgrounded),
|
||||
* we skip the dismiss attempt entirely and rely on the wake-lock alone.
|
||||
* The agent will see a lit screen but a present keyguard if a credential
|
||||
* lock is set.
|
||||
*
|
||||
* # Why this is separate from WakeLockManager
|
||||
*
|
||||
* [com.hermesandroid.relay.power.WakeLockManager] holds a
|
||||
* `PARTIAL_WAKE_LOCK` for the CPU during gesture dispatch — it does NOT
|
||||
* wake the screen and is always on while the bridge is enabled. This
|
||||
* manager is conditional on the unattended-access opt-in, holds a
|
||||
* SCREEN_BRIGHT lock specifically to wake the display, and does not
|
||||
* fire when unattended is off. Two distinct wake lifecycles, two
|
||||
* distinct files; keeps the unattended-access opt-in surgical and easy
|
||||
* to audit for security review.
|
||||
*
|
||||
* # Scoping
|
||||
*
|
||||
* Sideload-only. Both the Compose toggle row and the `acquireForAction`
|
||||
* call sites are gated on `BuildFlavor.isSideload`, which folds the
|
||||
* gating away in release builds via R8. The googlePlay flavor never
|
||||
* acquires this lock and never invokes requestDismissKeyguard.
|
||||
*/
|
||||
object UnattendedAccessManager {
|
||||
|
||||
private const val TAG = "UnattendedAccess"
|
||||
private const val WAKE_LOCK_TAG = "HermesRelay::Unattended"
|
||||
|
||||
/**
|
||||
* Hard cap on how long we hold the screen-bright lock per action.
|
||||
* Long enough to cover a user-visible page load + the gesture itself,
|
||||
* short enough that a stuck or buggy command can never pin the
|
||||
* display awake indefinitely.
|
||||
*/
|
||||
private const val WAKE_LOCK_TIMEOUT_MS: Long = 30_000L
|
||||
|
||||
/** Backing PowerManager handle, captured once on [initialize]. */
|
||||
@Volatile
|
||||
private var powerManager: PowerManager? = null
|
||||
|
||||
/** Backing KeyguardManager handle, captured once on [initialize]. */
|
||||
@Volatile
|
||||
private var keyguardManager: KeyguardManager? = null
|
||||
|
||||
/**
|
||||
* Live activity reference used for `requestDismissKeyguard` calls.
|
||||
* Held weakly via direct assignment + clear-on-stop semantics to
|
||||
* avoid leaking the Activity past its lifecycle. MainActivity sets
|
||||
* this in onResume and clears in onPause.
|
||||
*/
|
||||
@Volatile
|
||||
private var hostActivity: android.app.Activity? = null
|
||||
|
||||
/**
|
||||
* StateFlow surface for the user-toggle ON/OFF state. Mirrored from
|
||||
* BridgeSafetySettings.unattendedAccessEnabled by [BridgeViewModel]
|
||||
* via [setEnabled]. Read by `acquireForAction` for the fast-path
|
||||
* skip when off, and by the Bridge UI for the chip + warning dialog.
|
||||
*/
|
||||
private val _enabled = MutableStateFlow(false)
|
||||
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
|
||||
|
||||
/**
|
||||
* Snapshot of whether a credential lock (PIN / pattern / biometric)
|
||||
* is currently set on the device. Refreshed on demand via
|
||||
* [refreshKeyguardState]. Drives the persistent chip on the Bridge
|
||||
* screen explaining the credential-lock limitation.
|
||||
*
|
||||
* Note: this is "is a credential lock CONFIGURED" (`isDeviceSecure`),
|
||||
* not "is the device CURRENTLY locked" (`isKeyguardLocked`). The
|
||||
* limitation we surface to the user is structural — a configured
|
||||
* credential lock means our wake will stop at the lock screen even
|
||||
* when the device is currently unlocked at the moment they enable
|
||||
* the toggle.
|
||||
*/
|
||||
private val _credentialLockDetected = MutableStateFlow(false)
|
||||
val credentialLockDetected: StateFlow<Boolean> = _credentialLockDetected.asStateFlow()
|
||||
|
||||
private val countLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var lockCount: Int = 0
|
||||
|
||||
/**
|
||||
* One-shot initializer. Call from `Application.onCreate` with the
|
||||
* application context. Idempotent.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
synchronized(countLock) {
|
||||
if (powerManager != null) return
|
||||
powerManager = context.applicationContext
|
||||
.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
keyguardManager = context.applicationContext
|
||||
.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
||||
if (powerManager == null) {
|
||||
Log.w(TAG, "PowerManager unavailable — unattended-access will no-op")
|
||||
}
|
||||
// Seed credential-lock state from the live KeyguardManager so the
|
||||
// UI doesn't show a stale "no lock" badge on the first frame.
|
||||
refreshKeyguardState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the BridgeSafetySettings.unattendedAccessEnabled value into
|
||||
* this singleton so call sites without DataStore access (eg. bridge
|
||||
* command dispatch) can read the live state via [enabled.value].
|
||||
* Called from [BridgeViewModel] on every settings tick.
|
||||
*/
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
_enabled.value = enabled
|
||||
// When the user just turned the feature on, re-probe the keyguard
|
||||
// state — they may have changed their lock screen between app
|
||||
// launches and the cached value would be stale. When turning off,
|
||||
// refresh anyway so the chip reflects current reality.
|
||||
refreshKeyguardState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the activity that should receive `requestDismissKeyguard`
|
||||
* calls. Called from `MainActivity.onResume` / `onPause` (set / clear
|
||||
* respectively). Multiple activity instances would overwrite each
|
||||
* other, but Hermes-Relay is single-activity by design so that's
|
||||
* fine.
|
||||
*/
|
||||
fun setHostActivity(activity: android.app.Activity?) {
|
||||
hostActivity = activity
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read [KeyguardManager.isDeviceSecure] and update the
|
||||
* [credentialLockDetected] flow. Cheap — no IPC. Called from
|
||||
* [setEnabled], from MainActivity.onResume (so the badge updates
|
||||
* if the user just changed their lock), and from [requestDismiss]
|
||||
* before deciding whether to attempt dismiss at all.
|
||||
*/
|
||||
fun refreshKeyguardState() {
|
||||
val km = keyguardManager ?: return
|
||||
// isDeviceSecure: API 23+. Our minSdk is 26, so always available.
|
||||
val secure = try {
|
||||
km.isDeviceSecure
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "isDeviceSecure threw: ${t.message}")
|
||||
false
|
||||
}
|
||||
_credentialLockDetected.value = secure
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a wake attempt. The bridge command handler maps this
|
||||
* onto a structured response so the agent / LLM can react:
|
||||
*
|
||||
* - [Success] — screen woke, keyguard cleared (or wasn't present).
|
||||
* Action can proceed normally.
|
||||
* - [SuccessNoKeyguardChange] — screen woke, no dismiss attempted
|
||||
* or no keyguard to dismiss. Action can proceed.
|
||||
* - [KeyguardBlocked] — screen woke but the keyguard refused to
|
||||
* dismiss (credential lock present, or activity not foregrounded).
|
||||
* Action will likely fail; surface as `keyguard_blocked`.
|
||||
* - [Disabled] — feature is off (user hasn't opted in, or this is
|
||||
* a googlePlay build). No-op fast path.
|
||||
*/
|
||||
enum class WakeOutcome {
|
||||
Success,
|
||||
SuccessNoKeyguardChange,
|
||||
KeyguardBlocked,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the screen-bright wake lock + opportunistically request
|
||||
* keyguard dismiss. Synchronous — does not suspend. The caller (
|
||||
* [com.hermesandroid.relay.accessibility.ActionExecutor] wrapper, or
|
||||
* [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
|
||||
* pre-dispatch hook) holds onto the result and decides whether to
|
||||
* proceed with the action.
|
||||
*
|
||||
* Returns [WakeOutcome.Disabled] when the user hasn't opted in —
|
||||
* this is the fast path, no wake lock acquired. When enabled,
|
||||
* returns [WakeOutcome.Success] / [SuccessNoKeyguardChange] /
|
||||
* [KeyguardBlocked] depending on the dismiss attempt outcome.
|
||||
*
|
||||
* The wake lock auto-releases via the platform's 30s timeout — we
|
||||
* don't release explicitly per call because the bridge command may
|
||||
* take several gestures to complete and we want one continuous
|
||||
* wake-up, not a stutter. [release] is provided for the master
|
||||
* toggle off path.
|
||||
*
|
||||
* # Compatibility shim
|
||||
*
|
||||
* SCREEN_BRIGHT_WAKE_LOCK is API-21+ but @Suppress'd as deprecated
|
||||
* since API 17 — see class-level KDoc for why we accept the warning.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
fun acquireForAction(): WakeOutcome {
|
||||
if (!_enabled.value) return WakeOutcome.Disabled
|
||||
|
||||
val pm = powerManager ?: run {
|
||||
Log.w(TAG, "acquireForAction: PowerManager not initialized")
|
||||
return WakeOutcome.Disabled
|
||||
}
|
||||
|
||||
// Build the wake lock lazily — first call after enable creates it,
|
||||
// subsequent calls re-use. setReferenceCounted(false) so a manual
|
||||
// release() always fully releases regardless of acquire() count.
|
||||
synchronized(countLock) {
|
||||
if (wakeLock == null) {
|
||||
wakeLock = try {
|
||||
pm.newWakeLock(
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||
PowerManager.ON_AFTER_RELEASE,
|
||||
WAKE_LOCK_TAG,
|
||||
).apply { setReferenceCounted(false) }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "newWakeLock threw: ${t.message}")
|
||||
return WakeOutcome.Disabled
|
||||
}
|
||||
}
|
||||
val lock = wakeLock ?: return WakeOutcome.Disabled
|
||||
try {
|
||||
if (!lock.isHeld) {
|
||||
lock.acquire(WAKE_LOCK_TIMEOUT_MS)
|
||||
}
|
||||
lockCount += 1
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "wakeLock.acquire threw: ${t.message}")
|
||||
return WakeOutcome.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort keyguard dismiss. The result feeds into the
|
||||
// returned WakeOutcome — we don't actually block on the
|
||||
// callback because the bridge command's own gesture dispatch
|
||||
// will fail-then-retry naturally if the keyguard is still
|
||||
// present, and waiting for the dismiss callback would add
|
||||
// noticeable latency to every command.
|
||||
return requestDismiss()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous keyguard dismiss attempt. Returns:
|
||||
* - [WakeOutcome.SuccessNoKeyguardChange] when there's no keyguard
|
||||
* to dismiss, no host activity registered, or the device is
|
||||
* pre-API-26 (we minSdk=26 so this is unreachable but defensive).
|
||||
* - [WakeOutcome.Success] when we fired the dismiss request and
|
||||
* the device has no credential lock (None / Swipe locks dismiss
|
||||
* silently).
|
||||
* - [WakeOutcome.KeyguardBlocked] when we tried to dismiss but the
|
||||
* device has a credential lock — the dismiss will fail and the
|
||||
* bridge command should surface this so the LLM doesn't blindly
|
||||
* keep tapping at the lock screen.
|
||||
*
|
||||
* The actual `requestDismissKeyguard(activity, callback)` call is
|
||||
* fire-and-forget — the platform invokes the callback async, but
|
||||
* we make the success/failure decision based on isDeviceSecure
|
||||
* rather than waiting for the callback. If isDeviceSecure() is
|
||||
* true, dismiss WILL fail; if false, dismiss WILL succeed. No need
|
||||
* to suspend the caller for a callback we can predict.
|
||||
*/
|
||||
@Suppress("MissingPermission")
|
||||
private fun requestDismiss(): WakeOutcome {
|
||||
refreshKeyguardState()
|
||||
|
||||
val km = keyguardManager ?: return WakeOutcome.SuccessNoKeyguardChange
|
||||
val activity = hostActivity ?: return WakeOutcome.SuccessNoKeyguardChange
|
||||
|
||||
val isLocked = try {
|
||||
km.isKeyguardLocked
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "isKeyguardLocked threw: ${t.message}")
|
||||
false
|
||||
}
|
||||
if (!isLocked) {
|
||||
// Nothing to dismiss — screen wake alone is sufficient.
|
||||
return WakeOutcome.SuccessNoKeyguardChange
|
||||
}
|
||||
|
||||
// requestDismissKeyguard requires API 26 — our minSdk matches.
|
||||
// The system ignores the request silently if the activity isn't
|
||||
// foregrounded, which is fine (we'd report KeyguardBlocked
|
||||
// anyway based on the credential-lock predict).
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
km.requestDismissKeyguard(activity, /* callback = */ null)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "requestDismissKeyguard threw: ${t.message}")
|
||||
return WakeOutcome.KeyguardBlocked
|
||||
}
|
||||
}
|
||||
|
||||
return if (_credentialLockDetected.value) {
|
||||
WakeOutcome.KeyguardBlocked
|
||||
} else {
|
||||
WakeOutcome.Success
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the screen-bright wake lock immediately. Called from the
|
||||
* master-toggle-off path so the screen returns to its natural
|
||||
* timeout behaviour as soon as the user disables the bridge — no
|
||||
* residual "screen stuck on" 30 seconds after disable.
|
||||
*
|
||||
* Safe to call when no lock is held (no-op).
|
||||
*/
|
||||
fun release() {
|
||||
synchronized(countLock) {
|
||||
val lock = wakeLock ?: return
|
||||
lockCount = 0
|
||||
try {
|
||||
if (lock.isHeld) lock.release()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "wakeLock.release threw: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Shared profile/personality display and request identity helpers.
|
||||
*
|
||||
* A null profile name is the app's explicit "Server default" state. The
|
||||
* relay also advertises the root Hermes config as a synthetic profile named
|
||||
* "default"; for request/session identity that row is an alias of server
|
||||
* default so it does not split chat, voice, or session scope.
|
||||
*/
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
private val GENERIC_MODEL_ALIASES = setOf(
|
||||
"hermes-agent",
|
||||
"hermes_agent",
|
||||
"hermes agent",
|
||||
)
|
||||
|
||||
// Only an EXPLICIT pick drives request/session identity. The advertised
|
||||
// "default" profile is an alias for server default, so falling back to it
|
||||
// here would split chat, voice, or session scope.
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun effectiveProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
|
||||
// Display can use the synthetic default profile's metadata without making
|
||||
// it a request/session override. Verbose SOUL summaries are filtered by
|
||||
// profileDisplayName below, so this is safe for headers/cards.
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
|
||||
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||
// name first. The synthetic default profile uses its description only when
|
||||
// that description looks like a concise human agent name ("Victor"), not a
|
||||
// verbose SOUL summary.
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
if (isServerDefaultAlias(profile.name)) {
|
||||
return defaultProfileDisplayName(profile)
|
||||
}
|
||||
return when {
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultProfileDisplayName(profile: Profile?): String? =
|
||||
profile
|
||||
?.description
|
||||
?.trim()
|
||||
?.takeIf { it.looksLikeConciseAgentName() }
|
||||
?.let(::titleCase)
|
||||
|
||||
fun agentName(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
connectionLabel: String?,
|
||||
localDisplayAlias: String? = null,
|
||||
): String {
|
||||
localDisplayAlias(localDisplayAlias)?.let { return it }
|
||||
profileDisplayName(profile)?.let { return it }
|
||||
|
||||
val personalityName = if (
|
||||
selectedPersonality == "default" &&
|
||||
defaultPersonality.isNotBlank()
|
||||
) {
|
||||
defaultPersonality
|
||||
} else {
|
||||
selectedPersonality
|
||||
}
|
||||
|
||||
return when {
|
||||
personalityName.isNotBlank() && personalityName != "default" ->
|
||||
titleCase(personalityName.trim())
|
||||
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
|
||||
else -> "Hermes"
|
||||
}
|
||||
}
|
||||
|
||||
fun personalityLabel(
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
): String = when {
|
||||
selectedPersonality != "default" && selectedPersonality.isNotBlank() ->
|
||||
titleCase(selectedPersonality.trim())
|
||||
defaultPersonality.isNotBlank() -> titleCase(defaultPersonality.trim())
|
||||
else -> "Default"
|
||||
}
|
||||
|
||||
fun displayModelName(model: String?): String? =
|
||||
model
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
|
||||
|
||||
fun isServerDefaultAlias(profileName: String?): Boolean =
|
||||
profileName?.trim()?.equals("default", ignoreCase = true) == true
|
||||
|
||||
fun normalizeSelection(profile: Profile?): Profile? =
|
||||
if (isServerDefaultAlias(profile?.name)) null else profile
|
||||
|
||||
fun profileRequestName(profileName: String?): String? =
|
||||
profileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && !isServerDefaultAlias(it) }
|
||||
|
||||
fun profileSessionKey(profileName: String?): String =
|
||||
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
|
||||
|
||||
fun profileContextKey(connectionId: String?, profileName: String?): String =
|
||||
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
|
||||
|
||||
fun localDisplayAlias(value: String?): String? =
|
||||
value
|
||||
?.trim()
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun String.looksLikeConciseAgentName(): Boolean {
|
||||
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
|
||||
return false
|
||||
}
|
||||
if (any { it == '.' || it == ':' || it == ';' }) {
|
||||
return false
|
||||
}
|
||||
return trim().split(Regex("\\s+")).size <= 4
|
||||
}
|
||||
|
||||
private fun titleCase(value: String): String =
|
||||
value.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* User-tunable voice barge-in preferences.
|
||||
*
|
||||
* Phase V follow-on — owned by the voice-barge-in plan (Wave 1 / unit B1).
|
||||
*
|
||||
* Barge-in lets the user interrupt TTS playback by speaking. The three knobs
|
||||
* here back the Voice Settings "Interruption" section added by B5 and are
|
||||
* consumed by [com.hermesandroid.relay.viewmodel.VoiceViewModel] (wired in
|
||||
* B4):
|
||||
*
|
||||
* - [enabled] — master toggle for the whole barge-in path. When false, the
|
||||
* listener never starts and TTS plays uninterrupted. Default off at launch
|
||||
* on both flavors so existing users aren't surprised by mic activation
|
||||
* during a speaking turn.
|
||||
*
|
||||
* - [sensitivity] — maps to Silero VAD threshold + hysteresis tuning inside
|
||||
* [com.hermesandroid.relay.audio.VadEngine]. [BargeInSensitivity.Off] is
|
||||
* a UI convenience for "disable without flipping the top-level toggle";
|
||||
* it short-circuits the VAD to `isSpeech=false` regardless of input.
|
||||
*
|
||||
* - [resumeAfterInterruption] — if the user interrupts, then falls silent
|
||||
* within the 600 ms watchdog, should we re-enqueue the un-played sentence
|
||||
* chunks so the agent finishes speaking? On by default — without it,
|
||||
* barge-in behaves like a hard cancel, which is more abrupt than most
|
||||
* conversational UX expects.
|
||||
*
|
||||
* Matches the [BridgePreferences] / [VoicePreferences] / [MediaSettings] style:
|
||||
* single shared DataStore (`relayDataStore`), one key per scalar field, enum
|
||||
* stored as its `name` (cheap + schema-evolvable via fall-back to default on
|
||||
* unknown values). No migration needed — preferences are additive.
|
||||
*/
|
||||
data class BargeInPreferences(
|
||||
val enabled: Boolean = DEFAULT_ENABLED,
|
||||
val sensitivity: BargeInSensitivity = DEFAULT_SENSITIVITY,
|
||||
val resumeAfterInterruption: Boolean = DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
|
||||
/**
|
||||
* VAD sensitivity preset for barge-in detection.
|
||||
*
|
||||
* Tuning lives in [com.hermesandroid.relay.audio.VadEngine.setSensitivity] —
|
||||
* see the B2 unit spec for the exact `(threshold, attack, release,
|
||||
* consecutive)` tuple each value maps to. [Off] is UI-only shorthand for
|
||||
* "disable detection without clearing the toggle".
|
||||
*/
|
||||
enum class BargeInSensitivity {
|
||||
Off,
|
||||
Low,
|
||||
Default,
|
||||
High,
|
||||
}
|
||||
|
||||
const val DEFAULT_ENABLED: Boolean = false
|
||||
val DEFAULT_SENSITIVITY: BargeInSensitivity = BargeInSensitivity.Default
|
||||
const val DEFAULT_RESUME_AFTER_INTERRUPTION: Boolean = true
|
||||
|
||||
/**
|
||||
* DataStore-backed repository for [BargeInPreferences].
|
||||
*
|
||||
* Primary constructor takes an [android.content.Context] and resolves the
|
||||
* shared [Context.relayDataStore]; the secondary constructor takes a raw
|
||||
* [DataStore] so unit tests can inject a filesystem-backed instance without
|
||||
* needing an Android [android.content.Context]. Matches the shape of
|
||||
* [BridgeSafetyPreferencesRepository] (field `flow: Flow<...>`, per-field
|
||||
* suspend setters).
|
||||
*/
|
||||
class BargeInPreferencesRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
|
||||
private val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
|
||||
private val KEY_RESUME_AFTER_INTERRUPTION =
|
||||
booleanPreferencesKey("barge_in_resume_after_interruption")
|
||||
}
|
||||
|
||||
val flow: Flow<BargeInPreferences> = dataStore.data
|
||||
.map { prefs ->
|
||||
BargeInPreferences(
|
||||
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
|
||||
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
|
||||
?: DEFAULT_SENSITIVITY,
|
||||
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
|
||||
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setEnabled(value: Boolean) {
|
||||
dataStore.edit { it[KEY_ENABLED] = value }
|
||||
}
|
||||
|
||||
suspend fun setSensitivity(value: BargeInSensitivity) {
|
||||
dataStore.edit { it[KEY_SENSITIVITY] = value.name }
|
||||
}
|
||||
|
||||
suspend fun setResumeAfterInterruption(value: Boolean) {
|
||||
dataStore.edit { it[KEY_RESUME_AFTER_INTERRUPTION] = value }
|
||||
}
|
||||
|
||||
private fun decodeSensitivity(raw: String): BargeInSensitivity =
|
||||
runCatching { BargeInSensitivity.valueOf(raw) }.getOrDefault(DEFAULT_SENSITIVITY)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -52,6 +53,15 @@ data class BridgeSafetySettings(
|
||||
val autoDisableMinutes: Int = DEFAULT_AUTO_DISABLE_MINUTES,
|
||||
val statusOverlayEnabled: Boolean = DEFAULT_STATUS_OVERLAY_ENABLED,
|
||||
val confirmationTimeoutSeconds: Int = DEFAULT_CONFIRMATION_TIMEOUT_SECONDS,
|
||||
// === v0.4.1 unattended-access ===
|
||||
/** Sideload-only opt-in: agent may wake the screen on bridge commands
|
||||
* while the user is away. Hard-bounded by [autoDisableMinutes]. */
|
||||
val unattendedAccessEnabled: Boolean = DEFAULT_UNATTENDED_ACCESS_ENABLED,
|
||||
/** True after the user has dismissed the one-time scary opt-in dialog
|
||||
* for unattended access. Used so we only show it on FIRST enable, not
|
||||
* every toggle. */
|
||||
val unattendedWarningSeen: Boolean = DEFAULT_UNATTENDED_WARNING_SEEN,
|
||||
// === END v0.4.1 unattended-access ===
|
||||
)
|
||||
|
||||
/** Seeded blocklist — conservative high-stakes packages shipped out of the box. */
|
||||
@@ -120,6 +130,24 @@ const val DEFAULT_CONFIRMATION_TIMEOUT_SECONDS: Int = 30
|
||||
const val MIN_CONFIRMATION_TIMEOUT_SECONDS: Int = 10
|
||||
const val MAX_CONFIRMATION_TIMEOUT_SECONDS: Int = 60
|
||||
|
||||
// === v0.4.1 unattended-access defaults ===
|
||||
//
|
||||
// Default OFF for both flags. The decision matrix:
|
||||
//
|
||||
// - `unattendedAccessEnabled` is the load-bearing user opt-in. Defaulting
|
||||
// to false matches the principle of least power for a sideload-only
|
||||
// capability — the user must affirmatively grant before the screen-
|
||||
// wake wake-lock fires.
|
||||
//
|
||||
// - `unattendedWarningSeen` defaults to false so the first time the user
|
||||
// flips the toggle on, they get the scary explainer dialog (security
|
||||
// model + credential-lock limitation). Once acknowledged the flag is
|
||||
// set true and the dialog never appears again for that install — re-
|
||||
// enabling after a manual disable is silent.
|
||||
const val DEFAULT_UNATTENDED_ACCESS_ENABLED: Boolean = false
|
||||
const val DEFAULT_UNATTENDED_WARNING_SEEN: Boolean = false
|
||||
// === END v0.4.1 unattended-access defaults ===
|
||||
|
||||
class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
@@ -130,6 +158,24 @@ class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
private val KEY_CONFIRMATION_TIMEOUT =
|
||||
intPreferencesKey("bridge_confirmation_timeout_seconds")
|
||||
|
||||
// === v0.4.1 unattended-access keys ===
|
||||
private val KEY_UNATTENDED_ACCESS_ENABLED =
|
||||
booleanPreferencesKey("bridge_unattended_access_enabled")
|
||||
private val KEY_UNATTENDED_WARNING_SEEN =
|
||||
booleanPreferencesKey("bridge_unattended_warning_seen")
|
||||
// === END v0.4.1 unattended-access keys ===
|
||||
|
||||
// === "Don't ask again" trusted destructive verbs ===
|
||||
// Set of normalized (lowercase, trimmed) destructive verbs the user
|
||||
// has chosen to stop being prompted about. Stored as a native
|
||||
// stringSet (not JSON) because there's no ordering/evolution concern
|
||||
// here — just membership. Empty set by default — every destructive
|
||||
// verb prompts until the user opts in via the confirmation dialog's
|
||||
// "Don't ask again" checkbox.
|
||||
private val KEY_TRUSTED_DESTRUCTIVE_VERBS =
|
||||
stringSetPreferencesKey("bridge_trusted_destructive_verbs")
|
||||
// === END trusted destructive verbs ===
|
||||
|
||||
/** Sentinel key we set the first time settings get written. Used to
|
||||
* tell "user cleared the blocklist" from "user has never touched it". */
|
||||
private val KEY_SAFETY_INITIALIZED = booleanPreferencesKey("bridge_safety_initialized")
|
||||
@@ -155,6 +201,10 @@ class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
confirmationTimeoutSeconds = (prefs[KEY_CONFIRMATION_TIMEOUT]
|
||||
?: DEFAULT_CONFIRMATION_TIMEOUT_SECONDS)
|
||||
.coerceIn(MIN_CONFIRMATION_TIMEOUT_SECONDS, MAX_CONFIRMATION_TIMEOUT_SECONDS),
|
||||
unattendedAccessEnabled = prefs[KEY_UNATTENDED_ACCESS_ENABLED]
|
||||
?: DEFAULT_UNATTENDED_ACCESS_ENABLED,
|
||||
unattendedWarningSeen = prefs[KEY_UNATTENDED_WARNING_SEEN]
|
||||
?: DEFAULT_UNATTENDED_WARNING_SEEN,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -236,6 +286,85 @@ class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// === v0.4.1 unattended-access setters ===
|
||||
|
||||
/**
|
||||
* Persist the unattended-access opt-in state. Called from [BridgeViewModel]
|
||||
* after the user flips the toggle (and dismissed the scary warning on
|
||||
* first enable). Does NOT touch [KEY_UNATTENDED_WARNING_SEEN] — that
|
||||
* latch is owned by [setUnattendedWarningSeen] so a Disable→Re-enable
|
||||
* cycle doesn't re-show the dialog.
|
||||
*/
|
||||
suspend fun setUnattendedAccessEnabled(enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_UNATTENDED_ACCESS_ENABLED] = enabled
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latch that records whether the user has dismissed the one-time
|
||||
* scary opt-in dialog for unattended access. Set true the first time
|
||||
* the user taps "I understand" (or whatever the confirm button reads).
|
||||
* Never gets cleared in normal operation — the user's understanding
|
||||
* persists across enable/disable cycles. Cleared only via [reset]
|
||||
* (ie. user wipes app data).
|
||||
*/
|
||||
suspend fun setUnattendedWarningSeen(seen: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_UNATTENDED_WARNING_SEEN] = seen
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
// === END v0.4.1 unattended-access setters ===
|
||||
|
||||
// === "Don't ask again" trusted destructive verbs ===
|
||||
|
||||
/**
|
||||
* Live Flow of the verbs the user has marked "don't ask again" for.
|
||||
* Values are normalized (lowercase, trimmed) on write, so comparisons
|
||||
* against [BridgeSafetySettings.destructiveVerbs] and the incoming
|
||||
* modal `verb` field don't need any extra casing logic at the read
|
||||
* site. Master blocklist + master-disable still take precedence over
|
||||
* this set — this is only consulted AFTER the destructive-verb gate
|
||||
* decides a confirmation would otherwise fire.
|
||||
*/
|
||||
val trustedDestructiveVerbs: Flow<Set<String>> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS]?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun setTrustedDestructiveVerbs(verbs: Set<String>) {
|
||||
val normalized = verbs
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addTrustedDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] ?: emptySet()
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = current + normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearTrustedDestructiveVerbs() {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = emptySet()
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
// === END trusted destructive verbs ===
|
||||
|
||||
private fun decodeSet(raw: String): Set<String> =
|
||||
runCatching { json.decodeFromString<List<String>>(raw).toSet() }
|
||||
.getOrDefault(emptySet())
|
||||
|
||||
@@ -39,8 +39,124 @@ 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()
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
/**
|
||||
* Rich content cards emitted by the agent via `CARD:{json}` line
|
||||
* markers in the text stream. Parsed in
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.scanForCardMarkers]
|
||||
* and rendered inline by
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble]. Mirrors
|
||||
* [attachments]' lifecycle — the marker line is stripped from
|
||||
* [content] on match so the raw `CARD:{...}` never appears in the
|
||||
* bubble text, same as the `MEDIA:` parser.
|
||||
*/
|
||||
val cards: List<HermesCard> = emptyList(),
|
||||
/**
|
||||
* Tapped actions on any of [cards]. Checked by the renderer so a card
|
||||
* whose action has been dispatched collapses into a confirmation state
|
||||
* rather than re-offering the buttons.
|
||||
*/
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
/**
|
||||
* Structured trace of a phone-local voice intent dispatch. Populated only
|
||||
* for messages whose [id] starts with `voice-intent-` and that originated
|
||||
* from the sideload voice classifier. Used by
|
||||
* [com.hermesandroid.relay.voice.VoiceIntentSyncBuilder] to reconstruct
|
||||
* OpenAI-format `assistant` + `tool` message pairs the next time chat
|
||||
* sends a payload to the server, so the LLM sees prior voice actions in
|
||||
* its session memory.
|
||||
*
|
||||
* Null on every other message — including server-loaded history, normal
|
||||
* user messages, regular assistant turns, and tool-call cards rendered
|
||||
* via [ToolCall].
|
||||
*
|
||||
* The sync builder treats messages with [voiceIntent] != null and
|
||||
* [VoiceIntentTrace.syncedToServer] == false as the inputs to its
|
||||
* synthesis pass; on a successful send we flip [VoiceIntentTrace.syncedToServer]
|
||||
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* so they're not re-sent on the next turn.
|
||||
*/
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* Structured details about a phone-local voice intent that was dispatched
|
||||
* in-process via [com.hermesandroid.relay.network.handlers.BridgeCommandHandler.handleLocalCommand].
|
||||
*
|
||||
* Captured on a [ChatMessage] (id prefix `voice-intent-`) so the next chat
|
||||
* payload can include synthetic OpenAI-format `assistant` + `tool` message
|
||||
* pairs that bring the server-side LLM up to speed on what the user did via
|
||||
* voice. Without this, follow-up text questions like "did that work?" hit
|
||||
* the LLM with no prior context and get hallucinated answers.
|
||||
*
|
||||
* Why a single field instead of three booleans + nullable strings on
|
||||
* [ChatMessage]: keeps the ChatMessage surface small and makes the "this is
|
||||
* a voice-intent trace, treat it specially" check a single null-vs-non-null
|
||||
* comparison rather than a multi-field rule that would drift over time.
|
||||
*
|
||||
* @property toolName The Hermes plugin tool name the intent maps to
|
||||
* (`android_open_app`, `android_send_sms`, etc.). Matches the names the
|
||||
* gateway-side LLM tools use when it calls the same actions itself. Must
|
||||
* start with `android_` to be a valid sync target — the builder uses this
|
||||
* prefix as a sanity gate when constructing the synthetic tool_call.
|
||||
* @property argumentsJson Compact JSON object with the args the intent
|
||||
* resolved to, e.g. `{"app_name":"Chrome","package":"com.android.chrome"}`
|
||||
* for an Open App dispatch. Stored as a string so we can hand it straight
|
||||
* to the OpenAI `function.arguments` field, which is itself a JSON-encoded
|
||||
* string by spec. Must be valid JSON.
|
||||
* @property success True if the local dispatch returned a 200-class status,
|
||||
* false on any other status (denial, permission missing, dispatcher
|
||||
* failure, etc.). Drives whether the synthetic `tool`-role response
|
||||
* includes an `error` field.
|
||||
* @property resultJson Compact JSON object describing the dispatch outcome.
|
||||
* On success, typically `{"ok":true,...}` with any tool-specific fields
|
||||
* from [com.hermesandroid.relay.network.handlers.LocalDispatchResult.resultJson].
|
||||
* On failure, an error envelope including `ok:false`, `error`, optionally
|
||||
* `error_code`. Stored as a string and rendered verbatim into the
|
||||
* synthetic `tool`-role message's `content` field.
|
||||
* @property syncedToServer Idempotency guard. Flipped to true by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* the moment we hand the request payload to the API client. Once true,
|
||||
* the trace is excluded from future sync passes — the server-side
|
||||
* session has already absorbed it.
|
||||
*/
|
||||
data class VoiceIntentTrace(
|
||||
val toolName: String,
|
||||
val argumentsJson: String,
|
||||
val success: Boolean,
|
||||
val resultJson: String,
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -118,9 +234,31 @@ 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
|
||||
val completedAt: Long? = null,
|
||||
/**
|
||||
* Gateway `tool.generating` pre-start phase — the model is still
|
||||
* streaming this tool's arguments. Cleared (flipped false) when the
|
||||
* matching `tool.start` arrives and the call begins executing. Renders
|
||||
* as the quiet "preparing" state in ToolProgressCard / CompactToolCall
|
||||
* rather than the active running spinner.
|
||||
*/
|
||||
val isGenerating: Boolean = false,
|
||||
/**
|
||||
* Subagent lane index from gateway `subagent.*` events (`task_index`).
|
||||
* Null = top-level tool call, rendered exactly as before. Non-null
|
||||
* calls are grouped per index into a SubagentLane under the bubble.
|
||||
*/
|
||||
val taskIndex: Int? = null,
|
||||
/**
|
||||
* Human label for the owning subagent lane — the `subagent.start`
|
||||
* goal truncated to 60 chars. Carried on each child call so the lane
|
||||
* header can render without a separate lane registry.
|
||||
*/
|
||||
val taskLabel: String? = null
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
@@ -134,5 +272,16 @@ data class ChatSession(
|
||||
val title: String?,
|
||||
val model: String?,
|
||||
val messageCount: Int = 0,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
val startTimestamp: Long
|
||||
get() = firstPositive(startedAt, updatedAt, lastActivityAt)
|
||||
|
||||
private fun firstPositive(vararg values: Long): Long =
|
||||
values.firstOrNull { it > 0L } ?: 0L
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
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.
|
||||
*
|
||||
* Each connection has its own:
|
||||
* - API server URL + relay URL
|
||||
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
|
||||
* session token, device ID, API key, and paired-session metadata.
|
||||
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
||||
* so that store is intrinsically per-connection as long as hosts differ).
|
||||
* - Last-active session ID (to restore the open chat on connection switch).
|
||||
* - Transport hint + session expiry mirrored from the server's `auth.ok`
|
||||
* payload so the connection list can show "expires in 3d" without cracking
|
||||
* open the token store.
|
||||
*
|
||||
* Switching connection is a HEAVY context swap — caller is expected to tear down
|
||||
* the current [com.hermesandroid.relay.network.ConnectionManager],
|
||||
* [com.hermesandroid.relay.auth.AuthManager], and API client, then construct
|
||||
* fresh ones pointed at the new connection's `tokenStoreKey`.
|
||||
*
|
||||
* **Zero-disruption migration:** the legacy pre-multi-connection install kept
|
||||
* all of its auth state in a single EncryptedSharedPreferences file named
|
||||
* [LEGACY_TOKEN_STORE_KEY]. On first launch after the multi-connection upgrade,
|
||||
* [ConnectionStore.migrateLegacyConnectionIfNeeded] seeds connection 0 pointing
|
||||
* at that existing file — no token migration, no re-pair.
|
||||
*
|
||||
* **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 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(
|
||||
val id: String,
|
||||
val label: String,
|
||||
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,
|
||||
val transportHint: String? = null,
|
||||
/** 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
|
||||
* [com.hermesandroid.relay.auth.KeystoreTokenStore]'s original
|
||||
* hardcoded `PREFS_NAME`. Connection 0 re-uses this file as-is so the
|
||||
* existing paired device keeps working across the upgrade.
|
||||
*/
|
||||
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
|
||||
* UUID so the on-disk filename stays short and human-diffable, which
|
||||
* matters because [android.content.Context.deleteSharedPreferences]
|
||||
* only accepts a filename string.
|
||||
*/
|
||||
fun buildTokenStoreKey(id: String): String = "hermes_auth_${id.take(8)}"
|
||||
|
||||
/**
|
||||
* Human-friendly default label for a newly-added connection. Uses the
|
||||
* hostname of the API server URL so "http://192.168.1.10:8642" becomes
|
||||
* "192.168.1.10". Falls back to the raw URL if parsing fails (e.g.,
|
||||
* user typed a malformed value — better to show something recognizable
|
||||
* than to crash).
|
||||
*/
|
||||
fun extractDefaultLabel(apiServerUrl: String): String {
|
||||
return try {
|
||||
URI(apiServerUrl).host ?: apiServerUrl
|
||||
} catch (_: Exception) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Single source of truth for the list of Hermes server connections and which
|
||||
* one is currently active.
|
||||
*
|
||||
* Persistence lives on [Context.relayDataStore] — the same DataStore used by
|
||||
* [FeatureFlags] / [PairingPreferences] / etc. — under two keys:
|
||||
*
|
||||
* - [KEY_CONNECTIONS] — JSON array of [Connection] serialized via
|
||||
* kotlinx.serialization.
|
||||
* - [KEY_ACTIVE_CONNECTION_ID] — the UUID of the currently-active connection.
|
||||
* May be absent on a fresh install or after
|
||||
* the last connection is removed.
|
||||
*
|
||||
* The store exposes three [StateFlow]s:
|
||||
*
|
||||
* - [connections] — the full list, hot on the store's own scope.
|
||||
* - [activeConnectionId] — the active connection's UUID, or null.
|
||||
* - [activeConnection] — derived from the above two. Null when the active
|
||||
* ID is missing or refers to a connection that no
|
||||
* longer exists (stale ID after a delete, for
|
||||
* example).
|
||||
*
|
||||
* The store is **single-writer by convention** — all mutations go through its
|
||||
* suspend fns, each of which uses a [DataStore.edit] block under the hood so
|
||||
* concurrent writers serialize correctly. No external locking required.
|
||||
*
|
||||
* Tests instantiate it via the internal constructor that accepts a raw
|
||||
* [DataStore] so they can point it at a temp-folder preferences file without
|
||||
* needing an Android Context.
|
||||
*
|
||||
* **Terminology note (2026-04-18):** renamed from `ProfileStore` so that the
|
||||
* term "Profile" is free to mean what Hermes's server config means by it
|
||||
* (agent profiles). Legacy DataStore keys (`profiles_v1`, `active_profile_id`)
|
||||
* are migrated once on first launch — see the init block below.
|
||||
*/
|
||||
class ConnectionStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val context: Context?,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Production constructor — wires the store to [Context.relayDataStore].
|
||||
* The context reference is kept so [removeConnection] can call
|
||||
* [Context.deleteSharedPreferences] on the connection's
|
||||
* EncryptedSharedPreferences file when it's removed.
|
||||
*/
|
||||
constructor(context: Context) : this(
|
||||
dataStore = context.relayDataStore,
|
||||
context = context.applicationContext,
|
||||
)
|
||||
|
||||
/**
|
||||
* Test constructor — accepts a raw [DataStore]. The context is null, so
|
||||
* [removeConnection] skips the file-deletion side effect (tests don't have
|
||||
* access to a real EncryptedSharedPreferences anyway).
|
||||
*/
|
||||
internal constructor(dataStore: DataStore<Preferences>) : this(
|
||||
dataStore = dataStore,
|
||||
context = null,
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val connectionListSerializer = ListSerializer(Connection.serializer())
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
private val _connections = MutableStateFlow<List<Connection>>(emptyList())
|
||||
val connections: StateFlow<List<Connection>> = _connections.asStateFlow()
|
||||
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||
* seed values, which are indistinguishable from a genuinely empty store.
|
||||
* Consumers that must not mistake "still loading" for "nothing configured"
|
||||
* — e.g. the chat empty-state, which would otherwise flash a "Connect to
|
||||
* Hermes" CTA on every cold start — gate on this instead of on emptiness.
|
||||
*/
|
||||
private val _isHydrated = MutableStateFlow(false)
|
||||
val isHydrated: StateFlow<Boolean> = _isHydrated.asStateFlow()
|
||||
|
||||
/**
|
||||
* Derived: the active connection, or null when the active ID is missing
|
||||
* or points to a deleted connection. Recomputes every time either
|
||||
* upstream emits — cheap list scan, no memoization needed.
|
||||
*/
|
||||
val activeConnection: StateFlow<Connection?> = combine(_connections, _activeConnectionId) { list, id ->
|
||||
if (id == null) null else list.firstOrNull { it.id == id }
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
val prefs = dataStore.data.first()
|
||||
val newJson = prefs[KEY_CONNECTIONS]
|
||||
val oldJson = prefs[KEY_LEGACY_PROFILES]
|
||||
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
|
||||
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
|
||||
|
||||
// Prefer the new key. If absent and the old key has data,
|
||||
// migrate it once: write to the new key and clear the old ones
|
||||
// so we don't thrash every boot. JSON shape is identical
|
||||
// between the two names — `{"id": ..., "label": ..., ...}` —
|
||||
// so no per-record migration is needed.
|
||||
if (newJson == null && oldJson != null) {
|
||||
dataStore.edit { p ->
|
||||
p[KEY_CONNECTIONS] = oldJson
|
||||
p.remove(KEY_LEGACY_PROFILES)
|
||||
if (activeNew == null && activeOld != null) {
|
||||
p[KEY_ACTIVE_CONNECTION_ID] = activeOld
|
||||
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
}
|
||||
}
|
||||
_connections.value = decodeConnections(oldJson)
|
||||
_activeConnectionId.value = activeOld
|
||||
Log.i(
|
||||
TAG,
|
||||
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
|
||||
)
|
||||
} else {
|
||||
_connections.value = decodeConnections(newJson)
|
||||
_activeConnectionId.value = activeNew
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
} finally {
|
||||
// Mark hydration done even on failure — a failed read still
|
||||
// means "we now know the store's state is empty", so the UI
|
||||
// should stop showing the neutral loading gate.
|
||||
_isHydrated.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mutations ----------------------------------------------------------
|
||||
|
||||
suspend fun addConnection(connection: Connection) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
// If the same ID already exists, treat this as an upsert —
|
||||
// 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 normalized = connection.withDashboardDefaults()
|
||||
val next = current.filterNot { it.id == connection.id } + normalized
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the connection with [connection]'s id. No-op if no connection
|
||||
* with that id exists — callers should use [addConnection] for inserts.
|
||||
*/
|
||||
suspend fun updateConnection(connection: Connection) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (current.none { it.id == connection.id }) {
|
||||
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
|
||||
return@edit
|
||||
}
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.map { if (it.id == connection.id) normalized else it }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the connection with [id] from the list and delete its backing
|
||||
* EncryptedSharedPreferences file. If [id] is currently active, the
|
||||
* active pointer is cleared — callers are responsible for picking a new
|
||||
* active connection.
|
||||
*
|
||||
* The EncryptedSharedPreferences file is deleted via
|
||||
* [Context.deleteSharedPreferences], which is documented as API 24+ and
|
||||
* is safe on our minSdk 26. The legacy connection's file
|
||||
* ([Connection.LEGACY_TOKEN_STORE_KEY]) is deleted the same way — there's
|
||||
* nothing structurally special about it once the user explicitly asks
|
||||
* to remove connection 0.
|
||||
*/
|
||||
suspend fun removeConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
var removed: Connection? = null
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
removed = current.firstOrNull { it.id == id }
|
||||
val next = current.filterNot { it.id == id }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
|
||||
if (prefs[KEY_ACTIVE_CONNECTION_ID] == id) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
}
|
||||
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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setActiveConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_activeConnectionId.value = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update just the `lastActiveSessionId` on the identified connection.
|
||||
* Called whenever the user picks a chat session so connection-switch can
|
||||
* restore the same session on re-selection. No-op if the connection
|
||||
* doesn't exist.
|
||||
*/
|
||||
suspend fun setLastActiveSessionId(connectionId: String, sessionId: String?) {
|
||||
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(lastActiveSessionId = sessionId) else it
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the identified connection with pairing metadata pulled out of an
|
||||
* `auth.ok` payload. No-op if the connection doesn't exist — callers are
|
||||
* responsible for ordering this after [addConnection].
|
||||
*
|
||||
* Both [pairedAtMillis] and [expiresAtMillis] are epoch milliseconds —
|
||||
* pass `System.currentTimeMillis()` for pairedAt and `expires_at * 1000`
|
||||
* for the seconds-based auth.ok payload field. `ConnectionsSettingsScreen`
|
||||
* assumes millis when rendering relative time; pass seconds here and
|
||||
* cards will always read as "Paired decades ago".
|
||||
*/
|
||||
suspend fun markPaired(
|
||||
connectionId: String,
|
||||
pairedAtMillis: Long,
|
||||
transportHint: String?,
|
||||
expiresAtMillis: Long?,
|
||||
) {
|
||||
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(
|
||||
pairedAt = pairedAtMillis,
|
||||
transportHint = transportHint,
|
||||
expiresAt = expiresAtMillis,
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot legacy migration: if no connections are persisted yet, seed a
|
||||
* connection 0 pointing at [Connection.LEGACY_TOKEN_STORE_KEY] so the
|
||||
* existing paired install keeps working without a re-pair.
|
||||
*
|
||||
* Idempotent — subsequent calls after connection 0 is seeded (or after
|
||||
* the user has added real connections) are a no-op. Pass the legacy URL
|
||||
* / session values from whatever store currently holds them (e.g.,
|
||||
* [ConnectionViewModel]'s DataStore-backed URL preferences).
|
||||
*/
|
||||
suspend fun migrateLegacyConnectionIfNeeded(
|
||||
legacyApiServerUrl: String?,
|
||||
legacyRelayUrl: String?,
|
||||
legacyLastSessionId: String?,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val existing = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (existing.isNotEmpty()) {
|
||||
// Already migrated or already has user-created connections.
|
||||
return@edit
|
||||
}
|
||||
if (legacyApiServerUrl.isNullOrBlank() && legacyRelayUrl.isNullOrBlank()) {
|
||||
Log.i(TAG, "migrateLegacyConnectionIfNeeded: no legacy URLs to seed")
|
||||
return@edit
|
||||
}
|
||||
val apiUrl = legacyApiServerUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_API_URL
|
||||
val relayUrl = legacyRelayUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_RELAY_URL
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
val seed = Connection(
|
||||
id = id,
|
||||
label = Connection.extractDefaultLabel(apiUrl),
|
||||
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,
|
||||
expiresAt = null,
|
||||
)
|
||||
val next = listOf(seed)
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_connections.value = next
|
||||
_activeConnectionId.value = id
|
||||
Log.i(TAG, "migrateLegacyConnectionIfNeeded: seeded connection 0 id=$id apiUrl=$apiUrl")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
json.encodeToString(connectionListSerializer, list)
|
||||
|
||||
private fun decodeConnections(raw: String?): List<Connection> {
|
||||
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"
|
||||
|
||||
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
||||
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
|
||||
|
||||
// Pre-rename DataStore keys — read once in init on first launch after
|
||||
// the rename, then wiped. See the init block above.
|
||||
private val KEY_LEGACY_PROFILES = stringPreferencesKey("profiles_v1")
|
||||
private val KEY_LEGACY_ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
|
||||
|
||||
// Match the defaults used by ConnectionViewModel so a seeded connection
|
||||
// from migrateLegacyConnectionIfNeeded() resolves to the same endpoints
|
||||
// a fresh install would.
|
||||
private const val DEFAULT_API_URL = "http://localhost:8642"
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
/**
|
||||
* Validation rules for user-editable connection fields. Kept as a standalone
|
||||
* object so the same rules fire in all save paths — inline dialog feedback,
|
||||
* post-pairing add-connection, and any future import path — without the VM,
|
||||
* the UI, and the data layer each re-implementing string checks.
|
||||
*
|
||||
* All validators return null on success or a short human-readable message
|
||||
* suitable for surfacing in an OutlinedTextField `supportingText` slot or a
|
||||
* Snackbar. Messages are intentionally concise — callers add context ("in
|
||||
* connection label", "in relay URL") if the surface needs it.
|
||||
*/
|
||||
object ConnectionValidation {
|
||||
|
||||
const val LABEL_MAX_LEN: Int = 40
|
||||
|
||||
/**
|
||||
* @return null when valid, else a message describing the problem.
|
||||
* Callers should `.trim()` before persisting — this does not
|
||||
* mutate the input.
|
||||
*/
|
||||
fun validateLabel(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "Label can't be blank"
|
||||
if (trimmed.length > LABEL_MAX_LEN) return "Label too long (max $LABEL_MAX_LEN)"
|
||||
// Control characters (newlines, tabs, nul, etc.) would render badly
|
||||
// in chips and are almost certainly a paste-accident.
|
||||
if (trimmed.any { it.isISOControl() }) return "Label can't contain control characters"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* API server must be http:// or https:// with a host. The user pairs
|
||||
* before they can save a connection, so malformed URLs shouldn't reach
|
||||
* this path — but defense-in-depth against manual edits / restored
|
||||
* backups is worth the few lines.
|
||||
*/
|
||||
fun validateApiServerUrl(raw: String): String? = validateUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "API server URL",
|
||||
)
|
||||
|
||||
/** Relay URL must be ws:// or wss:// with a host. */
|
||||
fun validateRelayUrl(raw: String): String? = validateUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("ws", "wss"),
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/**
|
||||
* Catches the "added the same server twice" mistake. Matches when the
|
||||
* candidate's api + relay URLs exactly match an existing connection
|
||||
* (case-insensitive on scheme + host, per RFC 3986). [excludeId] skips
|
||||
* a specific connection so renames don't trip over their own entry.
|
||||
*
|
||||
* Deliberately lenient: two connections may share either URL alone (dev
|
||||
* that points API at prod + relay at a test box, say). Full exact-match
|
||||
* on both is the only blocked case.
|
||||
*/
|
||||
fun findDuplicate(
|
||||
connections: List<Connection>,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
excludeId: String? = null,
|
||||
): Connection? = connections.firstOrNull { c ->
|
||||
c.id != excludeId &&
|
||||
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
|
||||
c.relayUrl.equals(relayUrl, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "$kind can't be blank"
|
||||
val uri = try {
|
||||
URI(trimmed)
|
||||
} catch (_: URISyntaxException) {
|
||||
return "$kind is malformed"
|
||||
}
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme == null || scheme !in allowedSchemes) {
|
||||
return "$kind must start with ${allowedSchemes.joinToString(" or ") { "$it://" }}"
|
||||
}
|
||||
if (uri.host.isNullOrBlank()) return "$kind has no host"
|
||||
val port = uri.port
|
||||
if (port != -1 && (port < 1 || port > 65535)) return "$kind has an invalid port"
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -13,15 +17,27 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
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) {
|
||||
class DataManager(
|
||||
private val context: Context,
|
||||
/**
|
||||
* Multi-connection: the [ConnectionStore] singleton whose snapshot gets
|
||||
* written into [AppBackup.connections] on export. Nullable for
|
||||
* legacy/compat call sites that construct a [DataManager] without
|
||||
* connection support; a null store just means "export an empty
|
||||
* connections list" (equivalent to v2 behavior).
|
||||
*/
|
||||
private val connectionStore: ConnectionStore? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DataManager"
|
||||
@@ -39,53 +55,188 @@ class DataManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* - v2: adds `apiServerUrl` + `relayUrl`; `profiles: List<String>` held
|
||||
* server-issued session labels from `auth.ok` (never actually populated
|
||||
* on export — the field was vestigial).
|
||||
* - v3 (2026-04-18): `profiles: List<Profile>` — carried the new
|
||||
* multi-connection definitions under the then-current "Profile" name.
|
||||
* - v4 (2026-04-18): `connections: List<Connection>` — same shape as v3
|
||||
* under the renamed concept. v3 imports get their `profiles` field
|
||||
* 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 = 2,
|
||||
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 profiles: List<String> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
val connections: List<Connection> = emptyList(),
|
||||
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
|
||||
* in Pass 2 of the multi-connection rollout (2026-04-18) when it was
|
||||
* replaced by the structured `agentProfiles: StateFlow<List<Profile>>`.
|
||||
* Kept only for call-site signature stability; not written to the
|
||||
* backup. The backup's [AppBackup.connections] comes from the
|
||||
* injected [connectionStore] snapshot. Callers should pass
|
||||
* `emptyList()`. Will be removed in a later pass.
|
||||
*/
|
||||
suspend fun exportSettings(
|
||||
serverUrl: String?,
|
||||
theme: String,
|
||||
onboardingCompleted: Boolean,
|
||||
profiles: List<String>,
|
||||
@Suppress("UNUSED_PARAMETER") sessionLabels: List<String>,
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"exportSettings: no ConnectionStore wired — writing empty connections list " +
|
||||
"(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 = 2,
|
||||
version = 5,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
profiles = profiles,
|
||||
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.
|
||||
*
|
||||
* For v1/v2 backups we deliberately drop the old `profiles: List<String>`
|
||||
* field on its own, since kotlinx.serialization's `ignoreUnknownKeys`
|
||||
* would throw if it found the old scalar-string entries where it now
|
||||
* expects [Connection] objects. We pre-parse as a [JsonElement] and
|
||||
* rebuild the object with `connections = []` on older schema versions.
|
||||
*
|
||||
* For v3 backups, the old `profiles: List<Profile>` field is re-mapped
|
||||
* to `connections: List<Connection>` — same wire shape, just renamed.
|
||||
*/
|
||||
fun importSettings(jsonString: String): AppBackup? {
|
||||
return try {
|
||||
json.decodeFromString<AppBackup>(jsonString)
|
||||
val element = json.parseToJsonElement(jsonString)
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val version = obj["version"]?.let {
|
||||
(it as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
} ?: 4
|
||||
val normalized = when {
|
||||
version < 3 -> {
|
||||
// Strip the incompatible v1/v2 `profiles` field so the
|
||||
// serializer doesn't try to decode List<String> into
|
||||
// List<Connection>. The feature never populated the list
|
||||
// in export anyway, so no user data is lost.
|
||||
Log.d(
|
||||
TAG,
|
||||
"importSettings: dropping legacy v$version profiles field " +
|
||||
"(schema was vestigial)",
|
||||
)
|
||||
JsonObject(obj - "profiles")
|
||||
}
|
||||
version == 3 -> {
|
||||
// v3 used `profiles: List<Profile>` with the same wire
|
||||
// shape as v4's `connections: List<Connection>`. Swap
|
||||
// the key name and decode as v4.
|
||||
val profilesField = obj["profiles"]
|
||||
val withoutProfiles = obj - "profiles"
|
||||
if (profilesField != null) {
|
||||
JsonObject(withoutProfiles + ("connections" to profilesField))
|
||||
} else {
|
||||
JsonObject(withoutProfiles)
|
||||
}
|
||||
}
|
||||
else -> obj
|
||||
}
|
||||
json.decodeFromJsonElement(AppBackup.serializer(), normalized)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse backup JSON", e)
|
||||
null
|
||||
@@ -138,6 +289,11 @@ class DataManager(private val context: Context) {
|
||||
// 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() }
|
||||
|
||||
@@ -148,15 +304,7 @@ class DataManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -171,6 +319,61 @@ class DataManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
||||
* pairing, 2026-04-19).
|
||||
*
|
||||
* Pairing QRs can now carry an ordered list of candidate endpoints so a single
|
||||
* pairing works across LAN / Tailscale / public-reverse-proxy networks. The
|
||||
* phone picks the highest-priority reachable candidate at connect time and
|
||||
* re-evaluates on network change.
|
||||
*
|
||||
* Wire contract (v3 pairing payload):
|
||||
* ```json
|
||||
* {
|
||||
* "role": "lan",
|
||||
* "priority": 0,
|
||||
* "api": { "host": "192.168.1.100", "port": 8642, "tls": false },
|
||||
* "relay": { "url": "ws://192.168.1.100:8767", "transport_hint": "ws" }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **Semantics (locked by ADR 24):**
|
||||
* - [role] is an open string. Known values `lan` / `tailscale` / `public`
|
||||
* get styled labels; anything else renders generically (`Custom VPN (<role>)`).
|
||||
* No enum, no normalization — the raw role string must round-trip exactly
|
||||
* so HMAC canonicalization holds.
|
||||
* - [priority] is strict, `0 = highest`. Reachability never promotes a lower
|
||||
* priority over a higher one; it only breaks ties among equal priorities.
|
||||
* - The per-endpoint [RelayEndpoint] intentionally carries **only** the URL
|
||||
* and transport hint. The pairing `code`, `ttl_seconds`, and `grants` stay
|
||||
* at the top level of the pairing payload because they're per-pair
|
||||
* artifacts — not per-endpoint.
|
||||
*/
|
||||
@Serializable
|
||||
data class EndpointCandidate(
|
||||
val role: String,
|
||||
val priority: Int = 0,
|
||||
val api: ApiEndpoint,
|
||||
val relay: RelayEndpoint,
|
||||
)
|
||||
|
||||
/**
|
||||
* The API-server half of an [EndpointCandidate] — the HTTP/SSE target the
|
||||
* phone uses for `/v1/runs`, `/v1/chat/completions`, `/api/sessions/...`, etc.
|
||||
*
|
||||
* Note: [tls] defaults to false so a v2-synthesized candidate (built from a
|
||||
* legacy QR with no `endpoints` field and no top-level `tls`) still
|
||||
* deserializes cleanly.
|
||||
*/
|
||||
@Serializable
|
||||
data class ApiEndpoint(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val tls: Boolean = false,
|
||||
) {
|
||||
/** Build the full API server URL from host, port, and tls flag. */
|
||||
val url: String
|
||||
get() = "${if (tls) "https" else "http"}://$host:$port"
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay-server half of an [EndpointCandidate] — the WSS URL the phone
|
||||
* opens for the bridge + terminal channels.
|
||||
*
|
||||
* @property url full WebSocket URL, e.g. `ws://192.168.1.100:8767` (dev) or
|
||||
* `wss://hermes.example.com/relay` (fronted by a reverse proxy).
|
||||
* @property transportHint `"wss"` / `"ws"` / `null`. Drives the plaintext-ws
|
||||
* consent gate and the transport-security UI badge. Never gates
|
||||
* behavior on its own — the scheme of [url] is authoritative.
|
||||
*/
|
||||
@Serializable
|
||||
data class RelayEndpoint(
|
||||
val url: String,
|
||||
@SerialName("transport_hint")
|
||||
val transportHint: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns true when [EndpointCandidate.role] is one of the built-in, styled
|
||||
* roles: `lan`, `tailscale`, or `public`. Case-insensitive match — but the
|
||||
* role string itself is still preserved verbatim for HMAC canonicalization.
|
||||
*
|
||||
* Unknown roles (`"wireguard"`, `"zerotier"`, `"netbird-eu"`, operator-defined
|
||||
* labels) return false so the UI can fall back to [displayLabel]'s generic
|
||||
* "Custom VPN" treatment.
|
||||
*/
|
||||
fun EndpointCandidate.isKnownRole(): Boolean {
|
||||
return when (role.lowercase()) {
|
||||
"lan", "tailscale", "public" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for the UI. Known roles get fixed-case styled labels;
|
||||
* unknown roles render as `"Custom VPN (<role>)"` with the raw role preserved
|
||||
* so an operator can see exactly what they labeled it.
|
||||
*
|
||||
* The raw [role] on the [EndpointCandidate] is NOT modified — it stays in its
|
||||
* emitted form for HMAC canonicalization. This is a display-only transform.
|
||||
*/
|
||||
fun EndpointCandidate.displayLabel(): String {
|
||||
return when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
else -> "Custom VPN ($role)"
|
||||
}
|
||||
}
|
||||
@@ -59,26 +59,34 @@ object FeatureFlags {
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety hook for the V5 ExoPlayer rollout.
|
||||
*
|
||||
* Default `true` — VoicePlayer uses Media3 ExoPlayer for gapless TTS
|
||||
* queue playback. No MediaPlayer fallback is wired this session; if a
|
||||
* regression surfaces in the field we'll rewire one and honor this flag
|
||||
* at the construction site. Flipping this to `false` today has no
|
||||
* effect — it's a placeholder hook, not yet load-bearing.
|
||||
*/
|
||||
const val useExoPlayerVoice: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -101,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. */
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
|
||||
/**
|
||||
* Single source of truth for the opt-in "keep the gateway chat connection
|
||||
* alive in the background" preference. Off by default.
|
||||
*
|
||||
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
|
||||
* StateFlow + setter that drive the foreground service and the client's
|
||||
* no-background-close flag) and
|
||||
* [com.hermesandroid.relay.network.GatewayKeepAliveService]'s Stop notification
|
||||
* action, so both read/write the same key.
|
||||
*/
|
||||
val KEY_GATEWAY_KEEP_ALIVE = booleanPreferencesKey("gateway_keep_alive_background")
|
||||
|
||||
/** Persist the keep-alive preference. Used by the FGS Stop action. */
|
||||
suspend fun Context.setGatewayKeepAlive(enabled: Boolean) {
|
||||
relayDataStore.edit { it[KEY_GATEWAY_KEEP_ALIVE] = enabled }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A rich content card emitted inline in an assistant message via the
|
||||
* `CARD:{json}` line marker. Parsed by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler] and rendered by
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble].
|
||||
*
|
||||
* The marker lives in the text stream alongside `MEDIA:...` for the same
|
||||
* reason: it works unchanged across every streaming endpoint we support
|
||||
* (`/v1/runs`, `/api/sessions/{id}/chat/stream`, `/v1/chat/completions`)
|
||||
* without a server-side schema change. When upstream gains structured card
|
||||
* events, the parser can fan out — the [HermesCard] model stays.
|
||||
*
|
||||
* Unknown [type] values fall back to a generic title+fields render so the
|
||||
* surface doesn't break when the agent emits a card the phone build hasn't
|
||||
* seen. Unknown [accent] / action [style] values degrade to their defaults
|
||||
* the same way.
|
||||
*
|
||||
* Example (single-line in practice):
|
||||
* ```
|
||||
* CARD:{"type":"approval_request","title":"Run shell command?",
|
||||
* "body":"`rm -rf /tmp/cache`","accent":"warning",
|
||||
* "actions":[{"label":"Allow","value":"/approve","style":"primary"},
|
||||
* {"label":"Deny","value":"/deny","style":"danger"}]}
|
||||
* ```
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCard(
|
||||
/**
|
||||
* Card dispatcher key. Known built-ins are defined in [BuiltInTypes];
|
||||
* unknown values render via the generic fallback.
|
||||
*/
|
||||
val type: String,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
/** Markdown-rendered body text. Appears between the header and fields. */
|
||||
val body: String? = null,
|
||||
/**
|
||||
* Semantic accent — maps to a colorScheme token in the renderer.
|
||||
* Valid: `info` (default), `success`, `warning`, `danger`.
|
||||
*/
|
||||
val accent: String? = null,
|
||||
val fields: List<HermesCardField> = emptyList(),
|
||||
val actions: List<HermesCardAction> = emptyList(),
|
||||
/** Small muted text at the bottom of the card. */
|
||||
val footer: String? = null,
|
||||
/**
|
||||
* Optional stable id from the agent. Used by the renderer to track
|
||||
* which action (if any) has been dispatched, so the same card reloaded
|
||||
* from session history doesn't re-prompt. Falls back to the card's
|
||||
* position in the message when null.
|
||||
*
|
||||
* For the gateway ask types this is the ask's `request_id` (or
|
||||
* `approval-<sid>-<ts>` for approval, which has no request id) — the
|
||||
* dispatch tracker keys answer-once semantics off it.
|
||||
*/
|
||||
val id: String? = null,
|
||||
/**
|
||||
* Interactive input slot rendered between [fields] and [actions] —
|
||||
* the answer surface for the gateway ask cards (`ask.clarify` choice
|
||||
* chips + free text, `ask.secret` masked field, `ask.sudo`
|
||||
* hold-to-confirm). Null for every plain card. Submissions flow
|
||||
* through the renderer's `onInputSubmit(cardKey, value)` callback and
|
||||
* collapse the card via the same [HermesCardDispatch] list as button
|
||||
* actions.
|
||||
*/
|
||||
val input: HermesCardInput? = null,
|
||||
) {
|
||||
object BuiltInTypes {
|
||||
const val SKILL_RESULT = "skill_result"
|
||||
const val APPROVAL_REQUEST = "approval_request"
|
||||
const val LINK_PREVIEW = "link_preview"
|
||||
const val CALENDAR_EVENT = "calendar_event"
|
||||
const val WEATHER = "weather"
|
||||
|
||||
// Gateway interactive asks (desktop-parity wave). Locally built
|
||||
// from clarify/approval/sudo/secret request events — never parsed
|
||||
// out of the text stream.
|
||||
const val ASK_APPROVAL = "ask.approval"
|
||||
const val ASK_CLARIFY = "ask.clarify"
|
||||
const val ASK_SUDO = "ask.sudo"
|
||||
const val ASK_SECRET = "ask.secret"
|
||||
}
|
||||
|
||||
object Accents {
|
||||
const val INFO = "info"
|
||||
const val SUCCESS = "success"
|
||||
const val WARNING = "warning"
|
||||
const val DANGER = "danger"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive input slot on a [HermesCard]. The flags compose rather than
|
||||
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
|
||||
* submit is the 650ms press-fill button), while clarify is
|
||||
* `choices + allowFreeText` and secret is `masked` alone.
|
||||
*
|
||||
* Security contract: when [masked] is true the submitted value is a secret.
|
||||
* It must never be echoed into chat content, logged, or synced via
|
||||
* CardDispatchSyncBuilder — record [SECRET_PROVIDED_STAMP] as the dispatch's
|
||||
* actionValue instead of the real value. The renderer masks the collapse
|
||||
* stamp for masked inputs regardless, but the dispatch record itself is
|
||||
* persisted and synced, so the caller must not put the secret there.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardInput(
|
||||
/**
|
||||
* Input kind — one of [Kinds]. Drives which composite the renderer
|
||||
* builds; unknown kinds degrade to a plain free-text field so newer
|
||||
* asks still get an answer surface.
|
||||
*/
|
||||
val kind: String,
|
||||
/** Quick-answer chips (clarify). Empty = no chip row. */
|
||||
val choices: List<String> = emptyList(),
|
||||
/** Render the inline free-text mini field under the chips. */
|
||||
val allowFreeText: Boolean = false,
|
||||
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
|
||||
val masked: Boolean = false,
|
||||
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
|
||||
val holdToConfirm: Boolean = false,
|
||||
/**
|
||||
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
|
||||
* The renderer shows a countdown footer (Amber under 30s) and
|
||||
* self-collapses to "Expired — not granted" past it. Null = no timeout
|
||||
* (approval is session-scoped).
|
||||
*/
|
||||
val expiresAtMillis: Long? = null,
|
||||
) {
|
||||
object Kinds {
|
||||
const val CHOICE = "choice"
|
||||
const val TEXT = "text"
|
||||
const val SECRET = "secret"
|
||||
const val CONFIRM = "confirm"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sentinel recorded as [HermesCardDispatch.actionValue] when a
|
||||
* [masked] input is submitted. The real secret value goes only to
|
||||
* the ask-respond RPC — never into the dispatch record, chat
|
||||
* content, or session sync.
|
||||
*/
|
||||
const val SECRET_PROVIDED_STAMP = "secret-provided"
|
||||
|
||||
/**
|
||||
* Value submitted by a bare hold-to-confirm (no text field) — the
|
||||
* sudo/approval "yes" that carries no payload of its own.
|
||||
*/
|
||||
const val CONFIRM_VALUE = "confirm"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A label/value row inside a card. [value] is rendered as markdown so the
|
||||
* agent can embed emphasis, inline code, or links.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardField(
|
||||
val label: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* A tappable action on a card.
|
||||
*
|
||||
* When the user taps the button, the ViewModel reads [mode] to decide how
|
||||
* to dispatch [value]:
|
||||
* - [Modes.SEND_TEXT] (default): sends [value] as a new user message, so
|
||||
* the agent sees it in its next turn. This is how approval_request's
|
||||
* "Allow" / "Deny" replies flow back to the LLM.
|
||||
* - [Modes.SLASH_COMMAND]: runs [value] as a slash command (e.g.
|
||||
* `/approve` or `/clear`). The leading `/` is stripped if present.
|
||||
* - [Modes.OPEN_URL]: opens [value] in an external browser — used by
|
||||
* [HermesCard.BuiltInTypes.LINK_PREVIEW]'s "Open" button.
|
||||
*
|
||||
* [style] picks a button color from the colorScheme:
|
||||
* - `primary` — filled, colorScheme.primary
|
||||
* - `secondary` (default) — outlined, onSurfaceVariant
|
||||
* - `danger` — outlined, colorScheme.error
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardAction(
|
||||
val label: String,
|
||||
val value: String,
|
||||
val style: String? = null,
|
||||
val mode: String? = null,
|
||||
) {
|
||||
object Styles {
|
||||
const val PRIMARY = "primary"
|
||||
const val SECONDARY = "secondary"
|
||||
const val DANGER = "danger"
|
||||
}
|
||||
|
||||
object Modes {
|
||||
const val SEND_TEXT = "send_text"
|
||||
const val SLASH_COMMAND = "slash_command"
|
||||
const val OPEN_URL = "open_url"
|
||||
|
||||
/**
|
||||
* Ask-card answer: dispatch [value] straight to the gateway
|
||||
* ask-respond RPC (clarify/sudo/secret/approval.respond), never
|
||||
* as chat text. Dispatches in this mode are EXCLUDED from
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] —
|
||||
* the server already absorbed the answer through the blocking
|
||||
* ask, and for secrets the value must not enter session memory.
|
||||
*/
|
||||
const val SUBMIT_ASK = "submit_ask"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-only tracking of card action dispatch, stored alongside
|
||||
* [ChatMessage.cards] so a session reload from server history doesn't lose
|
||||
* "I already approved this" state. Keyed by the card's id (or its index
|
||||
* position as a fallback) + action value.
|
||||
*
|
||||
* Persisted to server-side session memory via
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder], modeled on
|
||||
* [com.hermesandroid.relay.voice.VoiceIntentSyncBuilder]: on the next
|
||||
* chat send, unsynced dispatches materialize as OpenAI-format `assistant`
|
||||
* (with structured `tool_calls`) + `tool` message pairs under a synthetic
|
||||
* `hermes_card_action` tool name, splicing them into the session history
|
||||
* the LLM sees. After the API client takes ownership of the request,
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
|
||||
* flips [syncedToServer] so subsequent turns don't re-send the same
|
||||
* trace.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardDispatch(
|
||||
val cardKey: String,
|
||||
val actionValue: String,
|
||||
val timestamp: Long,
|
||||
/**
|
||||
* Idempotency guard for the server-side session sync path.
|
||||
* Flipped to true by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
|
||||
* once the API client has accepted the request that carried this
|
||||
* dispatch's synthetic message pair. Once true, the dispatch is
|
||||
* excluded from future
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder.buildSyntheticMessages]
|
||||
* passes.
|
||||
*/
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
@@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* DataStore-backed preferences for the pairing + security overhaul introduced
|
||||
@@ -42,6 +44,27 @@ object PairingPreferences {
|
||||
private val KEY_INSECURE_ACK_SEEN = booleanPreferencesKey("insecure_ack_seen")
|
||||
private val KEY_INSECURE_REASON = stringPreferencesKey("insecure_reason")
|
||||
private val KEY_TOFU_PINS = stringPreferencesKey("tofu_pins")
|
||||
private val KEY_ALL_INSECURE_PAIR_ACK_SEEN =
|
||||
booleanPreferencesKey("all_insecure_pair_ack_seen")
|
||||
|
||||
/**
|
||||
* Prefix for per-device endpoint-candidate keys. Full key is
|
||||
* `device_endpoints:<deviceId>`; the value is a JSON-encoded
|
||||
* `List<EndpointCandidate>` (ADR 24, 2026-04-19). One key per paired
|
||||
* device so the phone can store + retrieve multi-endpoint pairing
|
||||
* candidates for each host without multiplexing into a single blob.
|
||||
*/
|
||||
private const val KEY_DEVICE_ENDPOINTS_PREFIX = "device_endpoints:"
|
||||
|
||||
private val endpointJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val endpointListSerializer = ListSerializer(EndpointCandidate.serializer())
|
||||
|
||||
private fun deviceEndpointsKey(deviceId: String) =
|
||||
stringPreferencesKey("$KEY_DEVICE_ENDPOINTS_PREFIX$deviceId")
|
||||
|
||||
// --- Pair TTL -----------------------------------------------------------
|
||||
|
||||
@@ -75,6 +98,27 @@ object PairingPreferences {
|
||||
context.relayDataStore.edit { it[KEY_INSECURE_ACK_SEEN] = seen }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-install acknowledgment that the user understands the implications of
|
||||
* pairing to a QR where *every* endpoint candidate is plain text
|
||||
* (`ws://` / `http://` with no secure Tailscale/wss fallback — the
|
||||
* [TransportSecurityState.AllInsecure] case).
|
||||
*
|
||||
* Gates the Pair button on the wizard's Confirm step only for the absolute-
|
||||
* boundary AllInsecure scenario. Mixed pairings (any secure route present)
|
||||
* are NOT gated — the app auto-falls back to the secure one, so the
|
||||
* existing amber advisory is sufficient. Once the user has acknowledged
|
||||
* once on this install the gate is removed for all future AllInsecure
|
||||
* pairs — matches the precedent set by [insecureAckSeen] for the
|
||||
* per-install insecure-mode dialog.
|
||||
*/
|
||||
fun allInsecurePairAckSeen(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { it[KEY_ALL_INSECURE_PAIR_ACK_SEEN] ?: false }
|
||||
|
||||
suspend fun setAllInsecurePairAckSeen(context: Context, seen: Boolean) {
|
||||
context.relayDataStore.edit { it[KEY_ALL_INSECURE_PAIR_ACK_SEEN] = seen }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason the user selected when they flipped insecure mode on.
|
||||
*
|
||||
@@ -139,4 +183,61 @@ object PairingPreferences {
|
||||
|
||||
private fun encodePins(pins: Map<String, String>): String =
|
||||
pins.entries.joinToString("|") { (host, pin) -> "$host=$pin" }
|
||||
|
||||
// --- Per-device endpoint candidates (ADR 24) ---------------------------
|
||||
//
|
||||
// Multi-endpoint pairing carries an ordered list of API+relay candidates
|
||||
// (LAN, Tailscale, public, custom-VPN, ...). The phone persists the list
|
||||
// per-device so the reachability-probe + network-aware switch (Kt-Probe)
|
||||
// can pick the best candidate on every connect without re-reading the
|
||||
// original QR.
|
||||
//
|
||||
// Storage shape: one DataStore string key per deviceId, value is the
|
||||
// JSON-encoded `List<EndpointCandidate>`. JSON keeps us flexible on
|
||||
// schema growth without touching the DataStore key layout (e.g. future
|
||||
// `weight`, `region`, `last_successful_at` per-candidate fields land as
|
||||
// new JSON properties, not new preference keys).
|
||||
|
||||
/**
|
||||
* Persist the ordered endpoint-candidate list for [deviceId]. Overwrites
|
||||
* any previously-stored list for that device. Encodes as JSON so future
|
||||
* schema fields land cleanly without a migration.
|
||||
*/
|
||||
suspend fun setDeviceEndpoints(
|
||||
context: Context,
|
||||
deviceId: String,
|
||||
endpoints: List<EndpointCandidate>,
|
||||
) {
|
||||
val encoded = endpointJson.encodeToString(endpointListSerializer, endpoints)
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[deviceEndpointsKey(deviceId)] = encoded
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the stored endpoint-candidate list for [deviceId]. Emits an
|
||||
* empty list when none has been persisted yet (first-pair / pre-ADR-24
|
||||
* legacy devices) or when decoding fails (forward-compat safety net —
|
||||
* a bad blob shouldn't crash the reachability probe).
|
||||
*/
|
||||
fun getDeviceEndpoints(context: Context, deviceId: String): Flow<List<EndpointCandidate>> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
val raw = prefs[deviceEndpointsKey(deviceId)] ?: return@map emptyList()
|
||||
try {
|
||||
endpointJson.decodeFromString(endpointListSerializer, raw)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the stored endpoint list for [deviceId]. Used when a device is
|
||||
* revoked / re-paired and its old candidate list is no longer trusted.
|
||||
* No-op if no record exists.
|
||||
*/
|
||||
suspend fun removeDeviceEndpoints(context: Context, deviceId: String) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs.remove(deviceEndpointsKey(deviceId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* An agent profile advertised by a Hermes server in its `auth.ok` payload.
|
||||
*
|
||||
* Historically (pre-R1) the server scanned a non-existent top-level
|
||||
* `profiles:` key in `~/.hermes/config.yaml` and effectively shipped an
|
||||
* empty list. Worker R1 rewrote the relay-side loader to scan the REAL
|
||||
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
|
||||
* and added [systemMessage], sourced from each profile's `SOUL.md`.
|
||||
*
|
||||
* 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
|
||||
* chat turns when isolated routing is not available;
|
||||
* - which model and system message the phone can send as compatibility
|
||||
* fallback (via [model] and [systemMessage]);
|
||||
* - which profile-scoped session id Android resumes for local chat context.
|
||||
*
|
||||
* It does not mutate the server's configured default profile.
|
||||
*
|
||||
* Wire shape uses snake_case (`system_message`), this class uses camelCase
|
||||
* (`systemMessage`) — translated via [SerialName].
|
||||
*
|
||||
* **v0.7.0 runtime metadata.** Three optional fields — [gatewayRunning],
|
||||
* [hasSoul], [skillCount] — describe what the relay observes about each
|
||||
* profile directory at discovery time:
|
||||
* - [gatewayRunning] is a read-only probe (best-effort; can be stale or
|
||||
* wrong if the relay's last probe missed a restart). Drives the green/
|
||||
* grey status dot in the agent sheet.
|
||||
* - [hasSoul] is true when the profile directory has a non-empty
|
||||
* `SOUL.md` on disk. Decoupled from `systemMessage != null` so a SOUL
|
||||
* that fails to load (permissions, I/O) still reports its presence.
|
||||
* - [skillCount] is the count of skills visible under the profile
|
||||
* directory — drives the "N skills" chip.
|
||||
*
|
||||
* All three default to safe zero-values and are optional on the wire, so
|
||||
* older relays without the fields deserialize cleanly as
|
||||
* `gatewayRunning = false, hasSoul = false, skillCount = 0`.
|
||||
*
|
||||
* **Hermes profile API metadata.** A relay can advertise an isolated
|
||||
* profile API server without exposing its secret. When [apiServerUrl] is
|
||||
* present, Android routes chat/session traffic to that URL and reuses the
|
||||
* active connection's stored API key. Operators that use distinct API keys
|
||||
* per profile should pair those profile API servers as separate connections.
|
||||
*/
|
||||
@Serializable
|
||||
data class Profile(
|
||||
val name: String,
|
||||
val model: String,
|
||||
val description: String = "",
|
||||
@SerialName("system_message")
|
||||
val systemMessage: String? = null,
|
||||
@SerialName("gateway_running")
|
||||
val gatewayRunning: Boolean = false,
|
||||
@SerialName("has_soul")
|
||||
val hasSoul: Boolean = false,
|
||||
@SerialName("skill_count")
|
||||
val skillCount: Int = 0,
|
||||
@SerialName("api_server_enabled")
|
||||
val apiServerEnabled: Boolean = false,
|
||||
@SerialName("api_server_url")
|
||||
val apiServerUrl: String? = null,
|
||||
@SerialName("api_server_host")
|
||||
val apiServerHost: String? = null,
|
||||
@SerialName("api_server_port")
|
||||
val apiServerPort: Int? = null,
|
||||
@SerialName("api_server_key_present")
|
||||
val apiServerKeyPresent: Boolean = false,
|
||||
) {
|
||||
val hasIsolatedApi: Boolean
|
||||
get() = !apiServerUrl.isNullOrBlank()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Local-only display aliases for agent profiles.
|
||||
*
|
||||
* These names are phone UI labels. They are never sent to Hermes and are keyed
|
||||
* by connection + profile context so the server-default agent can be called
|
||||
* something different on each configured Hermes host.
|
||||
*/
|
||||
class ProfileDisplayAliasStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileDisplayAliasesDataStore)
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_alias__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setAlias(connectionId: String, profileName: String?, alias: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName)
|
||||
if (alias.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = alias
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun aliasFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs -> prefs.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileDisplayAliasesDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_display_aliases")
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Wire contracts for the v0.7.0 Profile Inspector endpoints:
|
||||
*
|
||||
* - `GET /api/profiles/{name}/config`
|
||||
* - `GET /api/profiles/{name}/skills`
|
||||
* - `GET /api/profiles/{name}/soul`
|
||||
* - `GET /api/profiles/{name}/memory`
|
||||
*
|
||||
* All four endpoints are read-only introspection views served by the relay
|
||||
* directly off disk (no gateway round-trip). Field names mirror the Python
|
||||
* worker's contracts exactly — any rename here is a protocol break.
|
||||
*
|
||||
* Optional fields on the wire (`truncated`, `readonly`) default to safe
|
||||
* values so older relays that omit them deserialize without failing the
|
||||
* whole payload.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Response for `GET /api/profiles/{name}/config`.
|
||||
*
|
||||
* `config` is a raw [JsonObject] so the UI can render arbitrary nested YAML
|
||||
* loaded from the profile's `config.yaml`. We don't model every possible
|
||||
* config shape — that's upstream Hermes territory and churns frequently.
|
||||
*
|
||||
* @property readonly The relay always serves this view read-only; the flag
|
||||
* is advisory. Optional on the wire, defaults to false.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileConfigResponse(
|
||||
val profile: String,
|
||||
val path: String,
|
||||
val config: JsonObject,
|
||||
val readonly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One entry in the [ProfileSkillsResponse.skills] list.
|
||||
*
|
||||
* `enabled` is optional on the wire; defaults to true so a pre-v0.7 relay
|
||||
* that doesn't emit the field treats every skill as enabled.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSkillEntry(
|
||||
val name: String,
|
||||
val category: String,
|
||||
val description: String,
|
||||
val path: String,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** Response for `GET /api/profiles/{name}/skills`. */
|
||||
@Serializable
|
||||
data class ProfileSkillsResponse(
|
||||
val profile: String,
|
||||
val skills: List<ProfileSkillEntry>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `GET /api/profiles/{name}/soul`.
|
||||
*
|
||||
* When `exists=false`, [content] is typically an empty string and the UI
|
||||
* should render an empty-state pointing at the expected [path].
|
||||
*
|
||||
* [truncated] is optional on the wire — Python may omit when false.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSoulResponse(
|
||||
val profile: String,
|
||||
val path: String,
|
||||
val content: String,
|
||||
val exists: Boolean,
|
||||
@SerialName("size_bytes")
|
||||
val sizeBytes: Long,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One entry in the [ProfileMemoryResponse.entries] list — a single memory
|
||||
* file found under the profile's memories directory.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileMemoryEntry(
|
||||
val name: String,
|
||||
val filename: String,
|
||||
val path: String,
|
||||
val content: String,
|
||||
@SerialName("size_bytes")
|
||||
val sizeBytes: Long,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Response for `GET /api/profiles/{name}/memory`. */
|
||||
@Serializable
|
||||
data class ProfileMemoryResponse(
|
||||
val profile: String,
|
||||
@SerialName("memories_dir")
|
||||
val memoriesDir: String,
|
||||
val entries: List<ProfileMemoryEntry>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `PUT /api/profiles/{name}/soul`. Server echoes back the
|
||||
* profile name, on-disk path, and bytes written so the UI can
|
||||
* optimistically confirm the write without an immediate re-fetch.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSoulUpdateResponse(
|
||||
val ok: Boolean,
|
||||
val profile: String,
|
||||
val path: String,
|
||||
@SerialName("bytes_written")
|
||||
val bytesWritten: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `PUT /api/profiles/{name}/memory/{filename}`. Same shape
|
||||
* as [ProfileSoulUpdateResponse] plus the filename so a client can
|
||||
* confirm which entry it just wrote (relevant when creating a new file
|
||||
* — the request echo proves the server stored it under the requested
|
||||
* name rather than silently rewriting a collision).
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileMemoryUpdateResponse(
|
||||
val ok: Boolean,
|
||||
val profile: String,
|
||||
val filename: String,
|
||||
val path: String,
|
||||
@SerialName("bytes_written")
|
||||
val bytesWritten: Long,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-connection persisted selection of the active profile name.
|
||||
*
|
||||
* Why separate from [relayDataStore]: the main `relay_settings` store holds
|
||||
* a large pile of global settings that are expensive to iterate on every
|
||||
* profile-selection change, and we want these mappings to survive clean-up
|
||||
* passes over the main store without special-casing per-connection keys.
|
||||
* The dedicated `profile_selections` DataStore is small, scoped, and can be
|
||||
* cleared wholesale without collateral damage.
|
||||
*
|
||||
* Stored shape: one string-preference key per connection id, value is the
|
||||
* profile `name` string (NOT the serialized Profile object — the Profile
|
||||
* itself is advertised fresh by the server on every `auth.ok` and the
|
||||
* on-server set can drift between app launches, so we resolve name → Profile
|
||||
* at read time against the current [ConnectionViewModel.agentProfiles] list).
|
||||
*
|
||||
* A `null` value means "clear" — the key is removed from the store rather
|
||||
* than written as an empty string. That way the flow emits null cleanly
|
||||
* on fresh installs and on explicit clears.
|
||||
*
|
||||
* See Commit 3 of feature/profile-config-readonly for wiring. The caller
|
||||
* ([com.hermesandroid.relay.viewmodel.ConnectionViewModel]) handles the
|
||||
* name → Profile resolution and is the sole consumer.
|
||||
*/
|
||||
class ProfileSelectionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSelectionsDataStore)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Preference-key factory. Per-connection so every connection gets
|
||||
* its own slot — wholesale clearing of the store still works by
|
||||
* calling [clear] per connection id (or, in disaster-recovery,
|
||||
* dropping the file).
|
||||
*/
|
||||
private fun keyFor(connectionId: String) =
|
||||
stringPreferencesKey("selected_profile_$connectionId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the selected profile name for [connectionId]. Passing `null`
|
||||
* removes the key — fresh installs and explicit clears both converge
|
||||
* on the same "no key" state.
|
||||
*/
|
||||
suspend fun setSelectedProfile(connectionId: String, profileName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId)
|
||||
if (profileName == null) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = profileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the persisted profile name for [connectionId], or `null` when
|
||||
* no selection has been stored. Callers must resolve the name against
|
||||
* the current server-advertised profile list — if the profile was
|
||||
* removed on the server, the caller should treat the resolution as
|
||||
* null (see ConnectionViewModel for the reference resolver).
|
||||
*/
|
||||
fun selectedProfileFlow(connectionId: String): Flow<String?> {
|
||||
val key = keyFor(connectionId)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the persisted selection for [connectionId]. Called from the
|
||||
* Connection removal path AFTER the switch-away job completes so we
|
||||
* don't delete the selection the just-unmounted store is still writing.
|
||||
*/
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated DataStore for [ProfileSelectionStore]. Kept separate from
|
||||
* [relayDataStore] so the two stores can evolve independently and a nuke
|
||||
* of one doesn't take out the other.
|
||||
*/
|
||||
internal val Context.profileSelectionsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_selections")
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Which chat transport created (and can resume) a stored session.
|
||||
*
|
||||
* The two chat transports do NOT share session storage, so their ids are not
|
||||
* interchangeable on a non-default profile:
|
||||
* - [GATEWAY] — the `/api/ws` tui_gateway path. `session.create`/`session.resume`
|
||||
* bind the profile's own HERMES_HOME, so sessions live in that profile's
|
||||
* `state.db`. Ids look like `YYYYMMDD_HHMMSS_<hex>`.
|
||||
* - [SSE] — the api_server chat path (`/api/sessions/.../chat/stream`,
|
||||
* `/v1/runs`). The api_server has no per-request profile scoping; it always
|
||||
* persists to its launch `state.db`. Ids look like `api_<unixsecs>_<hex>`.
|
||||
*
|
||||
* Resuming an [SSE] id over the [GATEWAY] (which opens the profile DB) — or vice
|
||||
* versa — fails with "session not found" and silently forks a new session. So
|
||||
* each transport gets its own persisted slot, and a stored id is only ever
|
||||
* restored for the transport that can actually resume it.
|
||||
*/
|
||||
enum class SessionTransport(val key: String) {
|
||||
GATEWAY("gw"),
|
||||
SSE("sse");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Bucket a stored session id by the subsystem that created it — the
|
||||
* id's namespace is the server's own ground truth about which transport
|
||||
* can resume it, more reliable than re-deriving the resolved endpoint
|
||||
* (a turn can fall back from gateway to SSE per-turn).
|
||||
*/
|
||||
fun forSessionId(sessionId: String): SessionTransport =
|
||||
if (sessionId.startsWith("api_")) SSE else GATEWAY
|
||||
|
||||
/**
|
||||
* Bucket a resolved streaming endpoint. Only `"gateway"` resumes from
|
||||
* the per-profile DB; every SSE-family member (`"sessions"` /
|
||||
* `"completions"` / `"runs"`) rides the api_server's launch DB.
|
||||
*/
|
||||
fun forEndpoint(resolvedEndpoint: String): SessionTransport =
|
||||
if (resolvedEndpoint == "gateway") GATEWAY else SSE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection, per-Hermes-profile, per-transport last active chat session.
|
||||
*
|
||||
* This is intentionally separate from [ProfileSelectionStore]. Selection says
|
||||
* which agent is active; this store says which chat session belongs to that
|
||||
* agent on that connection. Null profile name is the explicit Server default
|
||||
* context.
|
||||
*
|
||||
* **Transport dimension (v1.0.0).** The slot is keyed by [SessionTransport] too,
|
||||
* because a gateway session and an api_server (SSE) session are stored in
|
||||
* different databases and cannot be cross-resumed on a non-default profile.
|
||||
* Keying by transport keeps the two from clobbering one slot and guarantees a
|
||||
* restored id is always resumable by the transport asking for it. The key shape
|
||||
* changed in this release, so pre-existing (untransported) slots are not read —
|
||||
* a one-time drop of the "last session" pointer that also clears the exact stale
|
||||
* cross-transport ids that caused mid-conversation forks.
|
||||
*/
|
||||
class ProfileSessionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSessionsDataStore)
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_session__"
|
||||
|
||||
private fun keyName(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}__${transport.key}"
|
||||
|
||||
private fun keyFor(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
) = stringPreferencesKey(keyName(connectionId, profileName, transport))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setSessionId(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
sessionId: String?,
|
||||
) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName, transport)
|
||||
if (sessionId.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = sessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sessionIdFlow(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName, transport)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileSessionsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_sessions")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Friendly-name store for terminal tabs, keyed by the stable wire-side
|
||||
* `session_name` (e.g. `hermes-<deviceId>-tabN`). Cosmetic-only — the name
|
||||
* never crosses the wire; tmux reattach + server-side bookkeeping still use
|
||||
* the opaque session name.
|
||||
*
|
||||
* Keyed on `session_name` because that's also what tmux keys on — if the
|
||||
* user detaches, restarts the app, and reattaches, the same name re-binds
|
||||
* to the same tmux session. Kill explicitly clears the name (via
|
||||
* [clearName]) because the underlying session is destroyed; detach leaves
|
||||
* the name intact as a "come back to this later" hint.
|
||||
*/
|
||||
class TerminalTabNameStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_NAMES_JSON = stringPreferencesKey("terminal_tab_names_json")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
|
||||
val namesFlow: Flow<Map<String, String>> = dataStore.data.map { prefs ->
|
||||
val raw = prefs[KEY_NAMES_JSON] ?: return@map emptyMap()
|
||||
runCatching { JSON.decodeFromString<Map<String, String>>(raw) }
|
||||
.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
/** Set or clear (null / blank) the friendly name for [sessionName]. */
|
||||
suspend fun setName(sessionName: String, displayName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[KEY_NAMES_JSON]
|
||||
?.let { runCatching { JSON.decodeFromString<Map<String, String>>(it) }.getOrNull() }
|
||||
?: emptyMap()
|
||||
val next = if (displayName.isNullOrBlank()) {
|
||||
current - sessionName
|
||||
} else {
|
||||
current + (sessionName to displayName.trim().take(MAX_NAME_LEN))
|
||||
}
|
||||
prefs[KEY_NAMES_JSON] = JSON.encodeToString(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the entry for a session that's being forcibly retired (e.g. kill). */
|
||||
suspend fun clearName(sessionName: String) = setName(sessionName, null)
|
||||
}
|
||||
|
||||
private const val MAX_NAME_LEN = 40
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Snapshot of a single tool-call invocation for the Stats-for-Nerds +
|
||||
* Timeline panels.
|
||||
*
|
||||
* Derived from [ToolCall] on the assistant messages but denormalized so
|
||||
* consumers don't need to walk the message list themselves. The ring
|
||||
* buffer that owns these is bounded — usually the last 10 tool calls
|
||||
* across all assistant messages in the active chat session.
|
||||
*
|
||||
* Status is split into two booleans so the UI can render three visual
|
||||
* states without introducing an enum dependency:
|
||||
* - `isComplete = false` → in-progress (spinner)
|
||||
* - `isComplete = true && success == true` → completed
|
||||
* - `isComplete = true && success == false` → failed
|
||||
*/
|
||||
data class ToolCallEvent(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val startedAtMs: Long,
|
||||
val completedAtMs: Long?,
|
||||
val isComplete: Boolean,
|
||||
val success: Boolean?,
|
||||
val resultSummary: String?,
|
||||
val errorSummary: String?,
|
||||
) {
|
||||
/** Elapsed wall-clock ms; null if the call hasn't completed. */
|
||||
val durationMs: Long?
|
||||
get() = completedAtMs?.let { it - startedAtMs }
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
@@ -20,48 +23,118 @@ import kotlinx.coroutines.flow.map
|
||||
* (V1 doesn't accept a language param).
|
||||
*/
|
||||
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,
|
||||
)
|
||||
|
||||
class VoicePreferencesRepository(private val context: Context) {
|
||||
enum class VoiceEngineMode(val storageValue: String) {
|
||||
HermesVoiceOutput("hermes_voice_output"),
|
||||
RealtimeAgent("realtime_agent");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoiceEngineMode =
|
||||
values().firstOrNull { it.storageValue == value } ?: HermesVoiceOutput
|
||||
}
|
||||
}
|
||||
|
||||
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> = context.relayDataStore.data.map { prefs ->
|
||||
VoiceSettings(
|
||||
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,
|
||||
)
|
||||
val settings: Flow<VoiceSettings> = dataStore.data
|
||||
.map { prefs ->
|
||||
VoiceSettings(
|
||||
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()
|
||||
|
||||
suspend fun setEngineMode(mode: VoiceEngineMode) {
|
||||
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) {
|
||||
context.relayDataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
}
|
||||
|
||||
suspend fun setSilenceThresholdMs(ms: Long) {
|
||||
context.relayDataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setAutoTts(enabled: Boolean) {
|
||||
context.relayDataStore.edit { it[KEY_AUTO_TTS] = enabled }
|
||||
dataStore.edit { it[KEY_AUTO_TTS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setLanguage(language: String) {
|
||||
context.relayDataStore.edit { it[KEY_LANGUAGE] = language }
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,13 @@ class ChannelMultiplexer {
|
||||
// flavor or by the master enable toggle in the UI).
|
||||
"bridge" -> handlers["bridge"]?.onMessage(envelope)
|
||||
// === END PHASE3-accessibility ===
|
||||
// Pairing channel — host-originated pushes that concern the
|
||||
// paired session itself (e.g. `profiles.updated` when the
|
||||
// server rescans its ~/.hermes/profiles tree). Routed to
|
||||
// whatever handler registered for "pairing"; AuthManager
|
||||
// picks it up so the existing agentProfiles flow updates
|
||||
// without requiring a re-pair round-trip.
|
||||
"pairing" -> handlers["pairing"]?.onMessage(envelope)
|
||||
else -> {
|
||||
// Unknown channel — ignore
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
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
|
||||
@@ -10,7 +20,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.CertificatePinner
|
||||
@@ -59,7 +71,38 @@ class ConnectionManager(
|
||||
* Defaults to always-allow for tests and legacy call sites. Production
|
||||
* wiring passes the AuthManager gate from [ConnectionViewModel].
|
||||
*/
|
||||
private val reconnectGate: () -> Boolean = { true }
|
||||
private val reconnectGate: () -> Boolean = { true },
|
||||
/**
|
||||
* Application context used to register the [ConnectivityManager
|
||||
* .NetworkCallback] that drives ADR 24's network-aware re-resolution.
|
||||
* Nullable for legacy call sites / tests — when null, the callback is
|
||||
* never registered and the manager degrades to single-URL behavior.
|
||||
*/
|
||||
private val context: Context? = null,
|
||||
/**
|
||||
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
|
||||
* 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`
|
||||
* disables multi-endpoint resolution even when [endpointResolver] is
|
||||
* non-null — the manager falls back to the single-URL path.
|
||||
*/
|
||||
private val deviceIdProvider: (suspend () -> String?)? = null,
|
||||
) {
|
||||
private val supervisorJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
|
||||
@@ -90,10 +133,19 @@ 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
|
||||
// Last HTTP status seen during WSS upgrade, captured in onFailure.
|
||||
// Used by scheduleReconnect() to pick an appropriate backoff — notably
|
||||
// a much longer one when the server is rate-limiting us (HTTP 429) so
|
||||
// we don't re-fill the ban bucket and brick our own auth window.
|
||||
@Volatile
|
||||
private var lastUpgradeResponseCode: Int? = null
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
@@ -106,27 +158,178 @@ class ConnectionManager(
|
||||
private val _isInsecureConnection = MutableStateFlow(false)
|
||||
val isInsecureConnection: StateFlow<Boolean> = _isInsecureConnection.asStateFlow()
|
||||
|
||||
// ADR 24 — currently-active endpoint candidate. Null when the manager is
|
||||
// running in legacy single-URL mode (no resolver wired, no candidates in
|
||||
// DataStore, or resolve() returned null and we fell back to the caller's
|
||||
// URL). Surfaced through [activeEndpoint] for the UI status chip + the
|
||||
// Endpoints card in Settings.
|
||||
private val _activeEndpoint = MutableStateFlow<EndpointCandidate?>(null)
|
||||
val activeEndpoint: StateFlow<EndpointCandidate?> = _activeEndpoint.asStateFlow()
|
||||
|
||||
/**
|
||||
* Manual role override. When non-null, the resolver's output is replaced
|
||||
* with whichever candidate in the stored list matches this role (case-
|
||||
* insensitive) — provided it's reachable. Reachability still gates: a
|
||||
* user-preferred endpoint that doesn't respond to HEAD /health falls
|
||||
* back through the normal priority chain.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
|
||||
/** Deferred reaction to a network loss — cancelled if a network returns within the grace. */
|
||||
private var networkLossJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/**
|
||||
* Set when a network loss outlives [NETWORK_LOSS_GRACE_MS] — only then may
|
||||
* a re-resolve switch DOWN to a lower-priority endpoint. Prevents a
|
||||
* transient probe miss (Wi-Fi settling) from switching routes and
|
||||
* cancelling an in-flight turn. Cleared once a resolution is published.
|
||||
*/
|
||||
@Volatile
|
||||
private var sustainedLossDeclared = false
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Grace before reacting to a network loss. A transient blip (Wi-Fi
|
||||
* power-save/roam, a brief drop, the OS swapping radios) recovers
|
||||
* within this window and must NOT mark the active endpoint unreachable
|
||||
* or switch routes — doing so rebuilds the chat client and cancels an
|
||||
* in-flight turn. Only a loss sustained past the grace switches.
|
||||
*/
|
||||
private const val NETWORK_LOSS_GRACE_MS = 6_000L
|
||||
// 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
|
||||
// banned forever. Waiting at least as long as the server's block
|
||||
// duration lets the ban expire naturally.
|
||||
private const val RATE_LIMIT_BACKOFF_MS = 300_000L
|
||||
}
|
||||
|
||||
fun setInsecureMode(enabled: Boolean) {
|
||||
if (_insecureMode.value == enabled) {
|
||||
return
|
||||
}
|
||||
_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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(url: String) {
|
||||
// Register the network callback on the first connect attempt. We
|
||||
// only do this once per manager lifetime; [shutdown] tears it down.
|
||||
ensureNetworkCallbackRegistered()
|
||||
|
||||
// ADR 24: if we have a resolver + device id, try the multi-endpoint
|
||||
// path first. Fall back to the caller-supplied URL whenever the
|
||||
// resolver returns nothing — preserving pre-ADR-24 single-URL
|
||||
// behavior for freshly-upgraded installs and for v1/v2 QRs where
|
||||
// the synthesized list just collapses to the same URL anyway.
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: url
|
||||
if (resolved != null) {
|
||||
_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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
* reopen the socket against that URL. Keeping this separate prevents
|
||||
* the callback from re-running the resolve loop inside another
|
||||
* resolve loop.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
@@ -135,16 +338,330 @@ 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 --------------------------------
|
||||
|
||||
/**
|
||||
* Load the device's stored [EndpointCandidate] list and hand it to
|
||||
* [EndpointResolver.resolve]. Returns `null` when any precondition is
|
||||
* missing (no resolver wired, no context, no device id, empty list) OR
|
||||
* when no candidate was reachable — caller then falls back to the
|
||||
* legacy single-URL path.
|
||||
*
|
||||
* Wraps the DataStore read in a 1-second timeout; if DataStore stalls
|
||||
* for any reason we don't block the connect loop forever.
|
||||
*/
|
||||
suspend fun resolveBestEndpoint(): EndpointCandidate? = resolveBestEndpointSafe()
|
||||
|
||||
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
|
||||
val resolver = endpointResolver ?: return null
|
||||
val ctx = context ?: return null
|
||||
|
||||
val endpoints = try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
endpointCandidatesProvider?.invoke()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: 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.value?.let { preferredRole ->
|
||||
val preferred = endpoints.firstOrNull {
|
||||
it.role.equals(preferredRole, ignoreCase = true)
|
||||
}
|
||||
if (preferred != null) {
|
||||
// Single-element list still respects the 2s probe gate.
|
||||
val winner = resolver.resolve(listOf(preferred))
|
||||
if (winner != null) return winner
|
||||
Log.i(TAG, "manualRoleOverride=$preferredRole not reachable — " +
|
||||
"falling through to strict-priority resolve")
|
||||
}
|
||||
}
|
||||
|
||||
return resolver.resolve(endpoints)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run endpoint resolution and publish the winner without forcing a
|
||||
* 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(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
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a specific role as the preferred endpoint. Cleared on [disconnect]
|
||||
* per the Endpoints-card contract. No-op until the next connect / probe
|
||||
* cycle — call [probeAndReconnect] to apply immediately.
|
||||
*/
|
||||
fun setManualRoleOverride(role: String?) {
|
||||
_manualRoleOverride.value = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${_manualRoleOverride.value ?: "(cleared)"}")
|
||||
}
|
||||
|
||||
fun getManualRoleOverride(): String? = _manualRoleOverride.value
|
||||
|
||||
private fun markActiveEndpointUnreachable(reason: String) {
|
||||
val active = _activeEndpoint.value ?: return
|
||||
endpointResolver?.markUnreachable(active)
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
networkResolveJob?.cancel()
|
||||
networkResolveJob = scope.launch {
|
||||
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == 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
|
||||
}
|
||||
// Endpoint hysteresis: a transient blip can make the active
|
||||
// (higher-priority) endpoint's health probe miss, so the resolver
|
||||
// falls through to a LOWER-priority fallback. Switching on that
|
||||
// transient miss rebuilds the chat client and CANCELS an in-flight
|
||||
// turn. Don't switch DOWN in priority unless a sustained loss was
|
||||
// actually declared (the onLost grace elapsed). Same/upgrade
|
||||
// winners always publish.
|
||||
val active = _activeEndpoint.value
|
||||
if (active != null && resolved.priority > active.priority && !sustainedLossDeclared) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"re-resolve picked lower-priority ${resolved.role}(p${resolved.priority}) over " +
|
||||
"active ${active.role}(p${active.priority}) not confirmed dead — keeping active",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
sustainedLossDeclared = false
|
||||
_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, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "network change: same winner is disconnected — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureNetworkCallbackRegistered() {
|
||||
val ctx = context ?: return
|
||||
if (networkCallback != null) return
|
||||
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
|
||||
// A network returned — cancel any pending loss reaction: the
|
||||
// drop was transient, so don't switch routes / rebuild the chat
|
||||
// client / cancel an in-flight turn. Re-resolve to pick the best
|
||||
// route (usually the same one); the rebuild only fires if the
|
||||
// URL actually moved.
|
||||
networkLossJob?.cancel()
|
||||
endpointResolver?.clearCache()
|
||||
scheduleNetworkReResolve("Network change — switching endpoint")
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
// Defer the reaction: a transient blip recovers within the grace
|
||||
// (onAvailable cancels this job). Reacting immediately — marking
|
||||
// the active endpoint unreachable + re-resolving to a fallback —
|
||||
// switches routes mid-blip, which rebuilds the chat client and
|
||||
// CANCELS the in-flight turn. The gateway client already handles
|
||||
// its own socket reconnect across the blip.
|
||||
Log.i(TAG, "network onLost — deferring fallback re-resolve by ${NETWORK_LOSS_GRACE_MS}ms")
|
||||
networkLossJob?.cancel()
|
||||
networkLossJob = scope.launch {
|
||||
delay(NETWORK_LOSS_GRACE_MS)
|
||||
Log.i(TAG, "network loss sustained past grace — marking active endpoint unreachable and resolving fallback")
|
||||
sustainedLossDeclared = true
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost (sustained)")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
networkCallback = callback
|
||||
Log.i(TAG, "registered NetworkCallback for ADR 24 re-resolution")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "registerNetworkCallback failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
networkLossJob?.cancel()
|
||||
networkLossJob = null
|
||||
val ctx = context ?: return
|
||||
val cb = networkCallback ?: return
|
||||
try {
|
||||
val cm = ctx.getSystemService(ConnectivityManager::class.java)
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "unregisterNetworkCallback failed: ${e.message}")
|
||||
} finally {
|
||||
networkCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeRelayUrl(url: String): String {
|
||||
@@ -165,14 +682,27 @@ 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 — 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
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
disconnect()
|
||||
unregisterNetworkCallback()
|
||||
supervisorJob.cancel()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
@@ -183,17 +713,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
|
||||
@@ -205,11 +756,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
|
||||
@@ -232,6 +796,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)
|
||||
@@ -246,17 +814,55 @@ 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?) {
|
||||
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=${response?.code})")
|
||||
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")
|
||||
}
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
webSocket = newSocket
|
||||
previousSocketToClose
|
||||
?.takeIf { it !== newSocket }
|
||||
?.let { staleSocket ->
|
||||
runCatching { staleSocket.close(1000, replaceReason) }
|
||||
staleSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
@@ -269,6 +875,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
|
||||
}
|
||||
@@ -276,8 +889,33 @@ class ConnectionManager(
|
||||
val url = serverUrl ?: return
|
||||
reconnectAttempt++
|
||||
|
||||
val backoffMs = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
// Server-issued 429 means we're IP-banned — keep retrying at our
|
||||
// normal exponential cadence and we'll re-fill the ban bucket on
|
||||
// every attempt, extending the ban indefinitely. Wait out the
|
||||
// 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)
|
||||
@@ -285,7 +923,19 @@ class ConnectionManager(
|
||||
// expires, auth state may have changed (e.g., user hit Revoke
|
||||
// during the retry window).
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
doConnect(url)
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
if (targetUrl != null && normalizeRelayUrl(targetUrl) != url) {
|
||||
Log.i(TAG, "scheduleReconnect: switching $url → ${normalizeRelayUrl(targetUrl)}")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else {
|
||||
doConnect(url)
|
||||
}
|
||||
} else if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate turned false during backoff — aborting retry")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
|
||||
@@ -19,32 +19,69 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
||||
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
|
||||
* per-device list, driven by ADR 24 "Multi-endpoint pairing + network-aware
|
||||
* switching" (2026-04-19).
|
||||
*
|
||||
* ### Semantics (locked by ADR 24)
|
||||
*
|
||||
* * **Strict priority.** `priority = 0` is highest. If a priority-0
|
||||
* candidate is reachable we use it; reachability never promotes a lower
|
||||
* 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. 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].
|
||||
*
|
||||
* The resolver is pure: no Context, no DataStore, no coroutine scope of its
|
||||
* own. Callers pass the pre-loaded [EndpointCandidate] list (from
|
||||
* `PairingPreferences.getDeviceEndpoints`), we run the probes, we return the
|
||||
* winner. That keeps the resolver testable from plain JUnit with a
|
||||
* MockWebServer stand-in.
|
||||
*
|
||||
* The resolver is **thread-safe** — the probe cache is a
|
||||
* [ConcurrentHashMap] so parallel probes from a race group don't tear it.
|
||||
*/
|
||||
class EndpointResolver(
|
||||
/**
|
||||
* OkHttp client used for probes. Callers pass the shared relay-side
|
||||
* client so TLS trust + DNS cache + cert-pinner state is consistent with
|
||||
* the eventual WSS connect. Internally the resolver applies its own
|
||||
* 2-second timeouts per call via [OkHttpClient.newBuilder], so the input
|
||||
* client's timeouts don't leak into probe behavior.
|
||||
*/
|
||||
private val httpClient: OkHttpClient,
|
||||
/**
|
||||
* Swappable "now" for tests. Production uses [System.currentTimeMillis];
|
||||
* tests feed a mutable clock to exercise the 30-second TTL.
|
||||
*/
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
|
||||
/**
|
||||
* Cached probe result. [expiresAt] is `clock()` + [CACHE_TTL_MS] when the
|
||||
* entry was written; after expiry the entry is re-probed.
|
||||
*/
|
||||
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
|
||||
|
||||
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"
|
||||
/**
|
||||
* Per-candidate HEAD probe timeout. ADR 24 speced 2s which was
|
||||
* tight — LTE hand-off and slow hotel Wi-Fi routinely blew past
|
||||
* 2s on the first packet and got candidates marked unreachable
|
||||
* spuriously. 4s preserves "fast-fail on real outage" while
|
||||
* surviving the flaky-network case.
|
||||
*/
|
||||
const val PROBE_TIMEOUT_MS = 4_000L
|
||||
/**
|
||||
* 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
|
||||
* changes anyway, so a 60s idle cache is functionally
|
||||
* equivalent. Manual probes (EndpointsCard → "Probe now")
|
||||
* bypass the cache.
|
||||
*/
|
||||
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)
|
||||
* but hostnames are lowercased — two roles pointing at the same
|
||||
* host:port share reachability state.
|
||||
*/
|
||||
internal fun cacheKey(candidate: EndpointCandidate): String =
|
||||
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the resolver against [candidates].
|
||||
*
|
||||
* 1. Group by `priority` ascending.
|
||||
* 2. For each priority group, race a HEAD /health probe against every
|
||||
* candidate in the group (2 s per candidate). First 2xx wins; ties
|
||||
* broken by whichever response lands first.
|
||||
* 3. If the entire group is unreachable, fall through to the next
|
||||
* priority group.
|
||||
* 4. If no candidate is reachable, return `null` — the caller falls back
|
||||
* to its legacy single-URL path.
|
||||
*
|
||||
* Candidates with an invalid api URL are skipped without affecting the
|
||||
* priority-group decision (a bad record shouldn't starve out the rest of
|
||||
* its tier). An empty [candidates] list returns null immediately without
|
||||
* touching the network.
|
||||
*/
|
||||
suspend fun resolve(candidates: List<EndpointCandidate>): EndpointCandidate? {
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
// Strict priority: sort ascending so priority-0 lands first. Grouping
|
||||
// preserves emitted order within a priority class (DNS SRV parity).
|
||||
val groups = candidates.groupBy { it.priority }.toSortedMap()
|
||||
|
||||
for ((priority, group) in groups) {
|
||||
Log.d(TAG, "probing priority=$priority group (size=${group.size})")
|
||||
val winner = raceGroup(group)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Race all candidates in [group] (same priority tier) in parallel. First
|
||||
* candidate that reports reachable — whether from cache or a fresh probe
|
||||
* — wins. Null when the entire group is unreachable.
|
||||
*
|
||||
* We **don't** await all probes before picking a winner: the spec calls
|
||||
* for "first 2xx wins" so latency matters. The losing probes' results
|
||||
* still land in the cache, though, so the next call benefits.
|
||||
*/
|
||||
private suspend fun raceGroup(group: List<EndpointCandidate>): EndpointCandidate? {
|
||||
if (group.isEmpty()) return null
|
||||
if (group.size == 1) {
|
||||
val only = group.first()
|
||||
return if (isReachable(only)) only else null
|
||||
}
|
||||
|
||||
// Fast-path: any cached-reachable candidate wins immediately without
|
||||
// touching the network.
|
||||
for (candidate in group) {
|
||||
val cached = probeCache[cacheKey(candidate)]
|
||||
if (cached != null && cached.expiresAt > clock() && cached.reachable) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return coroutineScope {
|
||||
val deferred = group.map { candidate ->
|
||||
async(Dispatchers.IO) {
|
||||
if (isReachable(candidate)) candidate else null
|
||||
}
|
||||
}
|
||||
// Collect results in arrival order: iterate through awaitAll +
|
||||
// pick the first non-null. awaitAll preserves input order, which
|
||||
// means a slow-but-reachable priority-0 candidate would block a
|
||||
// fast-and-reachable sibling. But HEAD /health against a healthy
|
||||
// API route replies in <100ms and the timeout caps stragglers at 2s,
|
||||
// so this is acceptable in practice. A true "first to arrive"
|
||||
// would need kotlinx.coroutines Channel plumbing that's not
|
||||
// worth the weight here.
|
||||
deferred.awaitAll().firstOrNull { it != null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-aware reachability check for a single candidate. Consults
|
||||
* [probeCache] first; on miss or expiry, runs a HEAD /health probe and
|
||||
* records the result.
|
||||
*/
|
||||
private suspend fun isReachable(candidate: EndpointCandidate): Boolean {
|
||||
val key = cacheKey(candidate)
|
||||
val now = clock()
|
||||
val cached = probeCache[key]
|
||||
if (cached != null && cached.expiresAt > now) {
|
||||
Log.d(TAG, "cache hit for $key reachable=${cached.reachable}")
|
||||
return cached.reachable
|
||||
}
|
||||
|
||||
val reachable = probe(candidate)
|
||||
val ttl = if (reachable) CACHE_TTL_MS else NEGATIVE_CACHE_TTL_MS
|
||||
probeCache[key] = CacheEntry(expiresAt = now + ttl, reachable = reachable)
|
||||
return reachable
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot HEAD /health probe against a candidate. 2-second timeout,
|
||||
* no retries — callers that need retry semantics can re-invoke after
|
||||
* the cache expires.
|
||||
*
|
||||
* Returns false on any failure (timeout, I/O, non-2xx, invalid URL).
|
||||
* 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()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.head()
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
fastClient.newCall(request).execute().use { resp ->
|
||||
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
|
||||
}
|
||||
} ?: 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 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() + NEGATIVE_CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
/** Test-only: snapshot the current cache for assertion purposes. */
|
||||
internal fun cacheSnapshot(): Map<String, Pair<Long, Boolean>> =
|
||||
probeCache.mapValues { (_, v) -> v.expiresAt to v.reachable }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
/**
|
||||
* Maps tui_gateway events for ONE chat turn onto [GatewayTurnCallbacks].
|
||||
*
|
||||
* Pure JVM (no Android deps) so the whole mapping table is unit-testable.
|
||||
* The caller (GatewayChatClient) filters events by live session id and
|
||||
* invokes [onEvent] in arrival order; this class owns per-turn state
|
||||
* (backfill guards, synthetic tool ids, turn-end detection).
|
||||
*
|
||||
* Forward-compat contract: unknown event types MUST be ignored — upstream
|
||||
* adds event types freely and old clients are expected to skip them. That is
|
||||
* why dispatch is a manual `when (type)` over [JsonObject] rather than a
|
||||
* sealed polymorphic hierarchy (which throws on unknown discriminators).
|
||||
*/
|
||||
class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
|
||||
|
||||
/** True once `message.complete` or `error` has been seen — the turn is over. */
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var syntheticToolCounter = 0
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
* server omits the id we synthesize one per start and match completes by
|
||||
* tool name, FIFO.
|
||||
*/
|
||||
private val openSyntheticIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
/**
|
||||
* `tool.generating` pre-registrations awaiting their `tool.start`, per
|
||||
* name FIFO — the start adopts the pre-minted id (unless the server
|
||||
* supplied a real `tool_id`) so the "preparing" placeholder and the
|
||||
* running card stay one ToolCall.
|
||||
*/
|
||||
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
when (type) {
|
||||
"reasoning.delta", "thinking.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
sawThinkingDelta = true
|
||||
callbacks.onThinkingDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Post-hoc reasoning (providers that don't stream it) — only
|
||||
// useful when nothing streamed live.
|
||||
"reasoning.available" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty() && !sawThinkingDelta) {
|
||||
sawThinkingDelta = true
|
||||
callbacks.onThinkingDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
sawTextDelta = true
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// Gateway has no server-side message id (placeholder UUID
|
||||
// stays). A second start inside one turn means a new
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
}
|
||||
|
||||
"tool.generating" -> {
|
||||
// `{name?}` with NO tool_id — the model is still streaming
|
||||
// this tool's arguments.
|
||||
val name = payload.string("name")
|
||||
if (name != null) {
|
||||
generatingIdsByName.getOrPut(name) { ArrayDeque() }
|
||||
.addLast("gateway-tool-$name-${syntheticToolCounter++}")
|
||||
}
|
||||
callbacks.onToolGenerating(name)
|
||||
}
|
||||
|
||||
"tool.start" -> {
|
||||
val name = payload.string("name") ?: "unknown"
|
||||
// A pending generating placeholder for this name is adopted
|
||||
// (consumed FIFO) whether or not the server sent a real id.
|
||||
val adopted = generatingIdsByName[name]?.removeFirstOrNull()
|
||||
val serverId = payload.string("tool_id")
|
||||
val toolId = when {
|
||||
serverId != null -> serverId
|
||||
adopted != null -> adopted.also {
|
||||
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(it)
|
||||
}
|
||||
else -> syntheticToolId(name)
|
||||
}
|
||||
callbacks.onToolCallStart(toolId, name)
|
||||
}
|
||||
|
||||
"tool.complete" -> {
|
||||
val name = payload.string("name") ?: "unknown"
|
||||
val toolId = payload.string("tool_id")
|
||||
?: openSyntheticIdsByName[name]?.removeFirstOrNull()
|
||||
?: return
|
||||
val error = payload.string("error")
|
||||
if (!error.isNullOrEmpty()) {
|
||||
callbacks.onToolCallFailed(toolId, error)
|
||||
} else {
|
||||
callbacks.onToolCallDone(toolId, payload.string("summary"))
|
||||
}
|
||||
}
|
||||
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
if (!sawThinkingDelta && !reasoning.isNullOrEmpty()) {
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
turnEnded = true
|
||||
callbacks.onError(payload.string("message") ?: "Gateway error")
|
||||
}
|
||||
|
||||
"subagent.start", "subagent.thinking", "subagent.tool",
|
||||
"subagent.progress", "subagent.complete",
|
||||
-> {
|
||||
val phase = when (type) {
|
||||
"subagent.start" -> GatewaySubagentEvent.Phase.START
|
||||
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
|
||||
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
|
||||
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
|
||||
else -> GatewaySubagentEvent.Phase.COMPLETE
|
||||
}
|
||||
callbacks.onSubagentEvent(
|
||||
GatewaySubagentEvent(
|
||||
phase = phase,
|
||||
taskIndex = payload.int("task_index") ?: 0,
|
||||
taskCount = payload.int("task_count") ?: 1,
|
||||
goal = payload.string("goal") ?: "",
|
||||
status = payload.string("status"),
|
||||
summary = payload.string("summary"),
|
||||
toolName = payload.string("tool_name"),
|
||||
// subagent.tool sets tool_preview AND mirrors it into
|
||||
// text; thinking/progress carry text only.
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"clarify.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"approval.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
timeoutSeconds = 0,
|
||||
),
|
||||
)
|
||||
|
||||
"sudo.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
// Payload carries request_id ONLY — no command to show.
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"secret.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
// Known-but-unrendered (notification.show, status.update, …) and
|
||||
// unknown types alike: ignore.
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun syntheticToolId(name: String): String {
|
||||
val id = "gateway-tool-$name-${syntheticToolCounter++}"
|
||||
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(id)
|
||||
return id
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* `message.complete.usage` uses tui_gateway's own key names
|
||||
* (`input`/`output`/`total`, with `prompt`/`completion` as the raw
|
||||
* counterparts — see upstream `_get_usage()`), NOT the
|
||||
* `input_tokens`/`prompt_tokens` schemes [UsageInfo] decodes from the
|
||||
* SSE paths. Translate explicitly. Values are session-cumulative.
|
||||
*
|
||||
* The context-window block (`context_used`/`context_max`/
|
||||
* `context_percent`) exists only when the server's context compressor
|
||||
* is active — absent fields stay null and the meter stays hidden.
|
||||
*/
|
||||
fun parseGatewayUsage(usage: JsonObject?): UsageInfo? {
|
||||
if (usage == null) return null
|
||||
val input = usage.int("input") ?: usage.int("prompt")
|
||||
val output = usage.int("output") ?: usage.int("completion")
|
||||
val total = usage.int("total")
|
||||
val contextUsed = usage.int("context_used")
|
||||
val contextMax = usage.int("context_max")
|
||||
val contextPercent = usage.int("context_percent")
|
||||
if (input == null && output == null && total == null &&
|
||||
contextUsed == null && contextMax == null && contextPercent == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return UsageInfo(
|
||||
inputTokens = input,
|
||||
outputTokens = output,
|
||||
totalTokens = total,
|
||||
contextUsed = contextUsed,
|
||||
contextMax = contextMax,
|
||||
contextPercent = contextPercent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream `_block()` timeouts per ask kind (server.py) — the blocked thread
|
||||
// resolves to "" when these elapse. Approval has none (session-scoped).
|
||||
private const val CLARIFY_TIMEOUT_SECONDS = 300
|
||||
private const val SUDO_TIMEOUT_SECONDS = 120
|
||||
private const val SECRET_TIMEOUT_SECONDS = 300
|
||||
|
||||
private fun JsonObject?.string(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun JsonObject?.int(key: String): Int? =
|
||||
(this?.get(key) as? JsonPrimitive)?.intOrNull
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? =
|
||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.setGatewayKeepAlive
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Opt-in foreground service that keeps the app process alive so the gateway
|
||||
* chat WebSocket (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||
* [GatewayChatClient]) survives Android's background-freeze / Doze — i.e.
|
||||
* "keep connected in the background".
|
||||
*
|
||||
* # Both flavors (Play declaration required)
|
||||
*
|
||||
* Declared in the MAIN manifest (unlike the device-control
|
||||
* [com.hermesandroid.relay.bridge.BridgeForegroundService], which is sideload
|
||||
* only), so googlePlay ships it too — the Home-Assistant-class persistent-
|
||||
* connection use case Google Play permits. The `specialUse` type is honest for
|
||||
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
||||
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
||||
* at submission. Off by default; only runs while the user enables the toggle.
|
||||
*
|
||||
* # It does NOT own the socket
|
||||
*
|
||||
* The service's only job is to hold the process in the foreground. The socket
|
||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||
* idle-close timer while the toggle is on. On task removal (user swipes the app
|
||||
* away) the ViewModel + socket die with the process, so the service stops
|
||||
* itself rather than leave a notification that lies about being connected.
|
||||
*
|
||||
* # Android 15 watchdog
|
||||
*
|
||||
* On target SDK 35 any intent to a service that declares a foregroundServiceType
|
||||
* must call `startForeground` within 5s — so [onStartCommand] always does that
|
||||
* first, before branching on the action. Shutdown goes through [stop]
|
||||
* (`stopService`) to bypass [onStartCommand] entirely.
|
||||
*/
|
||||
class GatewayKeepAliveService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "GatewayKeepAliveSvc"
|
||||
const val CHANNEL_ID = "gateway_keepalive"
|
||||
private const val CHANNEL_NAME = "Background connection"
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
// stopService() bypasses onStartCommand, so a "please shut down"
|
||||
// never trips the Android 15 foreground-start watchdog.
|
||||
context.applicationContext.stopService(
|
||||
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForegroundNotification()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
||||
// Flip the pref off so ConnectionViewModel's collector won't
|
||||
// restart us on the next foreground.
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
// The socket lives in the ViewModel, which dies when the task is
|
||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||
// are all declared in the main manifest (both flavors), so the type is
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
// check is finicky about correlating the runtime type arg with the manifest.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): android.app.Notification {
|
||||
val tapIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
|
||||
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes stays connected")
|
||||
.setContentText("Keeping your chat connection warm in the background.")
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Disconnect", stopPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description =
|
||||
"Persistent indicator while Hermes keeps your chat connection open in the background."
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
|
||||
/**
|
||||
* Shared types for the Gateway chat transport — upstream hermes-agent's
|
||||
* `tui_gateway` JSON-RPC-over-WebSocket surface at the dashboard's `/api/ws`.
|
||||
*
|
||||
* This is the same wire protocol the official hermes-desktop client and the
|
||||
* Ink TUI speak (reference shapes vendored in `desktop/src/gatewayTypes.ts`).
|
||||
* It is the only upstream surface that streams reasoning live
|
||||
* (`reasoning.delta` / `thinking.delta`) — the api_server SSE paths only
|
||||
* deliver reasoning after generation completes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Why the Gateway chat transport is or isn't usable right now. Mirrors
|
||||
* [com.hermesandroid.relay.viewmodel.StandardVoiceAvailability] — both ride
|
||||
* the dashboard surface and share the same probe — minus the audio-route
|
||||
* requirement (`/api/ws` ships with every embedded-chat dashboard build).
|
||||
*/
|
||||
enum class GatewayAvailability {
|
||||
/** No probe has completed yet (startup, connection switch). */
|
||||
Unknown,
|
||||
|
||||
/** Dashboard reachable and authenticated (or auth not required). */
|
||||
Ready,
|
||||
|
||||
/** Dashboard reachable and gated, but no signed-in session — Manage sign-in unlocks it. */
|
||||
SignInRequired,
|
||||
|
||||
/** Dashboard URL configured but `/api/status` did not answer. */
|
||||
Unreachable,
|
||||
|
||||
/**
|
||||
* Runtime sticky downgrade: the WS upgrade or ticket mint was rejected in
|
||||
* a way that says this server build has no usable `/api/ws` (404 on the
|
||||
* route, dashboard build predating the embedded chat). Cleared on
|
||||
* connection switch / fresh probe cycle.
|
||||
*/
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/** Lifecycle of the gateway WebSocket, exposed for diagnostics. */
|
||||
enum class GatewayConnectionState {
|
||||
Idle,
|
||||
MintingTicket,
|
||||
Connecting,
|
||||
AwaitingReady,
|
||||
Ready,
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||
* delegates here with its live state.
|
||||
*
|
||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
||||
*/
|
||||
fun resolveStreamingEndpointPreference(
|
||||
preference: String,
|
||||
gateway: GatewayAvailability,
|
||||
capabilities: ServerCapabilities,
|
||||
): String = when (preference) {
|
||||
"sessions", "completions", "runs", "gateway" -> preference
|
||||
else -> if (gateway == GatewayAvailability.Ready) {
|
||||
"gateway"
|
||||
} else {
|
||||
capabilities.preferredChatEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable handle for one in-flight chat turn, regardless of transport.
|
||||
* SSE turns wrap their [okhttp3.sse.EventSource]; gateway turns wrap a
|
||||
* `session.interrupt` dispatch. Replaces the raw `EventSource?` field in
|
||||
* ChatViewModel so both transports share the cancel/teardown sites.
|
||||
*/
|
||||
fun interface ActiveTurnHandle {
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
* server-side), or the turn is cancelled (`session.interrupt` force-releases
|
||||
* pending asks and force-denies approvals). Built by [GatewayEventMapper]
|
||||
* from the four `*.request` events; answered via the
|
||||
* [GatewayChatClient] `respond*` helpers.
|
||||
*/
|
||||
data class GatewayAsk(
|
||||
val kind: Kind,
|
||||
/**
|
||||
* Correlates the answer with the blocked server thread. Null ONLY for
|
||||
* [Kind.APPROVAL] — upstream approvals correlate per-session, not
|
||||
* per-request (`approval.respond` carries `session_id` instead).
|
||||
*/
|
||||
val requestId: String?,
|
||||
/** Question / command / prompt — whatever the ask wants the user to read. */
|
||||
val text: String,
|
||||
/** Clarify-only: server-suggested answers. */
|
||||
val choices: List<String>? = null,
|
||||
/** Secret-only: the env var the value will be stored under. */
|
||||
val envVar: String? = null,
|
||||
/**
|
||||
* Upstream blocking timeout (clarify/secret 300s, sudo 120s). 0 means no
|
||||
* countdown — approvals are session-scoped and never expire on their own.
|
||||
*/
|
||||
val timeoutSeconds: Int,
|
||||
) {
|
||||
enum class Kind { CLARIFY, APPROVAL, SUDO, SECRET }
|
||||
}
|
||||
|
||||
/**
|
||||
* One `subagent.*` lifecycle event, emitted on the PARENT session. Lifecycle
|
||||
* per task: START → (THINKING | TOOL | PROGRESS)* → COMPLETE. Field
|
||||
* availability varies by phase — [toolName]/[preview] ride TOOL,
|
||||
* [status]/[summary]/[durationSeconds] ride COMPLETE — and older emitters
|
||||
* omit everything beyond the three defaults-bearing fields.
|
||||
*/
|
||||
data class GatewaySubagentEvent(
|
||||
val phase: Phase,
|
||||
val taskIndex: Int,
|
||||
val taskCount: Int,
|
||||
val goal: String,
|
||||
val status: String? = null,
|
||||
val summary: String? = null,
|
||||
val toolName: String? = null,
|
||||
val preview: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
) {
|
||||
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider from the gateway `model.options` RPC — the curated, authenticated
|
||||
* provider/model list the upstream desktop + TUI model picker uses (NOT the
|
||||
* api_server `/v1/models`, which collapses to a single generic agent alias).
|
||||
*/
|
||||
data class GatewayModelProvider(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val models: List<String>,
|
||||
val isCurrent: Boolean,
|
||||
val warning: String?,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Result of the gateway `config.get {key:"reasoning"}` RPC. */
|
||||
data class GatewayReasoningSettings(
|
||||
val effort: String,
|
||||
val display: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Callback set for one gateway turn. Shapes intentionally mirror the SSE
|
||||
* callback lambdas in ChatViewModel.startStream() so the gateway branch can
|
||||
* forward to the exact same ChatHandler mutations.
|
||||
*
|
||||
* Every member is a REQUIRED constructor param on purpose: GatewayChatClient
|
||||
* `dispatchOn` must wrap each one onto the main thread, and a defaulted
|
||||
* member would compile unwrapped — running on the OkHttp reader thread.
|
||||
*/
|
||||
class GatewayTurnCallbacks(
|
||||
/** Stored (DB) session id — fired on session create/rotate so the drawer + persistence stay correct. */
|
||||
val onSessionId: (String) -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
val onToolCallFailed: (toolCallId: String, errorMsg: String?) -> Unit,
|
||||
val onTurnComplete: () -> Unit,
|
||||
val onComplete: () -> Unit,
|
||||
val onUsage: (UsageInfo?) -> Unit,
|
||||
val onError: (String) -> Unit,
|
||||
/**
|
||||
* `tool.generating` — the model is still writing this tool's arguments.
|
||||
* Carries the tool name when upstream sent one. The next `tool.start`
|
||||
* for the same name adopts the "preparing" placeholder (per name, FIFO).
|
||||
*/
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
* cancelled.
|
||||
*/
|
||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.network.models.CreateSessionRequest
|
||||
import com.hermesandroid.relay.network.models.HermesSseEvent
|
||||
@@ -15,15 +16,17 @@ import com.hermesandroid.relay.network.models.SessionResponse
|
||||
import com.hermesandroid.relay.network.models.SkillInfo
|
||||
import com.hermesandroid.relay.network.models.SkillListResponse
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
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
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -56,16 +59,16 @@ enum class ChatMode {
|
||||
* The Android client uses this to pick the best chat path automatically when
|
||||
* `streamingEndpoint = "auto"`. The bootstrap-injected vanilla-upstream case
|
||||
* is the interesting one: `sessionsApi=true` (we injected it) but
|
||||
* `sessionsChatStream=false` (we deliberately didn't inject the chat
|
||||
* handler — runs is better). The auto-resolver picks `runs` for chat in that
|
||||
* case while still using sessions endpoints for browse/rename/delete.
|
||||
* `sessionsChatStream=false` (the chat handler is absent). The auto-resolver
|
||||
* now picks OpenAI-compatible chat completions for that case because the route
|
||||
* 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) — standard upstream chat path. */
|
||||
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
|
||||
val runs: Boolean,
|
||||
/** `/v1/chat/completions` — OpenAI-compatible fallback. */
|
||||
val portable: Boolean,
|
||||
@@ -75,6 +78,7 @@ data class ServerCapabilities(
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
sessionsChatStream -> "sessions"
|
||||
portable -> "completions"
|
||||
runs -> "runs"
|
||||
else -> "sessions" // last-resort: try sessions, will surface a clear error
|
||||
}
|
||||
@@ -97,6 +101,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.
|
||||
*
|
||||
@@ -192,43 +252,109 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> = withContext(Dispatchers.IO) {
|
||||
suspend fun checkSessionsAuthDetailed(): HealthCheckResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=1").get().build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
parsed.items ?: parsed.sessions ?: emptyList()
|
||||
when {
|
||||
response.isSuccessful -> HealthCheckResult.Healthy
|
||||
response.code == 401 || response.code == 403 ->
|
||||
HealthCheckResult.Unhealthy("API reachable, but sessions auth failed - check your API key")
|
||||
response.code == 404 ->
|
||||
HealthCheckResult.Unhealthy("API reachable, but /api/sessions is unavailable")
|
||||
else ->
|
||||
HealthCheckResult.Unhealthy("Sessions check returned HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: javax.net.ssl.SSLException) {
|
||||
if (baseUrl.startsWith("https://", ignoreCase = true)) {
|
||||
HealthCheckResult.Unhealthy("TLS handshake failed - try http:// if your server doesn't use HTTPS")
|
||||
} else {
|
||||
HealthCheckResult.Unhealthy("SSL error: ${e.message}")
|
||||
}
|
||||
} catch (e: java.net.ConnectException) {
|
||||
HealthCheckResult.Unhealthy("Connection refused - check the URL and port")
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
HealthCheckResult.Unhealthy("Server not found - check the hostname")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
HealthCheckResult.Unhealthy("Connection timed out - is the server running?")
|
||||
} catch (e: IOException) {
|
||||
HealthCheckResult.Unhealthy("Connection failed: ${e.message ?: "I/O error"}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
emptyList()
|
||||
HealthCheckResult.Unhealthy("Unexpected error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSession(title: String? = null): SessionItem? = withContext(Dispatchers.IO) {
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessionsResult(limit: Int = 200): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(CreateSessionRequest(title = title))
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 200): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(
|
||||
title: String? = null,
|
||||
profileName: String? = null,
|
||||
model: String? = null,
|
||||
): Result<SessionItem> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(
|
||||
CreateSessionRequest(
|
||||
title = title,
|
||||
model = model,
|
||||
profile = AgentDisplay.profileRequestName(profileName),
|
||||
),
|
||||
)
|
||||
val request = authRequest("$baseUrl/api/sessions")
|
||||
.post(reqBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val body = response.body?.string() ?: return@withContext null
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "Create session"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Create session returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionResponse>(body)
|
||||
parsed.session ?: parsed.id?.let {
|
||||
val session = parsed.session ?: parsed.id?.let {
|
||||
SessionItem(id = it, title = parsed.title, model = parsed.model)
|
||||
}
|
||||
session?.let { Result.success(it) }
|
||||
?: Result.failure(IOException("Create session response missing session id"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create session: ${e.message}")
|
||||
null
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSession(
|
||||
title: String? = null,
|
||||
profileName: String? = null,
|
||||
model: String? = null,
|
||||
): SessionItem? =
|
||||
createSessionResult(title, profileName, model).getOrNull()
|
||||
|
||||
suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId")
|
||||
@@ -263,7 +389,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}")
|
||||
@@ -274,25 +400,45 @@ class HermesApiClient(
|
||||
// --- Skills ---
|
||||
|
||||
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
|
||||
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 (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills from $endpoint: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- Available models ---
|
||||
|
||||
/**
|
||||
* Available model ids from `GET /v1/models` (OpenAI-compatible:
|
||||
* `{"object":"list","data":[{"id":"…"}]}`). Backs the in-chat model
|
||||
* picker. Returns ids in server order; empty on any failure (the picker
|
||||
* then offers only "Server default").
|
||||
*/
|
||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/skills").get().build()
|
||||
val request = authRequest("$baseUrl/v1/models").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
|
||||
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()
|
||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
||||
data.mapNotNull {
|
||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills: ${e.message}")
|
||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -334,10 +480,22 @@ class HermesApiClient(
|
||||
key to ((value as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "")
|
||||
} ?: emptyMap()
|
||||
|
||||
// Default personality: config.display.personality
|
||||
// Default display identity. Upstream Hermes currently uses
|
||||
// config.display.personality for the active persona and often
|
||||
// mirrors the same identity through skin. Accept name-style
|
||||
// aliases too so older or profile-specific configs don't make
|
||||
// Android fall back to the literal "Hermes" label.
|
||||
val display = config["display"] as? JsonObject
|
||||
val defaultName = (display?.get("personality") as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.content ?: ""
|
||||
val defaultPersonality = display.stringField("personality")
|
||||
val defaultName = firstNonBlank(
|
||||
defaultPersonality.takeUnless { it.equals("default", ignoreCase = true) },
|
||||
display.stringField("agent_name"),
|
||||
display.stringField("assistant_name"),
|
||||
display.stringField("display_name"),
|
||||
display.stringField("name"),
|
||||
display.stringField("skin"),
|
||||
defaultPersonality,
|
||||
)
|
||||
|
||||
PersonalityConfig(
|
||||
names = prompts.keys.toList(),
|
||||
@@ -354,11 +512,41 @@ class HermesApiClient(
|
||||
|
||||
// --- Chat streaming via /api/sessions/{id}/chat/stream ---
|
||||
|
||||
/**
|
||||
* Stream chat via the sessions endpoint.
|
||||
*
|
||||
* @param modelOverride When non-null and non-blank, injects `"model":
|
||||
* "<value>"` at the top level of the session-chat request body,
|
||||
* asking the server to use that model for this turn. When null or
|
||||
* blank the `model` field is omitted entirely and the server falls
|
||||
* back to its session default. Used by the agent-profile picker so
|
||||
* an explicit user choice wins over implicit session/server defaults.
|
||||
*/
|
||||
fun sendChatStream(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
/**
|
||||
* Pre-built OpenAI-format synthetic messages to splice into the
|
||||
* payload alongside the live `message`. Produced by
|
||||
* [com.hermesandroid.relay.voice.VoiceIntentSyncBuilder.buildSyntheticMessages]
|
||||
* for the v0.4.1 voice-intent → server session sync feature.
|
||||
*
|
||||
* When non-empty, the request body grows a top-level `messages`
|
||||
* array containing the synthetic `assistant` (with `tool_calls`)
|
||||
* + `tool` (with `tool_call_id`) pairs. The server-side session
|
||||
* absorbs them into its conversation history so the LLM sees
|
||||
* prior phone-local voice actions in its session memory.
|
||||
*
|
||||
* Null / empty on every send that has no unsynced voice intents
|
||||
* to communicate, which is the common case after the first sync.
|
||||
* The Hermes API server treats unrecognised body fields
|
||||
* permissively (matches OpenAI Chat Completions semantics), so
|
||||
* this stays a safe additive change against any conformant
|
||||
* upstream.
|
||||
*/
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
@@ -369,24 +557,24 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildSessionChatStreamPayload(
|
||||
message = message,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/chat/stream")
|
||||
@@ -395,6 +583,8 @@ class HermesApiClient(
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("sessions")
|
||||
|
||||
// Notify caller of the session ID being used
|
||||
mainHandler.post { onSessionId(sessionId) }
|
||||
@@ -406,6 +596,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -416,6 +607,14 @@ class HermesApiClient(
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// First visible streamed token (reasoning OR text) — the
|
||||
// metric that exposes SSE's reasoning dead-air vs gateway.
|
||||
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
|
||||
!event.thinking.isNullOrEmpty()
|
||||
) {
|
||||
tracer.mark("ttft")
|
||||
}
|
||||
|
||||
// Check for usage data on ANY event before type resolution
|
||||
// (OpenAI-format chunks have no type/event field but may carry usage)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
@@ -560,6 +759,7 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -573,6 +773,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
@@ -582,13 +783,21 @@ class HermesApiClient(
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
// --- Run streaming via /v1/runs ---
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
|
||||
fun sendRunStream(
|
||||
/**
|
||||
* Stream chat through the OpenAI-compatible chat completions endpoint.
|
||||
*
|
||||
* This is the portable SSE fallback for servers that expose
|
||||
* `/v1/chat/completions` but where `/v1/runs` is an async JSON run-start
|
||||
* API rather than an EventSource-compatible stream.
|
||||
*/
|
||||
fun sendChatCompletionsStream(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
@@ -599,34 +808,36 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("model", model ?: "default")
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatCompletionsStream: modelOverride=$modelOverride (profile pick, was model=$model)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatCompletionsStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildChatCompletionsStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/runs")
|
||||
val request = authRequest("$baseUrl/v1/chat/completions")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("completions")
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
@@ -635,6 +846,195 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val event = json.decodeFromString<JsonObject>(data)
|
||||
openAiErrorMessage(event)?.let { msg ->
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
openAiUsage(event)?.let { usage ->
|
||||
mainHandler.post { onUsage(usage) }
|
||||
}
|
||||
|
||||
if (messageStarted.compareAndSet(false, true)) {
|
||||
openAiMessageId(event)?.let { messageId ->
|
||||
mainHandler.post { onMessageStarted(messageId) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiReasoningDelta(event)?.let { reasoning ->
|
||||
if (reasoning.isNotEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onThinkingDelta(reasoning) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiTextDelta(event)?.let { delta ->
|
||||
if (delta.isNotEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
|
||||
val finishReason = openAiFinishReason(event)
|
||||
if (!finishReason.isNullOrBlank() && completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unparseable chat completion SSE event ($type): ${e.message}\nRaw: $data")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
(event["choices"] as? JsonArray)
|
||||
?.firstOrNull()
|
||||
?.let { it as? JsonObject }
|
||||
|
||||
private fun openAiDelta(event: JsonObject): JsonObject? =
|
||||
openAiChoice(event)?.get("delta") as? JsonObject
|
||||
|
||||
private fun openAiTextDelta(event: JsonObject): String? =
|
||||
(openAiDelta(event)?.get("content") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiReasoningDelta(event: JsonObject): String? {
|
||||
val delta = openAiDelta(event) ?: return null
|
||||
return (delta["reasoning_content"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (delta["reasoning"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (delta["thinking"] as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
|
||||
private fun openAiFinishReason(event: JsonObject): String? =
|
||||
(openAiChoice(event)?.get("finish_reason") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiMessageId(event: JsonObject): String? =
|
||||
(event["id"] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiErrorMessage(event: JsonObject): String? {
|
||||
val error = event["error"] ?: return null
|
||||
return when (error) {
|
||||
is JsonPrimitive -> error.contentOrNull
|
||||
is JsonObject -> (error["message"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (error["error"] as? JsonPrimitive)?.contentOrNull
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAiUsage(event: JsonObject): UsageInfo? =
|
||||
(event["usage"] as? JsonObject)?.let { usage ->
|
||||
runCatching { json.decodeFromJsonElement<UsageInfo>(usage) }.getOrNull()
|
||||
}
|
||||
|
||||
// --- Run streaming via /v1/runs ---
|
||||
|
||||
/**
|
||||
* Stream a run via `/v1/runs`.
|
||||
*
|
||||
* @param model Caller's default model selection (nullable). When
|
||||
* [modelOverride] is null/blank this is used as the `model` field,
|
||||
* or `"default"` if both are null — preserving the pre-profile
|
||||
* behaviour exactly.
|
||||
* @param modelOverride When non-null and non-blank, wins over [model]
|
||||
* and is injected as the top-level `"model"` field in the run
|
||||
* request body. Used by the agent-profile picker so an explicit
|
||||
* user-selected profile model takes precedence over any implicit
|
||||
* caller default. When null/blank this parameter is ignored and
|
||||
* [model] drives selection as before.
|
||||
*/
|
||||
fun sendRunStream(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
/** See [sendChatStream]'s `voiceIntentMessages` doc — same semantics. */
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
onThinkingDelta: (String) -> Unit,
|
||||
onToolCallStart: (String, String) -> Unit,
|
||||
onToolCallDone: (String, String?) -> Unit,
|
||||
onToolCallFailed: (String, String?) -> Unit,
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendRunStream: modelOverride=$modelOverride (profile pick, was model=$model)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendRunStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildRunStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/runs")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("runs")
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -645,6 +1045,14 @@ class HermesApiClient(
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// First visible streamed token (reasoning OR text) — the
|
||||
// metric that exposes SSE's reasoning dead-air vs gateway.
|
||||
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
|
||||
!event.thinking.isNullOrEmpty()
|
||||
) {
|
||||
tracer.mark("ttft")
|
||||
}
|
||||
|
||||
// Check for usage data before type resolution (catches OpenAI-format chunks)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
mainHandler.post { onUsage(event.usage) }
|
||||
@@ -705,6 +1113,7 @@ class HermesApiClient(
|
||||
"reasoning.available" -> {
|
||||
val reasoningText = event.text
|
||||
if (!reasoningText.isNullOrEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onThinkingDelta(reasoningText) }
|
||||
}
|
||||
}
|
||||
@@ -791,6 +1200,7 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -804,6 +1214,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
@@ -830,14 +1241,16 @@ 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/runs` — runs endpoint presence (same 405-vs-404 logic).
|
||||
* 5. `HEAD /v1/models` — OpenAI-compat reachability.
|
||||
* 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
|
||||
* middleware (`security_headers_middleware`) that intercepts OPTIONS
|
||||
@@ -847,10 +1260,13 @@ class HermesApiClient(
|
||||
* for present, 404 for missing). Verified empirically against the
|
||||
* production hermes-agent gateway on 2026-04-12.
|
||||
*
|
||||
* **Success criterion:** any HTTP response code that isn't 404 means
|
||||
* the route is registered. We accept 200, 204, 401, 403, 405, 415,
|
||||
* etc. as positive — even quirky middleware responses count, because
|
||||
* the alternative (404) is the only signal that means "no such path."
|
||||
* **Route presence criterion:** for sessions and completions, any HTTP
|
||||
* response code that isn't 404 means the route is registered. We accept
|
||||
* 200, 204, 401, 403, 405, 415, etc. as positive because the alternative
|
||||
* (404) is the only signal that means "no such path." `/v1/runs` is
|
||||
* stricter: route presence alone is not enough because async runs can
|
||||
* return `202 application/json`; auto only uses it if event-stream support
|
||||
* is explicitly advertised.
|
||||
*
|
||||
* Network errors (connection refused, DNS failure, etc.) count as
|
||||
* "missing" since we can't differentiate from a server-down case.
|
||||
@@ -865,6 +1281,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
|
||||
@@ -876,10 +1306,31 @@ class HermesApiClient(
|
||||
false
|
||||
}
|
||||
|
||||
fun Response.advertisesEventStream(): Boolean {
|
||||
val contentType = header("Content-Type").orEmpty()
|
||||
val streamMode = header("X-Hermes-Stream-Mode").orEmpty()
|
||||
val runStreaming = header("X-Hermes-Run-Streaming").orEmpty()
|
||||
return contentType.contains("text/event-stream", ignoreCase = true) ||
|
||||
streamMode.equals("sse", ignoreCase = true) ||
|
||||
runStreaming.equals("sse", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun routeExplicitlySupportsEventStream(path: String): Boolean = try {
|
||||
val req = authRequest("$baseUrl$path")
|
||||
.head()
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
response.code != 404 && response.advertisesEventStream()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val sessionsApi = routeExists("/api/sessions?limit=1")
|
||||
val sessionsChatStream = routeExists("/api/sessions/probe/chat/stream")
|
||||
val runs = routeExists("/v1/runs")
|
||||
val portable = routeExists("/v1/models")
|
||||
val portable = routeExists("/v1/chat/completions")
|
||||
val runs = routeExplicitlySupportsEventStream("/v1/runs")
|
||||
|
||||
ServerCapabilities(
|
||||
sessionsApi = sessionsApi,
|
||||
@@ -911,4 +1362,20 @@ class HermesApiClient(
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val detail = response.message.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty()
|
||||
val message = when (response.code) {
|
||||
401, 403 -> "$operation unauthorized - check your API key"
|
||||
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
|
||||
else -> "$operation failed - HTTP ${response.code}$detail"
|
||||
}
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(name: String): String =
|
||||
((this?.get(name) as? JsonPrimitive)?.contentOrNull ?: "").trim()
|
||||
|
||||
private fun firstNonBlank(vararg values: String?): String =
|
||||
values.firstOrNull { !it.isNullOrBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
internal fun buildSessionChatStreamPayload(
|
||||
message: String,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
put("model", modelOverride)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildRunStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildChatCompletionsStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("stream", true)
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
putJsonArray("messages") {
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
addJsonObject {
|
||||
put("role", "system")
|
||||
put("content", systemMessage)
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
voiceIntentMessages.forEach { add(it) }
|
||||
}
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { it.isImage }) {
|
||||
put("content", buildJsonArray {
|
||||
addJsonObject {
|
||||
put("type", "text")
|
||||
put("text", message)
|
||||
}
|
||||
attachments.filter { it.isImage }.forEach { att ->
|
||||
addJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:${att.contentType};base64,${att.content}")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
put("content", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { !it.isImage }) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.filter { !it.isImage }.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||
*
|
||||
* Relays and Hermes gateways often bind profile API servers to loopback or
|
||||
* 0.0.0.0 on the host machine. Those addresses are correct for the relay
|
||||
* process but wrong on Android, where 127.0.0.1 means the phone. When the
|
||||
* active connection uses a reachable LAN/Tailscale host, replace loopback
|
||||
* profile hosts with that same host while preserving the profile port.
|
||||
*/
|
||||
object ProfileApiUrlResolver {
|
||||
fun normalize(url: String?): String? =
|
||||
url?.trim()?.takeIf { it.isNotBlank() }?.trimEnd('/')
|
||||
|
||||
fun resolveForConnection(profileApiUrl: String?, baseApiUrl: String?): String? {
|
||||
val profile = normalize(profileApiUrl) ?: return null
|
||||
val base = normalize(baseApiUrl) ?: return profile
|
||||
|
||||
val profileUri = runCatching { URI(profile) }.getOrNull() ?: return profile
|
||||
val profileHost = profileUri.host?.takeIf { it.isNotBlank() } ?: return profile
|
||||
if (!isLocalBindHost(profileHost)) return profile
|
||||
|
||||
val baseUri = runCatching { URI(base) }.getOrNull() ?: return profile
|
||||
val baseHost = baseUri.host?.takeIf { it.isNotBlank() } ?: return profile
|
||||
if (isLocalBindHost(baseHost)) return profile
|
||||
|
||||
val scheme = baseUri.scheme?.takeIf { it.isNotBlank() }
|
||||
?: profileUri.scheme?.takeIf { it.isNotBlank() }
|
||||
?: return profile
|
||||
val hostPart = if (baseHost.contains(":") && !baseHost.startsWith("[")) {
|
||||
"[$baseHost]"
|
||||
} else {
|
||||
baseHost
|
||||
}
|
||||
val portPart = profileUri.port.takeIf { it != -1 }?.let { ":$it" }.orEmpty()
|
||||
val pathPart = profileUri.rawPath?.takeIf { it.isNotBlank() && it != "/" }.orEmpty()
|
||||
val queryPart = profileUri.rawQuery?.let { "?$it" }.orEmpty()
|
||||
val fragmentPart = profileUri.rawFragment?.let { "#$it" }.orEmpty()
|
||||
|
||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
||||
}
|
||||
|
||||
private fun isLocalBindHost(host: String): Boolean {
|
||||
return when (host.lowercase().trim('[', ']')) {
|
||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.ProfileConfigResponse
|
||||
import com.hermesandroid.relay.data.ProfileMemoryResponse
|
||||
import com.hermesandroid.relay.data.ProfileSkillsResponse
|
||||
import com.hermesandroid.relay.data.ProfileSoulResponse
|
||||
import com.hermesandroid.relay.data.ProfileSoulUpdateResponse
|
||||
import com.hermesandroid.relay.data.ProfileMemoryUpdateResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* HTTP client for the read-only **Profile Inspector** endpoints added in
|
||||
* the v0.7.0 relay:
|
||||
*
|
||||
* - `GET /api/profiles/{name}/config` (already live)
|
||||
* - `GET /api/profiles/{name}/skills` (already live)
|
||||
* - `GET /api/profiles/{name}/soul` (Python worker)
|
||||
* - `GET /api/profiles/{name}/memory` (Python worker)
|
||||
*
|
||||
* Mirrors the constructor shape of [RelayHttpClient] — same OkHttpClient,
|
||||
* the same `wss://` → `https://` URL-flipping trick, and the same lazy
|
||||
* session-token provider so a paired bearer token from EncryptedSharedPrefs
|
||||
* is only read when actually needed.
|
||||
*
|
||||
* All IO hops over [Dispatchers.IO] — we had a `NetworkOnMainThreadException`
|
||||
* during v0.6.0 development when an earlier client went straight from a
|
||||
* Composable effect to OkHttp without a dispatcher hop, so every path here
|
||||
* starts with `withContext(Dispatchers.IO) { ... }`.
|
||||
*
|
||||
* Profile names are URL-encoded before being spliced into the path so
|
||||
* names containing spaces or non-ASCII characters don't produce malformed
|
||||
* URLs.
|
||||
*/
|
||||
class RelayProfileInspectorClient(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val relayUrlProvider: () -> String?,
|
||||
private val sessionTokenProvider: suspend () -> String?,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayProfileInspector"
|
||||
|
||||
/**
|
||||
* Server-side upload ceiling for SOUL.md and memory entries
|
||||
* (1 MiB). Kept as a named constant so the matching wire limit
|
||||
* in the Python worker can be moved in lockstep. We use it to
|
||||
* translate a generic 413 into a friendlier error message.
|
||||
*/
|
||||
private const val SOUL_MAX_BYTES: Long = 1024L * 1024L
|
||||
|
||||
/**
|
||||
* JSON media type used for all PUT requests. Hoisted to a
|
||||
* constant so we don't re-parse it on every write.
|
||||
*/
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// Lenient + ignore unknown keys so if the Python worker adds a
|
||||
// field later we don't fail to deserialize the whole payload.
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/config`. */
|
||||
suspend fun fetchConfig(profileName: String): Result<ProfileConfigResponse> =
|
||||
get(profileName, "config", ProfileConfigResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/skills`. */
|
||||
suspend fun fetchSkills(profileName: String): Result<ProfileSkillsResponse> =
|
||||
get(profileName, "skills", ProfileSkillsResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/soul`. */
|
||||
suspend fun fetchSoul(profileName: String): Result<ProfileSoulResponse> =
|
||||
get(profileName, "soul", ProfileSoulResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/memory`. */
|
||||
suspend fun fetchMemory(profileName: String): Result<ProfileMemoryResponse> =
|
||||
get(profileName, "memory", ProfileMemoryResponse.serializer())
|
||||
|
||||
/**
|
||||
* `PUT /api/profiles/{name}/soul` with body `{"content": "..."}`.
|
||||
*
|
||||
* Server-side contract:
|
||||
* - 200: content written; returns [ProfileSoulUpdateResponse]
|
||||
* - 404: profile not found
|
||||
* - 413: body exceeds 1 MiB size limit
|
||||
* - 401/403: unauthorized — re-pair
|
||||
*
|
||||
* The [content] may be any UTF-8 string including empty (to blank the
|
||||
* file) — the server does not enforce non-emptiness. A `null` content
|
||||
* would be a protocol violation; we send empty-string for an empty
|
||||
* SOUL.
|
||||
*/
|
||||
suspend fun updateSoul(
|
||||
profileName: String,
|
||||
content: String,
|
||||
): Result<ProfileSoulUpdateResponse> = withContext(Dispatchers.IO) {
|
||||
val bodyPayload = buildJsonObject { put("content", content) }
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
bodyPayload,
|
||||
)
|
||||
put(
|
||||
profileName = profileName,
|
||||
segment = "soul",
|
||||
body = bodyJson,
|
||||
deserializer = ProfileSoulUpdateResponse.serializer(),
|
||||
maxBytesHint = SOUL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /api/profiles/{name}/memory/{filename}` with body
|
||||
* `{"content": "..."}`.
|
||||
*
|
||||
* Filenames are validated both locally (by the caller — we expect
|
||||
* `.md` suffix, no traversal) and server-side. A bad filename yields
|
||||
* a 400 from the relay.
|
||||
*
|
||||
* Used for both creating a new memory entry (the relay writes the
|
||||
* file if missing) and updating an existing entry.
|
||||
*/
|
||||
suspend fun updateMemoryEntry(
|
||||
profileName: String,
|
||||
filename: String,
|
||||
content: String,
|
||||
): Result<ProfileMemoryUpdateResponse> = withContext(Dispatchers.IO) {
|
||||
val bodyPayload = buildJsonObject { put("content", content) }
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
bodyPayload,
|
||||
)
|
||||
val encodedFilename = URLEncoder.encode(filename, "UTF-8").replace("+", "%20")
|
||||
put(
|
||||
profileName = profileName,
|
||||
segment = "memory/$encodedFilename",
|
||||
body = bodyJson,
|
||||
deserializer = ProfileMemoryUpdateResponse.serializer(),
|
||||
// Memory entries share the same 1MiB ceiling server-side —
|
||||
// no documented difference, so we report the same soft hint
|
||||
// in the error message.
|
||||
maxBytesHint = SOUL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared PUT-body-and-parse path for the two update endpoints
|
||||
* (SOUL + memory). Centralized so we reuse the same URL builder,
|
||||
* session-token plumbing, and error mapping. Kept separate from
|
||||
* [get] rather than generalized over the HTTP method because the
|
||||
* body/response semantics (413, 400 invalid filename) are specific
|
||||
* to the update side.
|
||||
*/
|
||||
private suspend fun <T> put(
|
||||
profileName: String,
|
||||
segment: String,
|
||||
body: String,
|
||||
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
|
||||
maxBytesHint: Long = SOUL_MAX_BYTES,
|
||||
): Result<T> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val encodedName = URLEncoder.encode(profileName, "UTF-8").replace("+", "%20")
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/profiles/$encodedName/$segment".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.put(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
400 -> {
|
||||
// Relay emits 400 for invalid filename or
|
||||
// malformed JSON. Surface the response body
|
||||
// when present so the user sees the specific
|
||||
// validation error.
|
||||
val bodyText = response.body?.string().orEmpty()
|
||||
if (bodyText.isNotBlank()) {
|
||||
"Invalid request: ${extractErrorDetail(bodyText)}"
|
||||
} else {
|
||||
"Invalid request"
|
||||
}
|
||||
}
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
404 -> "Profile '$profileName' not found on relay"
|
||||
413 -> "Content too large — max ${maxBytesHint / 1024} KiB"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
|
||||
val bodyText = response.body?.string().orEmpty()
|
||||
if (bodyText.isBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = try {
|
||||
json.decodeFromString(deserializer, bodyText)
|
||||
} catch (e: SerializationException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Malformed response from relay: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "$segment write failed for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$segment write unexpected error for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /api/skills/toggle` with body `{"name": "...", "enabled": true/false}`.
|
||||
*
|
||||
* Current relay stubs this out — returns 501 with
|
||||
* `{"error": "skill_toggle_not_implemented", "detail": "..."}`. The
|
||||
* UI uses the distinctive 501 to show a "not supported on this
|
||||
* server" snackbar and ghost out the toggle. When the real
|
||||
* implementation lands server-side, this method needs no change.
|
||||
*/
|
||||
suspend fun updateSkillToggle(
|
||||
skillName: String,
|
||||
enabled: Boolean,
|
||||
): Result<SkillToggleResult> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/skills/toggle".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("name", skillName)
|
||||
put("enabled", enabled)
|
||||
}
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.put(bodyJson.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
in 200..299 -> Result.success(SkillToggleResult.Ok)
|
||||
501 -> Result.success(SkillToggleResult.NotImplemented)
|
||||
401, 403 -> Result.failure(
|
||||
IOException("Unauthorized — re-pair with the relay")
|
||||
)
|
||||
else -> Result.failure(
|
||||
IOException("Relay returned HTTP ${response.code}")
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "skill toggle failed for $skillName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability probe for the skill-toggle endpoint — HEAD / OPTIONS
|
||||
* would be the ideal choice but we need to know specifically if the
|
||||
* server responds 501 vs 200, which is only visible on PUT. We
|
||||
* send a no-op PUT with `enabled = true` against a placeholder
|
||||
* skill name that the server treats as a probe ping — relays that
|
||||
* implement the endpoint accept it; stubbed relays return 501.
|
||||
*
|
||||
* In practice we don't want this probe to have side effects, so we
|
||||
* use the HTTP OPTIONS verb instead and treat a 501 response as
|
||||
* "not implemented" and any 2xx as "supported". The relay serves
|
||||
* OPTIONS via aiohttp's CORS handling by default.
|
||||
*/
|
||||
suspend fun probeSkillToggleSupported(): Boolean = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) return@withContext false
|
||||
val sessionToken = sessionTokenProvider() ?: return@withContext false
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/skills/toggle".toHttpUrl()
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
// OPTIONS probe. Relays that don't mount the handler return 404
|
||||
// or the default 405 method-not-allowed; stubbed-implementation
|
||||
// relays return 501 from the PUT handler but allow OPTIONS.
|
||||
// A 2xx/3xx OPTIONS does NOT confirm PUT works (the server
|
||||
// might still 501 on the real call), so a successful OPTIONS
|
||||
// here means "worth trying". A 501 response on OPTIONS (rare)
|
||||
// is definitive "not supported".
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.method("OPTIONS", null)
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
501 -> false
|
||||
404, 405 -> false
|
||||
// 401/403 — we don't know; default to true (don't
|
||||
// ghost the toggle over an auth problem, let the
|
||||
// PUT fail and snackbar through the normal path).
|
||||
401, 403 -> true
|
||||
else -> response.isSuccessful
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// Network / offline — assume supported; PUT will surface
|
||||
// the real failure.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a skill-toggle PUT. Kept as a small sealed class so
|
||||
* the caller can distinguish "server accepted it" from "server
|
||||
* answered 501 — not implemented yet" without inventing magic
|
||||
* error strings.
|
||||
*/
|
||||
sealed class SkillToggleResult {
|
||||
data object Ok : SkillToggleResult()
|
||||
data object NotImplemented : SkillToggleResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort pull of a `detail` or `error` string out of a relay
|
||||
* 400 body. Falls back to the first 120 chars of the payload when
|
||||
* the response isn't JSON-shaped.
|
||||
*/
|
||||
private fun extractErrorDetail(body: String): String {
|
||||
return try {
|
||||
val obj = json.parseToJsonElement(body) as? kotlinx.serialization.json.JsonObject
|
||||
?: return body.take(120)
|
||||
val detail = (obj["detail"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
val error = (obj["error"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
detail ?: error ?: body.take(120)
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared GET-and-parse path for all four endpoints. Centralizing here
|
||||
* keeps the error-mapping consistent (404 profile-not-found, 401
|
||||
* re-pair, 5xx server error, etc.) without four near-identical copies.
|
||||
*/
|
||||
private suspend fun <T> get(
|
||||
profileName: String,
|
||||
segment: String,
|
||||
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
|
||||
): Result<T> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
// Percent-encode the profile name for splicing into the path —
|
||||
// profile names are typically ASCII identifiers but nothing
|
||||
// structurally forbids spaces or non-ASCII.
|
||||
// URLEncoder encodes spaces as `+` which is wrong for paths; swap
|
||||
// back to `%20` after encoding.
|
||||
val encodedName = URLEncoder.encode(profileName, "UTF-8").replace("+", "%20")
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/profiles/$encodedName/$segment".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
404 -> "Profile '$profileName' not found on relay"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = try {
|
||||
json.decodeFromString(deserializer, body)
|
||||
} catch (e: SerializationException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Malformed response from relay: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "$segment fetch failed for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$segment fetch unexpected error for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Derives the conventional Hermes-Relay WSS/WS URL from a Hermes API URL.
|
||||
*
|
||||
* Hermes API and Relay are separate processes, but normal installs expose
|
||||
* them on the same host with API on 8642 and Relay on 8767. Keeping this
|
||||
* logic centralized lets setup flows treat the Relay URL as "Auto" by
|
||||
* default while still allowing a manual override for custom reverse proxies.
|
||||
*/
|
||||
object RelayUrlDeriver {
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
fun deriveFromApiUrl(apiUrl: String, relayPort: Int = DEFAULT_RELAY_PORT): String? {
|
||||
val trimmed = apiUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val relayScheme = 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 "$relayScheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedRelayUrl(relayUrl: String, apiUrl: String): Boolean {
|
||||
val trimmed = relayUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return true
|
||||
if (isDefaultLocalRelayUrl(trimmed)) return true
|
||||
|
||||
val derived = deriveFromApiUrl(apiUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isDefaultLocalRelayUrl(relayUrl: String): Boolean {
|
||||
val uri = runCatching { URI(relayUrl.trim()) }.getOrNull() ?: return false
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme != "ws" && scheme != "wss") return false
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
val port = if (uri.port == -1) {
|
||||
when (scheme) {
|
||||
"ws" -> 80
|
||||
"wss" -> 443
|
||||
else -> -1
|
||||
}
|
||||
} else {
|
||||
uri.port
|
||||
}
|
||||
return port == DEFAULT_RELAY_PORT &&
|
||||
(host == "localhost" || host == "127.0.0.1" || host == "::1")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user