Compare commits
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# @subframe-version 0.15.1-beta
|
||||
# @subframe-managed
|
||||
#
|
||||
# SubFrame pre-commit hook
|
||||
# Auto-updates STRUCTURE.json when JS files in src/ are committed.
|
||||
#
|
||||
|
||||
# Check if any JS files in src/ are staged
|
||||
STAGED_JS=$(git diff --cached --name-only --diff-filter=ACMRD | grep -E '^src/.*\.(js|ts|tsx|jsx)$' || true)
|
||||
|
||||
# Also check for deleted source files
|
||||
DELETED_JS=$(git diff --cached --name-only --diff-filter=D | grep -E '^src/.*\.(js|ts|tsx|jsx)$' || true)
|
||||
|
||||
if [ -z "$STAGED_JS" ] && [ -z "$DELETED_JS" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only update if .subframe/STRUCTURE.json exists (SubFrame project)
|
||||
if [ ! -f ".subframe/STRUCTURE.json" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only update if the updater script exists
|
||||
UPDATER=".githooks/update-structure.js"
|
||||
if [ ! -f "$UPDATER" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[SubFrame] Source files changed, updating .subframe/STRUCTURE.json..."
|
||||
|
||||
# Run the updater with staged/deleted file lists as env vars
|
||||
STAGED_FILES="$STAGED_JS" DELETED_FILES="$DELETED_JS" node "$UPDATER"
|
||||
|
||||
# Stage the updated .subframe/STRUCTURE.json
|
||||
git add .subframe/STRUCTURE.json
|
||||
|
||||
echo "[SubFrame] .subframe/STRUCTURE.json updated and staged."
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# @subframe-version 0.15.1-beta
|
||||
# @subframe-managed
|
||||
# SubFrame pre-push hook
|
||||
# Triggers pipeline workflows configured with "on: { push: true }"
|
||||
# To bypass: git push --no-verify
|
||||
|
||||
SUBFRAME_DIR=".subframe"
|
||||
PIPELINES_DIR="$SUBFRAME_DIR/pipelines"
|
||||
TRIGGER_FILE="$PIPELINES_DIR/.pre-push-trigger"
|
||||
|
||||
# Only trigger if SubFrame is initialized
|
||||
if [ ! -d "$SUBFRAME_DIR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Write trigger file for SubFrame to detect
|
||||
mkdir -p "$PIPELINES_DIR"
|
||||
echo "{\"trigger\": \"pre-push\", \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$TRIGGER_FILE"
|
||||
|
||||
# Don't block the push — pipeline runs async via SubFrame UI
|
||||
exit 0
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
// @subframe-version 0.15.1-beta
|
||||
// @subframe-managed
|
||||
/**
|
||||
* SubFrame STRUCTURE.json Updater
|
||||
* Called by .githooks/pre-commit when source files in src/ are staged.
|
||||
* Reads STAGED_FILES and DELETED_FILES from environment variables.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const STRUCTURE_FILE = path.join(ROOT, '.subframe', 'STRUCTURE.json');
|
||||
const SRC_DIR = path.join(ROOT, 'src');
|
||||
|
||||
// Strip any JS/TS extension for module key
|
||||
function stripExt(p) {
|
||||
return p.replace(/\.(js|ts|tsx|jsx)$/, '');
|
||||
}
|
||||
|
||||
// Load existing STRUCTURE.json
|
||||
let structure;
|
||||
try {
|
||||
structure = JSON.parse(fs.readFileSync(STRUCTURE_FILE, 'utf-8'));
|
||||
} catch (e) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!structure.modules) {
|
||||
structure.modules = {};
|
||||
}
|
||||
|
||||
const files = (process.env.STAGED_FILES || '').split('\n').filter(Boolean);
|
||||
const deleted = (process.env.DELETED_FILES || '').split('\n').filter(Boolean);
|
||||
|
||||
// Remove deleted modules
|
||||
for (const file of deleted) {
|
||||
const key = stripExt(path.relative(SRC_DIR, path.join(ROOT, file)))
|
||||
.replace(/\\/g, '/');
|
||||
if (structure.modules[key]) {
|
||||
delete structure.modules[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse each staged file
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(ROOT, file);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(fullPath, 'utf-8');
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = stripExt(path.relative(SRC_DIR, fullPath))
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
// Extract description from top JSDoc comment
|
||||
let description = '';
|
||||
const docMatch = content.match(/^\/\*\*\s*\n\s*\*\s*([^\n]+)/);
|
||||
if (docMatch) description = docMatch[1].trim();
|
||||
|
||||
// Extract exports — CJS (module.exports) and ESM (export { ... }, export function)
|
||||
const xports = [];
|
||||
const cjsMatch = content.match(/module\.exports\s*=\s*\{([^}]+)\}/);
|
||||
if (cjsMatch) {
|
||||
cjsMatch[1].split(',').forEach(function(s) {
|
||||
const name = s.trim().split(':')[0].trim();
|
||||
if (name && !name.startsWith('//')) xports.push(name);
|
||||
});
|
||||
}
|
||||
// ESM named exports: export { foo, bar } or export function foo
|
||||
const esmExportRe = /^export\s+(?:function|const|let|class|async\s+function)\s+(\w+)/gm;
|
||||
let em;
|
||||
while ((em = esmExportRe.exec(content)) !== null) {
|
||||
if (!xports.includes(em[1])) xports.push(em[1]);
|
||||
}
|
||||
|
||||
// Extract dependencies — CJS require() and ESM import
|
||||
const deps = [];
|
||||
const reqRe = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
let m;
|
||||
while ((m = reqRe.exec(content)) !== null) {
|
||||
const dep = m[1];
|
||||
if (dep.startsWith('./') || dep.startsWith('../')) {
|
||||
deps.push(stripExt(dep.replace(/^\.+\//, '')));
|
||||
} else {
|
||||
deps.push(dep);
|
||||
}
|
||||
}
|
||||
const importRe = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
const dep = m[1];
|
||||
if (dep.startsWith('./') || dep.startsWith('../')) {
|
||||
deps.push(stripExt(dep.replace(/^\.+\//, '')));
|
||||
} else {
|
||||
deps.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract function names with line numbers
|
||||
const functions = {};
|
||||
const fnRe = /^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/gm;
|
||||
while ((m = fnRe.exec(content)) !== null) {
|
||||
const lineNum = content.substring(0, m.index).split('\n').length;
|
||||
functions[m[1]] = { line: lineNum };
|
||||
}
|
||||
|
||||
const existing = structure.modules[key] || {};
|
||||
structure.modules[key] = {
|
||||
file: file,
|
||||
description: description || existing.description || '',
|
||||
exports: xports,
|
||||
depends: deps.filter(function(v, i, a) { return a.indexOf(v) === i; }),
|
||||
functions: Object.keys(functions).length > 0 ? functions : (existing.functions || {})
|
||||
};
|
||||
}
|
||||
|
||||
// Update timestamp and save
|
||||
structure.lastUpdated = new Date().toISOString().split('T')[0];
|
||||
if (structure._frame_metadata) {
|
||||
structure._frame_metadata.lastUpdated = structure.lastUpdated;
|
||||
}
|
||||
fs.writeFileSync(STRUCTURE_FILE, JSON.stringify(structure, null, 2) + '\n');
|
||||
@@ -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,88 @@
|
||||
name: CI desktop CLI
|
||||
|
||||
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@v4
|
||||
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@v4
|
||||
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
|
||||
@@ -0,0 +1,97 @@
|
||||
# Hermes-Relay — Python Relay CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# Python-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check -> unit-tests
|
||||
|
||||
name: CI — Relay
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/ci-relay.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/ci-relay.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-relay-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax 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
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — unittest discover
|
||||
#
|
||||
# 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: Unit tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
# 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"
|
||||
|
||||
# conftest.py imports `pytest` and `responses` at collection time.
|
||||
# `unittest discover` walks conftest.py like any other module, so both
|
||||
# must be importable even though none of the tests themselves use
|
||||
# pytest fixtures (they're all stdlib unittest.TestCase).
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run unit tests
|
||||
run: python -m unittest discover plugin/tests
|
||||
@@ -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-relay.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,42 @@ 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
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
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 Review
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Release desktop CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
name: Build cross-platform 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@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: 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
|
||||
|
||||
# NOTE: delegate to the package.json scripts so there's a single source
|
||||
# of truth for Bun compile flags. Previously these steps inlined their
|
||||
# own flag list, which silently diverged from `npm run build:bin:*` and
|
||||
# made the "drop --bytecode" fix ineffective on desktop-v0.3.0-alpha.2.
|
||||
- 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
|
||||
|
||||
# Smoke-test the Linux binary (runner platform) before publishing.
|
||||
# Catches the two failure modes we've burned alpha tags on:
|
||||
# - startup segfault (exit != 0, no output)
|
||||
# - silent exit 0 with zero output (main() never invoked)
|
||||
# We can only smoke the Linux target without a cross-platform matrix;
|
||||
# Windows/macOS smoke would need their own runners — tracked as an
|
||||
# alpha.5+ hardening item.
|
||||
- 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: Generate SHA256SUMS
|
||||
working-directory: desktop/dist/bin
|
||||
run: |
|
||||
sha256sum hermes-relay-* > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay Desktop CLI — ${{ github.ref_name }}
|
||||
|
||||
**Experimental phase.** Binaries are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. See the install scripts for the `Unblock-File` / `xattr -dr` escape hatches.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux:**
|
||||
```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=${{ github.ref_name }}`.
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
See [Desktop CLI docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
|
||||
files: |
|
||||
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
|
||||
desktop/dist/bin/SHA256SUMS.txt
|
||||
@@ -47,6 +47,7 @@ jobs:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -62,8 +63,15 @@ 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
|
||||
|
||||
+15
-3
@@ -24,6 +24,9 @@ Thumbs.db
|
||||
local.properties
|
||||
/build/
|
||||
/app/build/
|
||||
/relay-core/build/
|
||||
/relay-ui/build/
|
||||
/quest/build/
|
||||
/app/release/
|
||||
*.apk
|
||||
*.aab
|
||||
@@ -48,14 +51,19 @@ certs/
|
||||
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 +71,7 @@ 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
|
||||
|
||||
@@ -1,54 +1,386 @@
|
||||
# hermes-relay
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
# hermes-android - SubFrame Project
|
||||
|
||||
## Overview
|
||||
This extension adds Android device control to hermes-agent via the `android` toolset.
|
||||
It communicates with the Hermes-Relay app running on an Android device over WSS.
|
||||
This project is managed with **SubFrame**. AI assistants should follow the rules below to keep documentation up to date.
|
||||
|
||||
## Setup
|
||||
> **Note:** This file is named `AGENTS.md` to be AI-tool agnostic. CLAUDE.md and GEMINI.md contain a reference to this file.
|
||||
|
||||
### Quick start (canonical installer)
|
||||
---
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
## 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
|
||||
```
|
||||
|
||||
This clones the repo to `~/.hermes/hermes-relay/`, `pip install -e`s the package into the hermes-agent venv, registers the clone's `skills/` directory in `~/.hermes/config.yaml` under `skills.external_dirs`, symlinks the plugin into `~/.hermes/plugins/hermes-relay`, and installs a `hermes-pair` shell shim into `~/.local/bin/`. Restart hermes-agent and everything is live. Updates are a `git pull` inside `~/.hermes/hermes-relay/`.
|
||||
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.
|
||||
|
||||
See [docs/relay-server.md](docs/relay-server.md) for Docker, systemd, TLS, and configuration options.
|
||||
### Sub-Task Recognition Rules
|
||||
|
||||
### Full setup
|
||||
1. Install the Hermes-Relay APK on the Android device (build via `scripts/dev.bat build`)
|
||||
2. Grant the app Accessibility Service permission in Settings > Accessibility
|
||||
3. Grant SYSTEM_ALERT_WINDOW permission
|
||||
4. Run the installer (above) and restart hermes-agent
|
||||
5. Start the relay server if you need terminal/bridge: `python -m plugin.relay --no-ssl`
|
||||
6. Pair the phone: type `/hermes-relay-pair` in any Hermes chat surface, or run `hermes-pair` from a shell. **Note:** the top-level `hermes pair` sub-command is not currently exposed — upstream argparser doesn't forward to plugin CLI dicts. Use the slash command or the dashed shim.
|
||||
**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
|
||||
|
||||
## Tool usage patterns
|
||||
**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)
|
||||
|
||||
### Read before act
|
||||
ALWAYS call android_read_screen before tapping. Never guess coordinates.
|
||||
### Sub-Task Creation Flow
|
||||
|
||||
### Prefer text over coordinates
|
||||
Use android_tap_text("Continue") over android_tap(x=540, y=1200).
|
||||
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
|
||||
|
||||
### Wait after navigation
|
||||
After opening an app or tapping a button that triggers loading,
|
||||
always call android_wait with expected text before next action.
|
||||
### Sub-Task Content Rules
|
||||
|
||||
### Confirmation pattern for destructive actions
|
||||
Before confirming a purchase, ride, or send action — always report
|
||||
to the user what you're about to do and wait for approval.
|
||||
Example: "I'm about to confirm an Uber ride to [destination] for [price].
|
||||
Reply 'yes' to confirm."
|
||||
**title:** Short, action-oriented
|
||||
- OK: "Add tasks button to terminal toolbar"
|
||||
- Bad: "Tasks"
|
||||
|
||||
## Common package names
|
||||
- com.ubercab — Uber
|
||||
- com.bolt.client — Bolt
|
||||
- com.whatsapp — WhatsApp
|
||||
- com.spotify.music — Spotify
|
||||
- com.google.android.apps.maps — Google Maps
|
||||
- com.android.chrome — Chrome
|
||||
- com.google.android.gm — Gmail
|
||||
- com.instagram.android — Instagram
|
||||
- com.twitter.android — X/Twitter
|
||||
**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 -->
|
||||
|
||||
+734
-10
@@ -6,16 +6,740 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.** Bailey: *"This isn't cohesive — 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.** Bailey 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.** Bailey on alpha.9 ran `hermes-relay update --check`, expected to see alpha.10, got "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 added to `release-desktop.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 release-desktop.yml, 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 relay server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
|
||||
- **`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 Bailey's 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-relay.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **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 + relay 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 Bailey's 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)
|
||||
|
||||
v0.4 roughly triples the bridge surface. The agent can now do everything
|
||||
v0.3 could do, plus long-press, drag, full clipboard access, system-wide
|
||||
media control, raw Android Intents, an accessibility-event stream, app
|
||||
launching, app listing, multi-window screen reads, filtered node search,
|
||||
screen-hash change detection, stable per-node IDs, three-tier
|
||||
`tap_text` fallback, a batched macro dispatcher, wake-lock-guarded
|
||||
gesture dispatch, and a per-app skill playbook for common flows. The
|
||||
sideload track additionally ships direct SMS, contact lookup, one-tap
|
||||
dialing, and location awareness.
|
||||
|
||||
**Read surface**
|
||||
|
||||
- **`/long_press`** (A1) — long-press gesture by coordinate or node ID,
|
||||
covering context menus, text selection, and widget rearranging
|
||||
- **`/drag`** (A2) — drag gesture from point A → point B over a
|
||||
configurable duration
|
||||
- **`/find_nodes`** (A3) — filtered accessibility-tree search (text,
|
||||
clickable flag, class name, resource ID) instead of returning the
|
||||
whole tree
|
||||
- **`/describe_node`** (A4) — full property bag for a stable node ID,
|
||||
plus `nodeId` wiring for `/tap` and `/scroll` so the agent can hand
|
||||
IDs forward without re-resolving coordinates
|
||||
- **`/screen_hash`** + **`/diff_screen`** (A5) — cheap SHA-256 screen
|
||||
fingerprint and diff tools for "wait until this screen changes"
|
||||
loops without re-downloading the full accessibility tree
|
||||
- **`/events`** + **`/events/stream`** (B1) — accessibility-event
|
||||
stream. In-memory `EventStore` buffers recent `AccessibilityEvent`
|
||||
objects so the agent can poll for UI events or wait for a specific
|
||||
trigger instead of hammering `/screen`. Toggle capture on/off via
|
||||
`/events/stream`.
|
||||
- **Multi-window `ScreenReader`** (P1) — `/screen` now walks every
|
||||
accessibility window (system UI, popups, notification shade) instead
|
||||
of only the active app's window
|
||||
|
||||
**Act surface**
|
||||
|
||||
- **`/clipboard`** (A6) — bidirectional system clipboard read/write
|
||||
- **`/media`** (A7) — system-wide playback control (play, pause, next,
|
||||
previous, volume) via `MediaSessionManager`
|
||||
- **`/send_intent`** + **`/broadcast`** (B4) — raw Android Intent /
|
||||
broadcast escape hatch for apps that expose deep-link actions
|
||||
- **Three-tier `tap_text` cascade** (A9) — exact match → clickable
|
||||
ancestor walk → substring fallback, fixes apps that wrap labels in
|
||||
non-clickable parents
|
||||
- **`android_macro`** (A10) — batched workflow dispatcher runs a
|
||||
sequence of bridge commands as one call with configurable pacing,
|
||||
no round-trip per step
|
||||
- **`WakeLockManager`** (A8) — `PARTIAL_WAKE_LOCK` scope wrapper around
|
||||
gesture dispatch so commands still land on dim or idle screens.
|
||||
Scoped try/finally semantics, never a stale hold.
|
||||
|
||||
**Tier C — sideload-only phone utilities**
|
||||
|
||||
- **`/location`** (C1) — GPS last-known-location read for "where am
|
||||
I?" and location-scoped commands
|
||||
- **`/search_contacts`** (C2) — contact lookup by name → phone number
|
||||
for voice intents like "text Mom"
|
||||
- **`/call`** (C3) — direct call via `ACTION_CALL`, with an
|
||||
`ACTION_DIAL` fallback where the flavor can't hold `CALL_PHONE`
|
||||
- **`/send_sms`** (C4) — direct SMS send via `SmsManager` with
|
||||
send-result confirmation (no dialer bounce)
|
||||
|
||||
**Docs + skills**
|
||||
|
||||
- **`skills/android/SKILL.md`** (A11) — per-app playbook with reusable
|
||||
flows for common apps, agent-discoverable via the Hermes skills
|
||||
system
|
||||
- **`docs/spec.md` + `docs/decisions.md`** — v0.4 bridge surface
|
||||
documented, Phase 3 status marked shipped, 15-item spec rot pass
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing Kotlin handlers for `/open_app`, `/get_apps`, `/apps`,
|
||||
and `/setup`** — latent v0.3.0 regression. The Python relay side had
|
||||
the routes and the plugin tools were calling them, but the in-app
|
||||
`BridgeCommandHandler` had never wired the corresponding `when (path)
|
||||
->` branches. Commands silent-dropped until this release.
|
||||
- **Android 11+ package visibility for `/get_apps`** — added a
|
||||
`<queries>` element to the main manifest so
|
||||
`PackageManager.queryIntentActivities(ACTION_MAIN + CATEGORY_LAUNCHER)`
|
||||
returns the full launchable app list. Without this, the tool returned
|
||||
an empty list on modern Android targets.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`user-docs` expansion** — added the full 27-route bridge HTTP
|
||||
inventory to `reference/relay-server.md`, rewrote
|
||||
`architecture/security.md` around the five-stage safety gate + Tier 5
|
||||
rails, added ADR-9 through ADR-13 (bridge safety gate, wake-scope,
|
||||
event stream, MediaProjection FGS type, build flavors), and retired
|
||||
all remaining "Bridge :8766" references now that the bridge is
|
||||
unified on `:8767`.
|
||||
|
||||
## [0.3.0] - 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
**Phase 3 — Bridge channel (the big one)** — the agent can now read the
|
||||
phone's screen, tap, type, swipe, and take screenshots. Gated behind a
|
||||
**Bridge channel (the big one)** — the agent can now read the phone's
|
||||
screen, tap, type, swipe, and take screenshots. Gated behind a
|
||||
deliberate in-app master toggle, per-channel session grants, Android
|
||||
Accessibility Service permission, MediaProjection consent, and Tier 5
|
||||
safety rails (blocklist, destructive-verb confirmation modal, idle
|
||||
auto-disable timer, optional persistent status overlay).
|
||||
Accessibility Service permission, MediaProjection consent, and the
|
||||
safety rails system (blocklist, destructive-verb confirmation modal,
|
||||
idle auto-disable timer, optional persistent status overlay).
|
||||
|
||||
- **`HermesAccessibilityService`** — Android `AccessibilityService`
|
||||
subclass that reads the active window's UI tree, dispatches taps /
|
||||
@@ -24,7 +748,7 @@ auto-disable timer, optional persistent status overlay).
|
||||
- **`ScreenCapture.kt`** — `MediaProjection` → `VirtualDisplay` →
|
||||
`ImageReader` → PNG bytes, uploaded to the relay via `/media/upload`
|
||||
- **`BridgeCommandHandler`** — routes inbound `bridge.command` envelopes
|
||||
to the executor, with the three-stage Tier 5 safety check
|
||||
to the executor, with the three-stage safety check
|
||||
(blocklist → destructive-verb confirmation → auto-disable reschedule)
|
||||
- **`BridgeSafetyManager`** — process-wide safety enforcement singleton
|
||||
with DataStore-backed blocklist (30 default banking/payments/2FA
|
||||
@@ -41,7 +765,7 @@ auto-disable timer, optional persistent status overlay).
|
||||
- **Bridge Safety settings screen** — blocklist editor (searchable
|
||||
package picker), destructive verb editor, auto-disable timer slider,
|
||||
status overlay toggle, confirmation timeout slider
|
||||
- **14 `android_*` plugin tools** routed through the new unified bridge
|
||||
- **18 `android_*` plugin tools** routed through the new unified bridge (14 baseline + send_sms, call, search_contacts, return_to_hermes added in v0.4.0)
|
||||
channel (migrated from the legacy standalone `android_relay.py`)
|
||||
- **`android_navigate`** — vision-driven close-the-loop navigation tool
|
||||
(sideload track only)
|
||||
@@ -60,7 +784,7 @@ auto-disable timer, optional persistent status overlay).
|
||||
info, Test Voice)
|
||||
- New relay endpoints — `POST /voice/transcribe`, `POST /voice/synthesize`,
|
||||
`GET /voice/config`
|
||||
- **Voice-to-bridge intent routing** (sideload track only, Tier 3) —
|
||||
- **Voice-to-bridge intent routing** (sideload track only) —
|
||||
spoken commands like "text Mom saying on my way" route to the bridge
|
||||
channel instead of the chat channel, with destructive-verb
|
||||
confirmation flow
|
||||
@@ -108,7 +832,7 @@ badges showed stale Connected/Disconnected for 30s after foregrounding.
|
||||
|
||||
**Two build flavors** — `googlePlay` (Play Store track, conservative
|
||||
Accessibility use case) and `sideload` (`.sideload` applicationId
|
||||
suffix, full Phase 3 tiers including voice-to-bridge and
|
||||
suffix, full feature set including voice-to-bridge intents and
|
||||
`android_navigate`). `sideload` shows as "Hermes Dev" in the launcher
|
||||
for side-by-side disambiguation.
|
||||
|
||||
@@ -210,7 +934,7 @@ picker.
|
||||
- Voice messages appear as normal chat messages in session history
|
||||
- **Reactive layered-sine waveform** — three overlapping waves with amplitude-driven phase velocity (`withFrameNanos` ticker), pill-shaped edge merge (geometric `sin(πt)` taper + `BlendMode.DstIn` gradient mask), color-keyed to voice state
|
||||
- **Enter/exit voice chimes** — synthesized 200ms PCM sweeps via AudioTrack (440→660 Hz enter, mirror exit)
|
||||
- **Terminal (Phase 2)** — tmux-backed persistent shells with tabs, scrollback search, and session info sheet
|
||||
- **Terminal (preview)** — tmux-backed persistent shells with tabs, scrollback search, and session info sheet
|
||||
- **Session TTL picker** — choose 1d / 7d / 30d / 90d / 1y / Never at pair time
|
||||
- **Per-channel grants** — control terminal/bridge access per paired device
|
||||
- **Android Keystore token storage** — StrongBox-preferred hardware-backed encrypted storage with TEE fallback
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
A native Android app for Hermes agent. Chat connects directly to the Hermes API Server. Bridge and terminal channels use a relay server over WSS. The app is Kotlin + Jetpack Compose. The server relay is Python + aiohttp.
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
|
||||
|
||||
**Current state:** v0.1.0 (Google Play). Phase 0 + Phase 1 complete with direct API chat, session management, markdown rendering, messaging-style chat header (avatar + agent name + model subtitle), personality picker with agent name on bubbles, searchable command palette (29 gateway commands + dynamic personalities + server skills), QR code pairing, ConnectionStatusBadge (animated pulse ring), in-app analytics (Stats for Nerds with reset, peak times, tokens/msg), animated splash screen, tool display configuration, client-side message queuing (send while streaming), file attachments (images, documents, any file type via base64), **inbound media pipeline** (agent-produced screenshots/files served via relay-hosted `MediaRegistry` + opaque tokens + `FileProvider`-backed cache, Discord-style rendering for image/video/audio/pdf/text/generic via `InboundAttachmentCard`, configurable max size / auto-fetch-on-cellular / cache cap / clear-cache), **pairing + security architecture** (user-chosen session TTL at pair time via `SessionTtlPickerDialog` with 1d/7d/30d/90d/1y/never options, per-channel grants on one session token via `Session.grants`, Android Keystore session storage with TEE fallback via `SessionTokenStore`, TOFU cert pinning via `CertPinStore`, transport security badge + first-time insecure ack dialog, Tailscale detection, full Paired Devices screen with list + revoke via `GET/DELETE /sessions`, HMAC-SHA256 QR signing via `plugin/relay/qr_sign.py`), configurable limits (attachment size, message length), feature gating with Developer Options, ASCII morphing sphere animation (empty chat state + ambient mode + behind-messages background), and animation settings in Settings. The relay server handles bridge (Phase 3) and terminal (Phase 2) via WSS. Auth uses optional Bearer token for API, pairing code for relay. Relay/pairing settings are hidden in production behind Developer Options (tap version 7x to unlock).
|
||||
**Current state:** v0.7.x (unreleased on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode, bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -31,78 +31,86 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `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):**
|
||||
|
||||
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
|
||||
|
||||
1. **Codename-11 fork** (`feat/api-server-enhancements` branch, currently merged into `axiom`) — adds them natively in `gateway/platforms/api_server.py`. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556).
|
||||
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — installed alongside the plugin via `install.sh`, runs at Python interpreter startup (via a `.pth` file in the venv's site-packages), monkey-patches `aiohttp.web.Application` so that when `APIServerAdapter.connect()` builds its app, our extra routes are added to the same router. **Vanilla upstream + plugin = these endpoints work too.** The bootstrap deliberately does NOT inject `/api/sessions/{id}/chat/stream` — chat goes through standard `/v1/runs` instead, which has live tool events and avoids touching `_create_agent` / `run_conversation` internals.
|
||||
3. **Upstream-merged** (post PR #8556) — same paths, native upstream support. The bootstrap feature-detects on route paths and no-ops in this case.
|
||||
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.
|
||||
|
||||
| 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 — use `/v1/runs`) |
|
||||
| `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 /api/skills`, `/{name}` | Skill discovery (list + detail) | Fork OR bootstrap OR upstream-merged |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"` (the default for new installs), `ConnectionViewModel.resolveStreamingEndpoint()` reads the capability snapshot and picks `sessions` (when the chat-stream handler is present) or `runs` (otherwise). Users can still force `sessions` or `runs` manually in Settings → Chat → Streaming endpoint.
|
||||
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.
|
||||
|
||||
**Dashboard web server (separate surface — loopback-only):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** (`/v1/runs`) — Best for tool display. Emits `tool.started`/`tool.completed` as real SSE events → rendered as ToolProgressCards in real-time.
|
||||
2. **Sessions API** (`/api/sessions/{id}/chat/stream`) — Does NOT emit structured tool events during streaming. Tool calls are stored server-side as `tool_calls` JSON on each message. On stream complete, `ChatViewModel.onCompleteCb` reloads message history via `getMessages()` → `loadMessageHistory()` to get proper message boundaries + tool call cards. This is the "session_end reload" pattern.
|
||||
3. **Annotation parser** (`ChatHandler.parseAnnotationLine` + `finalizeAnnotations`) — Fallback for servers that inject inline markdown annotations (`` `💻 terminal` ``). Parses during streaming + reconciliation pass on stream end. If your Hermes version uses a different format, check `adb logcat -s HermesApiClient` for raw SSE events and update the regex.
|
||||
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).
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether the bootstrap injects it (`hermes_relay_bootstrap/_handlers.py`) or if it requires the fork.
|
||||
- When building features that interface with hermes-agent, reference the upstream source — not just our spec docs. Our spec may be aspirational or based on a specific server version.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver in `ConnectionViewModel.resolveStreamingEndpoint()` (or equivalent) degrades gracefully.
|
||||
- **Bootstrap maintenance:** When upstream PR #8556 merges and reaches a released hermes-agent version, the entire `hermes_relay_bootstrap/` package and its `.pth` file in `install.sh` can be deleted. The bootstrap is no-op-compatible with both fork and upstream-merged installs (feature detection by route path), so leaving it in place during the rollout window is harmless.
|
||||
- **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.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
hermes-android/ ← Android Studio opens this root
|
||||
├── app/ ← Android app module (Compose)
|
||||
│ ├── src/main/kotlin/com/hermesandroid/relay/
|
||||
│ │ ├── ui/ # Screens, components, theme
|
||||
│ │ ├── network/ # ConnectionManager, ChannelMultiplexer, handlers
|
||||
│ │ ├── auth/ # AuthManager (pairing + tokens)
|
||||
│ │ ├── viewmodel/ # ChatViewModel, ConnectionViewModel
|
||||
│ │ └── data/ # ChatMessage, ToolCall models
|
||||
│ └── build.gradle.kts
|
||||
├── build.gradle.kts ← Root Gradle (AGP, Kotlin plugins)
|
||||
├── settings.gradle.kts
|
||||
├── gradle/ ← Wrapper (8.13) + version catalog
|
||||
├── scripts/ ← Dev scripts (build, install, run, test, relay)
|
||||
├── plugin/ ← Hermes agent plugin (14 android_* tools + relay + pair CLI)
|
||||
│ ├── android_tool.py # 14 android_* tool handlers; points BRIDGE_URL at the unified relay (localhost:8767) as of Phase 3 Wave 1
|
||||
│ ├── pair.py # QR pairing implementation — `python -m plugin.pair`, wrapped by `hermes-pair` shim and the `hermes-relay-pair` skill
|
||||
│ ├── cli.py # Registers plugin CLI sub-commands (note: top-level `hermes pair` blocked by upstream argparser gap — use slash command or shell shim)
|
||||
│ ├── relay/ # Canonical WSS relay (consolidated from relay_server/)
|
||||
│ │ ├── server.py # aiohttp WSS + HTTP routes (incl. /pairing/register)
|
||||
│ │ ├── auth.py # PairingManager, SessionManager, RateLimiter
|
||||
│ │ ├── config.py # RelayConfig, PAIRING_ALPHABET (full A-Z / 0-9)
|
||||
│ │ └── channels/ # chat.py, terminal.py (PTY), bridge.py (stub)
|
||||
│ ├── tools/ # Standalone toolset
|
||||
│ ├── skills/ # Agent skills
|
||||
│ └── tests/
|
||||
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
|
||||
│ ├── __main__.py # `python -m relay_server` still works
|
||||
│ ├── Dockerfile
|
||||
│ ├── hermes-relay.service # Systemd unit
|
||||
│ └── requirements.txt
|
||||
├── skills/ ← Installable Hermes skills (categorized per canonical layout)
|
||||
│ └── devops/
|
||||
│ └── hermes-relay-pair/ # /hermes-relay-pair slash command — QR pairing skill (category: devops)
|
||||
├── docs/ ← spec, decisions, security
|
||||
└── .github/workflows/ ← CI + release
|
||||
hermes-android/
|
||||
├── app/src/main/kotlin/com/hermesandroid/relay/
|
||||
│ ├── ui/ # Screens, components, theme
|
||||
│ ├── network/ # ConnectionManager, ChannelMultiplexer, handlers
|
||||
│ ├── auth/ # AuthManager (pairing + tokens)
|
||||
│ ├── viewmodel/ # ChatViewModel, ConnectionViewModel
|
||||
│ ├── data/ # ChatMessage, ToolCall models, FeatureFlags
|
||||
│ ├── audio/ # VoiceRecorder, VoicePlayer, VoiceSfxPlayer
|
||||
│ ├── voice/ # VoiceViewModel, VoiceBridgeIntentHandler
|
||||
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
|
||||
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
|
||||
│ └── notifications/ # HermesNotificationCompanion
|
||||
├── 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
|
||||
│ └── 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
|
||||
├── 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
|
||||
```
|
||||
|
||||
## Project Conventions
|
||||
@@ -111,37 +119,42 @@ hermes-android/ ← Android Studio opens this root
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, .gitignore
|
||||
- **docs/** — spec, decisions, security, and any other long-form documentation
|
||||
- **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.
|
||||
|
||||
### Code Style — Android (Kotlin)
|
||||
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
|
||||
- **kotlinx.serialization** — not Gson. Type-safe, faster.
|
||||
- **OkHttp** for WebSocket + SSE — `okhttp` for WSS relay, `okhttp-sse` for API streaming
|
||||
- **Single-activity** — Compose Navigation for all routing
|
||||
- **Package:** `com.hermesandroid.relay`
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 36**
|
||||
- **Kotlin 2.0+**, JVM toolchain 17
|
||||
- **Namespace (Kotlin source tree):** `com.hermesandroid.relay` — stable, drives on-disk layout + class FQCNs
|
||||
- **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** for the WSS relay — async, matches existing Hermes relay patterns
|
||||
- **aiohttp** — async, matches existing Hermes relay patterns
|
||||
- **Type hints everywhere** — Python 3.11+ syntax
|
||||
- **asyncio** for concurrency — no threading
|
||||
- **Structured logging** — use `logging` module, not print()
|
||||
- **asyncio** — no threading; **structured logging** — use `logging`, not print()
|
||||
|
||||
### Git
|
||||
- **Commit messages:** Conventional Commits — `type: description` — e.g. `feat: add chat channel UI`, `fix: WSS reconnect race condition`, `docs: rename Bridge pairing card`, `chore: sync version sources`
|
||||
- **Feature branches are the house style** as of 2026-04-13. Straight-to-main is reserved for single-file typos and tiny one-liners — everything else goes on a branch and merges via PR.
|
||||
- `feature/<name>` — new feature (>1-2 commits)
|
||||
- `fix/<name>` — focused bug fix
|
||||
- `docs/<name>` — docs-only changes larger than a typo
|
||||
- `chore/<name>` — cleanup / refactor / tooling
|
||||
- **Merge style:** always `git merge --no-ff <branch>` (or the GitHub "Create a merge commit" option in the PR UI). Squash is NOT the house style — no-ff preserves the per-commit trail which is critical for agent-team branches where you want to see "which agent did what" in `git log --graph`.
|
||||
- **Version bumps happen on `main` at release-prep time, NEVER on feature branches.** Three version sources (`gradle/libs.versions.toml`, `pyproject.toml`, `plugin/relay/__init__.py::__version__`) must stay in lockstep. Use `bash scripts/bump-version.sh <new-version>` to bump them atomically — the script validates SemVer, bumps `appVersionCode` monotonically, rewrites all three files, runs a sanity grep, and prints next steps. Don't edit by hand. See `RELEASE.md` for the full recipe.
|
||||
- **Branch protection** is enabled on `main` (as of 0.3.0): direct pushes blocked except for the `release: vX.Y.Z` pattern, PR must pass CI before merge, force push + branch deletion blocked. No required reviews (solo-dev overhead). Release-prep commits are the one carve-out — they need atomic bump+tag, so direct push is allowed.
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
|
||||
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Use `bash scripts/bump-version.sh <new-version>` to bump all three sources atomically (`gradle/libs.versions.toml`, `pyproject.toml`, `plugin/relay/__init__.py`). The `release: vX.Y.Z` commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the tag is cut from `main`.
|
||||
- **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:** pytest for relay tests
|
||||
- **CI runs on every push** — build must pass before merge
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-relay.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -149,138 +162,130 @@ hermes-android/ ← Android Studio opens this root
|
||||
|------|-----|
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `app/src/main/kotlin/.../ui/RelayApp.kt` | Main scaffold — bottom nav, navigation |
|
||||
| `app/src/main/kotlin/.../network/HermesApiClient.kt` | Direct HTTP/SSE client — `sendChatStream()` for sessions endpoint, `sendRunStream()` for runs endpoint, `detectChatMode()` for capability probing |
|
||||
| `app/src/main/kotlin/.../network/ConnectionManager.kt` | WSS connection with auto-reconnect (relay) |
|
||||
| `app/src/main/kotlin/.../network/ChannelMultiplexer.kt` | Envelope routing by channel (relay) |
|
||||
| `app/src/main/kotlin/.../network/ConnectivityObserver.kt` | Reactive network connectivity listener |
|
||||
| `app/src/main/kotlin/.../network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser (inline markdown → ToolCall) |
|
||||
| `app/src/main/kotlin/.../network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `app/src/main/kotlin/.../data/FeatureFlags.kt` | Feature gating — compile-time defaults (DEV_MODE) + runtime DataStore overrides |
|
||||
| `app/src/main/kotlin/.../data/AppAnalytics.kt` | In-app analytics singleton (TTFT, tokens, health, stream rates) |
|
||||
| `app/src/main/kotlin/.../ui/screens/ChatScreen.kt` | Chat UI — streaming messages, slash commands, tool cards |
|
||||
| `app/src/main/kotlin/.../ui/screens/SettingsScreen.kt` | Settings — unified Connection section (Pair-with-your-server card with Scan QR primary action + unified API/Relay/Session status summary; collapsible Manual configuration card for API URL / key / Relay URL / Insecure toggle + Save & Test; collapsible Bridge-pairing-code card gated by `relayEnabled` feature flag, Phase 3 only), plus chat, appearance, analytics, about |
|
||||
| `app/src/main/kotlin/.../ui/components/StatsForNerds.kt` | Canvas bar charts for analytics display |
|
||||
| `app/src/main/kotlin/.../ui/components/CompactToolCall.kt` | Inline compact tool call display |
|
||||
| `app/src/main/kotlin/.../ui/components/PersonalityPicker.kt` | Personality picker dropdown (from config.agent.personalities) |
|
||||
| `app/src/main/kotlin/.../ui/components/CommandPalette.kt` | Searchable command palette (bottom sheet) + inline autocomplete |
|
||||
| `app/src/main/kotlin/.../ui/components/ConnectionStatusBadge.kt` | Animated pulse ring status indicator (connected/connecting/disconnected) |
|
||||
| `app/src/main/kotlin/.../ui/components/MorphingSphere.kt` | ASCII morphing sphere — 3D lit character sphere with color pulse, used in empty chat state, ambient mode, and behind-messages background |
|
||||
| `app/src/main/kotlin/.../ui/components/MessageBubble.kt` | Message bubbles with markdown, tokens, tool cards |
|
||||
| `app/src/main/kotlin/.../ui/components/ToolProgressCard.kt` | Expandable tool execution card (auto-expand/collapse) |
|
||||
| `app/src/main/kotlin/.../viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `app/src/main/kotlin/.../viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay) |
|
||||
| `app/src/main/res/drawable/splash_icon.xml` | Splash screen icon (0.9x scale) |
|
||||
| `app/src/main/res/drawable/splash_icon_animated.xml` | Animated splash (scale + overshoot + fade) |
|
||||
| `plugin/relay/server.py` | Canonical relay server — WSS + HTTP routes (health, /pairing, /pairing/register) |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch package for vanilla upstream hermes-agent. Loaded via `hermes_relay_bootstrap.pth` in the venv site-packages (dropped by `install.sh` step 2). `__init__.py` installs a `sys.meta_path` finder; `_patch.py` swaps `aiohttp.web.Application` for a subclass that intercepts `app["api_server_adapter"] = self` and triggers `_handlers.register_routes()`; `_handlers.py` ports ~14 management handlers (sessions CRUD, memory, skills, config, available-models) from the fork. Feature-detects on route paths so it no-ops on fork or upstream-merged installs. Removable in one PR once PR #8556 lands. |
|
||||
| `hermes_relay_bootstrap.pth` | Single-line `.pth` file at repo root: `import hermes_relay_bootstrap`. `install.sh` copies this into the hermes-agent venv's `site-packages/` so Python's `site` module loads the bootstrap at every interpreter startup. NOT installed automatically by `pip install -e` — setuptools' data-files doesn't ship to site-packages reliably for editable installs. |
|
||||
| `uninstall.sh` | Canonical uninstaller — reverses every `install.sh` step in opposite order: stops + disables systemd unit, removes shim, removes skills external_dirs entry from config.yaml (preserves other entries), removes plugin symlink + legacy stales, removes bootstrap `.pth` from site-packages, `pip uninstall hermes-relay`, removes the clone (unless `--keep-clone`). Idempotent. Never touches `~/.hermes/.env`, `state.db`, or the hermes-agent venv core. Flags: `--dry-run`, `--keep-clone`, `--remove-secret` (the last preserves QR signing identity by default). |
|
||||
| `app/src/main/kotlin/.../network/HermesApiClient.kt` (capability detection) | `data class ServerCapabilities(sessionsApi, sessionsChatStream, runs, portable, healthy)` + `suspend fun probeCapabilities()`. Uses **HEAD-method probes** against `/api/sessions`, `/api/sessions/probe/chat/stream`, `/v1/runs`, `/v1/models`. Treats any non-404 status as "route exists" (200/204/401/403/405 all count). HEAD avoids hermes-agent's CORS middleware which intercepts OPTIONS preflight and returns 403 for both existing AND missing paths — verified empirically against the production gateway 2026-04-12. The result drives `streamingEndpoint = "auto"` resolution. |
|
||||
| `app/src/main/kotlin/.../viewmodel/ConnectionViewModel.kt` (auto-resolver) | `resolveStreamingEndpoint(preference)` collapses `"auto"` to a concrete `"sessions"` or `"runs"` based on the latest `serverCapabilities` snapshot. Manual `"sessions"` / `"runs"` settings pass through unchanged. |
|
||||
| `plugin/relay/auth.py` | PairingManager (generate + register_code), SessionManager, RateLimiter |
|
||||
| `plugin/relay/config.py` | RelayConfig + PAIRING_ALPHABET (full A-Z / 0-9 as of 2026-04-11) |
|
||||
| `plugin/relay/channels/terminal.py` | Phase 2 PTY-backed terminal handler |
|
||||
| `plugin/relay/channels/bridge.py` | Phase 3 bridge channel handler — routes agent tool calls to the connected phone over the unified WSS relay. `BridgeHandler.handle_command(method, path, params, body)` mints a `request_id`, sends a `bridge.command` envelope, and awaits a matching `bridge.response` with 30s timeout. `handle(ws, envelope)` opportunistically latches `phone_ws` and dispatches inbound `bridge.response`/`bridge.status`. `detach_ws(ws, reason)` fails all pending futures with `ConnectionError` on phone disconnect so HTTP callers fail fast. Migrated from the legacy standalone `plugin/tools/android_relay.py` (port 8766) into the unified relay on port 8767 in Phase 3 Wave 1 (Agent bridge-server, 2026-04-12). Wire protocol is frozen — envelope fields match the legacy relay byte-for-byte. 14 HTTP routes (`/ping`, `/screen`, `/screenshot`, `/get_apps`, `/apps` legacy, `/current_app`, `/tap`, `/tap_text`, `/type`, `/swipe`, `/open_app`, `/press_key`, `/scroll`, `/wait`, `/setup`) are registered in `plugin/relay/server.py` between `# === PHASE3-bridge-server ===` markers and delegate straight through to `handle_command`. |
|
||||
| `relay_server/__main__.py` | Thin shim → `plugin.relay.server.main()` — legacy `python -m relay_server` entrypoint |
|
||||
| `relay_server/SKILL.md` | Hermes skill reference for relay self-setup |
|
||||
| `relay_server/Dockerfile` | Container image for relay server |
|
||||
| `relay_server/hermes-relay.service` | Systemd unit file for persistent deployment |
|
||||
| `docs/relay-server.md` | Relay server setup, config, Docker, systemd, TLS reference |
|
||||
| `app/src/main/kotlin/.../ui/components/QrPairingScanner.kt` | QR code scanner + `HermesPairingPayload` (incl. optional `relay` block with `url` + `code`) |
|
||||
| `plugin/relay/media.py` | `MediaRegistry` — in-memory LRU-capped token store for inbound media (opaque tokens, 24h TTL, 500-entry cap, path sandboxing via `os.path.realpath` against allowed roots); shared `validate_media_path()` helper used by both register and by-path fetch. **`allowed_roots=None` skips the root check** (permissive mode — default for `/media/by-path` since 2026-04-11); the token path always enforces. `strict_sandbox` flag on `MediaRegistry` / `RelayConfig.media_strict_sandbox` / `RELAY_MEDIA_STRICT_SANDBOX=1` re-enables the allowlist on by-path for operators who want defense-in-depth. |
|
||||
| `plugin/relay/client.py` | `register_media()` — stdlib `urllib.request` helper for host-local tools to loopback-POST to `/media/register` and receive a token |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR payload signing — `canonicalize()`, `sign_payload()`, `verify_payload()`, `load_or_create_secret()` (host-local secret at `~/.hermes/hermes-relay-qr-secret`, 32 bytes, 0o600). `canonicalize` explicitly excludes the `sig` field so signing is idempotent. `allow_nan=False` so accidentally signing `math.inf` crashes loudly. |
|
||||
| `plugin/relay/auth.py` | Session/PairingMetadata/PairingManager/SessionManager/RateLimiter with full pairing architecture — per-channel grants on Session, `math.inf` for never-expire (serializes as `null`), `_materialize_grants` clamps grants to session lifetime, `RateLimiter.clear_all_blocks()` called on successful `/pairing/register` to fix "phone rate-limited after relay restart". `DEFAULT_TERMINAL_CAP=30d`, `DEFAULT_BRIDGE_CAP=7d`. |
|
||||
| `plugin/relay/server.py` (additions) | `handle_media_register` + `handle_media_get` + `handle_media_by_path`; `handle_pairing_register` now accepts `ttl_seconds` / `grants` / `transport_hint` metadata and clears rate-limit blocks on success; `handle_pairing_approve` (Phase 3 bidirectional pairing stub); `handle_sessions_list` (GET /sessions — tokens masked to 8-char prefix); `handle_sessions_revoke` (DELETE /sessions/{prefix}, self-revoke flagged); `_detect_transport_hint` helper sniffs `request.transport.get_extra_info('ssl_object')`; `auth.ok` payload now includes `expires_at` / `grants` / `transport_hint` with `math.inf → null`. |
|
||||
| `plugin/tools/android_tool.py::android_screenshot` | First consumer of `register_media()` — emits `MEDIA:hermes-relay://<token>` on success, falls back to bare `MEDIA:<path>` on relay unavailability |
|
||||
| `plugin/tools/android_navigate.py` | Phase 3 Tier 4 `android_navigate(intent, max_iterations=5)` tool — vision-driven close-the-loop navigation. One screenshot per iteration via the bridge `/screenshot` endpoint (never continuous capture), vision model picks next action, tool dispatches to `/tap_text`/`/tap`/`/type`/`/swipe`/`/press_key` on the same Wave 1 bridge relay as `android_tool.py`. Returns a structured trace `[{step, action, params, screenshot_token, reasoning, result}, …]` on success + every failure envelope. Default cap 5, hard-clamped to `ABSOLUTE_MAX_ITERATIONS=20`. LLM integration uses a `call_vision_model` injection point (production swaps `_default_vision_model` for a real Anthropic/OpenAI vision client; tests patch it; smoke runs can set `HERMES_NAVIGATE_STUB_REPLY`). Until a real client is wired up, calling against a live phone returns `{"status": "error", "reason": "llm_gap", ...}` instead of crashing. |
|
||||
| `plugin/tools/android_navigate_prompt.py` | Prompt template + response parser for `android_navigate`, kept separate so the parser is unit-testable without importing `requests`. Response format is `ACTION: <verb>\nPARAMS: <json>\nREASON: <sentence>`. Parser is case-insensitive, tolerates trailing punctuation on the verb, allows `done` to omit PARAMS, enforces per-verb shape (`tap` needs `(x,y)` ints or `node_id` string, `swipe` requires valid direction, etc.), and surfaces structural failures as `ParsedAction(action="error", reasoning=<why>)` so the loop can bail cleanly instead of calling the bridge with garbage. |
|
||||
| `plugin/tests/test_android_navigate.py` | Stdlib `unittest` suite for `android_navigate` (35 tests) — every valid verb + malformed-input grid for the parser, plus loop tests for success/iteration-cap/screenshot-failure/parse-error/action-failure/llm-gap via `unittest.mock`. Uses no `responses`/`pytest` so it runs via `python -m unittest plugin.tests.test_android_navigate` without tripping the existing `conftest.py`. |
|
||||
| `plugin/pair.py` | QR payload builder + CLI shim (`--ttl` / `--grants` flags, parses durations + grant specs). `build_payload(sign=True)` auto-bumps `hermes: 1 → 2` when any v2 field is present and attaches HMAC signature via `qr_sign`. `read_relay_config` detects TLS via `RELAY_SSL_CERT` for the transport hint. |
|
||||
| `app/src/main/kotlin/.../auth/SessionTokenStore.kt` | Session token storage abstraction — `KeystoreTokenStore` (StrongBox-preferred via `setRequestStrongBoxBacked` on Android 9+, best-effort via `tryCreate`) + `LegacyEncryptedPrefsTokenStore` (TEE-backed `EncryptedSharedPreferences` fallback). One-shot lossless migration on first launch post-upgrade. |
|
||||
| `app/src/main/kotlin/.../auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI fingerprints per `host:port` in DataStore. `recordPinIfAbsent` on first successful wss handshake; `buildPinnerSnapshot` produces an OkHttp `CertificatePinner`. Wiped on explicit re-pair via `applyServerIssuedCodeAndReset`. Plain `ws://` short-circuits pinning. |
|
||||
| `app/src/main/kotlin/.../auth/PairedSession.kt` | `PairedSession` state (token, expiresAt, grants, transportHint, firstSeen, hasHardwareStorage) + `PairedDeviceInfo` wire model for `GET /sessions` responses. |
|
||||
| `app/src/main/kotlin/.../auth/AuthManager.kt` | Wires `SessionTokenStore` + `CertPinStore`, exposes `currentPairedSession: StateFlow<PairedSession?>`, parses new auth.ok fields (`expires_at` / `grants` / `transport_hint`), injects `ttl_seconds` + `grants` into pairing-mode auth envelope. `applyServerIssuedCodeAndReset(code, relayUrl?)` wipes the TOFU pin for the target host. |
|
||||
| `app/src/main/kotlin/.../data/PairingPreferences.kt` | DataStore keys for the pairing UX: `pair_ttl_seconds`, `insecure_ack_seen`, `insecure_reason`, `tofu_pins`. |
|
||||
| `app/src/main/kotlin/.../util/TailscaleDetector.kt` | Informational Tailscale detection via `NetworkInterface` scan (`tailscale0` + `100.64.0.0/10`) + relay-URL host check (`.ts.net` / CGNAT). Purely informational — does NOT auto-change defaults. |
|
||||
| `app/src/main/kotlin/.../ui/components/SessionTtlPickerDialog.kt` | Compose TTL picker — 1d / 7d / 30d / 90d / 1y / Never radio list, inline warning under Never, `defaultTtlSeconds(qrTtl, transportHint, tailscale)` helper (QR operator value wins → wss/Tailscale = 30d → plain ws = 7d → unknown = 30d). Always opens on QR scan so user confirms the TTL. |
|
||||
| `app/src/main/kotlin/.../ui/components/TransportSecurityBadge.kt` | Three-state badge (🔒 secure / 🔓 insecure-with-reason / 🔓 insecure-unknown), three sizes (Chip / Row / Large). Rendered in Settings Connection, Session info sheet, and Paired Devices cards. |
|
||||
| `app/src/main/kotlin/.../ui/components/InsecureConnectionAckDialog.kt` | First-time insecure-mode consent dialog — plain-language threat model + reason picker (LAN only / Tailscale or VPN / Local dev only). Persists ack + reason but does NOT gate the toggle — operator intent is the trust model. |
|
||||
| `app/src/main/kotlin/.../ui/screens/PairedDevicesScreen.kt` | Full-screen list of all paired devices with metadata (name + ID, transport badge, expiry, grant chips, current badge, revoke button). Pulls from `RelayHttpClient.listSessions()`. Revoke via `RelayHttpClient.revokeSession(tokenPrefix)`. Self-revoke wipes local state + redirects to pair. |
|
||||
| `app/src/main/kotlin/.../network/ConnectionManager.kt` | Takes optional `CertPinStore` — rebuilds `OkHttpClient` on every connect with fresh `CertificatePinner` snapshot so re-pair pin wipes take effect immediately. Records peer cert fingerprint in `onOpen` on TLS handshake. |
|
||||
| `app/src/main/kotlin/.../network/RelayHttpClient.kt` | OkHttp client for `GET /media/{token}`, `GET /media/by-path`, plus **`listSessions()`** (GET /sessions → `List<PairedDeviceInfo>`, 404 → empty list so UI degrades gracefully), **`revokeSession(tokenPrefix)`** (DELETE /sessions/{prefix}, 404 → success), **`extendSession(tokenPrefix, ttlSeconds?, grants?)`** (PATCH /sessions/{prefix} — restarts the clock from now), and **`probeHealth(relayUrl)`** (unauthenticated GET /health with 3s timeout, parses response body, validates it looks like a hermes-relay via `status == "ok"` + non-blank `version` — backs the Settings → Manual configuration → Save & Test button). |
|
||||
| `plugin/relay/voice.py` | Voice endpoints on the relay — `VoiceHandler` with `handle_transcribe` (multipart audio → text), `handle_synthesize` (JSON text → audio/mpeg file), `handle_voice_config` (provider availability + current settings). Imports `tools.tts_tool.text_to_speech_tool` and `tools.transcription_tools.transcribe_audio` lazily inside each handler (both sync functions wrapped in `asyncio.to_thread`). Bearer auth via local `_require_bearer_session` helper, matching `/media/*` pattern. Reads provider config from `~/.hermes/config.yaml` internally via the upstream tools — relay doesn't need to pass provider/voice arguments. |
|
||||
| `app/src/main/kotlin/.../audio/VoiceRecorder.kt` | `MediaRecorder` wrapper — MPEG_4/AAC output to `.m4a` at 16kHz/64kbps mono. Exposes `amplitude: StateFlow<Float>` polled at ~60fps from `mediaRecorder.maxAmplitude` with **perceptual curve** (noise-floor subtraction + speech-ceiling rescale + sqrt boost) so normal conversation (~raw 3000/32767) maps to a visually reactive ~0.48 rather than linear 0.09. API 31+ uses context-ctor, legacy path suppressed. |
|
||||
| `app/src/main/kotlin/.../audio/VoicePlayer.kt` | `MediaPlayer` + `android.media.audiofx.Visualizer` wrapper for TTS playback. Exposes `amplitude: StateFlow<Float>` driven by 8-bit PCM RMS with NaN guard (`Float.coerceIn` silently passes NaN per IEEE 754). Visualizer construction is in try/catch — some OEM devices refuse even with `MODIFY_AUDIO_SETTINGS`, falls back to flat-zero amplitude without crashing. `suspend fun awaitCompletion()` via `suspendCancellableCoroutine`. |
|
||||
| `app/src/main/kotlin/.../audio/VoiceSfxPlayer.kt` | Pre-synthesized 200 ms PCM chimes (ascending 440→660 Hz enter, descending mirror exit) played via `AudioTrack.MODE_STATIC` with `USAGE_ASSISTANT`. Phase-accumulated sweep (no chirp artifacts), 15 ms attack + 40 ms half-cosine decay, peak 0.35 of full-scale. Failed AudioTrack construction becomes a silent no-op — voice mode still works, just no chime. Wired into `VoiceViewModel.enterVoiceMode()` / `exitVoiceMode()` via a 5th `initialize()` parameter. |
|
||||
| `app/src/main/kotlin/.../network/RelayVoiceClient.kt` | OkHttp client for `/voice/*` endpoints. `transcribe(File): Result<String>` (multipart POST), `synthesize(String): Result<File>` (JSON POST → audio bytes streamed to `cacheDir/voice_tts_<ts>.mp3`), `getVoiceConfig(): Result<VoiceConfig>`. Mirrors `RelayHttpClient`'s ctor shape — same bearer-auth pattern, same error handling. |
|
||||
| `app/src/main/kotlin/.../voice/VoiceBridgeIntentHandler.kt` | **Phase 3 Wave 2 voice-intents**: interface + `sealed class IntentResult { Handled, NotApplicable }` for routing transcribed voice utterances to the bridge channel instead of chat. Flavor-specific impls live in `app/src/googlePlay/.../voice/VoiceBridgeIntentHandlerImpl.kt` (no-op) and `app/src/sideload/.../voice/VoiceBridgeIntentHandlerImpl.kt` (real keyword classifier). Both flavors export `createVoiceBridgeIntentHandler(multiplexer)` in `VoiceBridgeIntentFactory.kt`. `VoiceViewModel` only ever sees the interface — no reflection, flavor picked at build time. v1 confirmation model: destructive intents (SMS) speak a confirmation + start a 5s countdown coroutine, cancellable via `VoiceViewModel.cancelPendingBridgeIntent()`. Full conversational confirmation is a Wave 3 follow-up. |
|
||||
| `app/src/sideload/kotlin/.../voice/VoiceIntentClassifier.kt` | **Phase 3 Wave 2 voice-intents** (sideload only): regex-based keyword classifier for phone-control intents. Six patterns: SendSms (`text <contact> saying <body>` / `text <contact>: <body>` / no-separator fallback), OpenApp (`open|launch|start <app>`), Tap (`tap|press|click(?: on)? <target>`), Scroll (`scroll up|down|top|bottom`), Back (`(go|navigate)? back`), Home (`(press|go)? home`). Filler words (`hey`, `okay`, `please`, `can you`, …) are stripped before matching. Design bias: false negatives > false positives — utterances that don't cleanly split into a structured action fall through to chat via `IntentResult.NotApplicable`. |
|
||||
| `app/src/main/kotlin/.../viewmodel/VoiceViewModel.kt` | Voice turn state machine — `Idle / Listening / Transcribing / Thinking / Speaking / Error`. Sentence-boundary detection via top-level `extractNextSentence(StringBuilder)` helper (whitespace-lookahead for abbreviations like `e.g.`, `MIN_SENTENCE_LEN=6`). TTS queue as `Channel<String>` with dedicated consumer coroutine (while+tryReceive loop — `maybeAutoResume` only fires when queue is actually drained, NOT after every sentence, so waveform stays alive through multi-sentence playback). **`ignoreAssistantId`** captures the pre-send last-assistant-id so the stream observer doesn't replay the previous turn's response as TTS. **`errorEvents: SharedFlow<HumanError>`** one-shot events from `surfaceError(t, context)` → snackbar + overlay banner. **`applyEnvelope`** attack-fast/release-slow follower (0.75/0.10 at 60Hz) replaces the old Compose spring. **Integration pattern**: observes `chatVm.messages: StateFlow<List<ChatMessage>>` directly (diffs last streaming message's content length) rather than adding a callback to `ChatViewModel` — zero churn on chat code. Transcribed user text routes through `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. |
|
||||
| `app/src/main/kotlin/.../ui/components/VoiceModeOverlay.kt` | Full-screen voice-mode UI — top bar with interaction-mode dropdown + close X, MorphingSphere at 60% height (voiceMode=true), VoiceWaveform, scrollable response text, mic button with `pulseScale` from amplitude + hold-gesture `pointerInput` + haptics. Speaking + Listening states both use vivid red `Color(0xFFE53935)` stop button. Optional `errorEvents: SharedFlow<HumanError>?` parameter collects classified errors into `LocalSnackbarHost`. Maps `VoiceState` → `SphereState`. |
|
||||
| `app/src/main/kotlin/.../ui/components/VoiceWaveform.kt` | Reactive layered-sine-wave visualizer mounted below the MorphingSphere. Three overlapping sine waves at co-prime frequencies (1.2/2.1/3.4) with amplitude-driven phase velocity via `rememberAmplitudeDrivenPhases` (`withFrameNanos` ticker, base durations 2000/1400/950 ms, speed up to 3.5× at peak amplitude via `PHASE_AMP_BOOST`). Pill-edge merge via dual technique: geometric `sin(π·t)` taper forces wave to centerY at both endpoints + `BlendMode.DstIn` horizontal gradient mask inside `saveLayer`. Color-keyed to `VoiceState` (blue/purple Listening, green/teal Speaking). No Compose spring — amplitude comes pre-smoothed from the ViewModel's envelope follower. |
|
||||
| `app/src/main/kotlin/.../ui/screens/VoiceSettingsScreen.kt` | Voice settings sub-screen — interaction mode radio list, silence threshold slider (1–10s), Auto-TTS switch ("coming soon"), TTS/STT provider info from `/voice/config`, language picker (stored to `VoicePreferences` but relay auto-detects for now), Test Voice button calls `VoiceViewModel.testVoice(sample)`. |
|
||||
| `app/src/main/kotlin/.../data/VoicePreferences.kt` | DataStore repo mirroring `MediaSettings.kt`. Keys: `voice_interaction_mode` (tap/hold/continuous), `voice_silence_threshold_ms` (default 3000), `voice_auto_tts` (default false, reserved), `voice_language` (default ""). |
|
||||
| `app/src/main/kotlin/.../ui/screens/BridgeScreen.kt` | Phase 3 bridge-ui rewrite — four-card control surface (master toggle, status, permission checklist, activity log) + inert Safety placeholder. Owns a `BridgeViewModel` via default `viewModel()` param. Re-probes permission state on `Lifecycle.Event.ON_RESUME` so returning from Android Settings flips the a11y row from red to green without navigation churn. |
|
||||
| `app/src/main/kotlin/.../viewmodel/BridgeViewModel.kt` | Phase 3 bridge-ui — AndroidViewModel for BridgeScreen. Exposes `masterToggle`/`bridgeStatus`/`permissionStatus`/`activityLog` StateFlows. Reads `Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES` + `Settings.canDrawOverlays` + `enabled_notification_listeners`. Seeds `BridgeStatus` from `PowerManager.isInteractive` + `BatteryManager.BATTERY_PROPERTY_CAPACITY`. **accessibility-handoff stubs** marked with `TODO(accessibility-handoff)`: replace `_bridgeStatus` MutableStateFlow with the real `HermesAccessibilityService` status flow; confirm `A11Y_SERVICE_CLASS` FQCN; wire accessibility's command dispatcher to call `recordActivity(entry)`. |
|
||||
| `app/src/main/kotlin/.../data/BridgePreferences.kt` | Phase 3 bridge-ui — DataStore repo backing `BridgeViewModel`. Keys: `bridge_master_enabled` (Boolean) + `bridge_activity_log` (JSON-serialized `List<BridgeActivityEntry>` capped at `MAX_LOG_ENTRIES=100`). `appendEntry` dedupes by id so Pending → Success/Failed transitions replace in place. Lenient `Json` for forward-compat. |
|
||||
| `app/src/main/kotlin/.../ui/components/BridgeMasterToggle.kt` | Phase 3 bridge-ui — headline "Allow Agent Control" Switch card. Inline device/battery/screen/current-app rows, Play-review-required explanation dialog behind the info icon, `accessibilityGranted` gate blocks enabling before a11y permission is granted. |
|
||||
| `app/src/main/kotlin/.../ui/components/BridgeStatusCard.kt` | Phase 3 bridge-ui — standalone bridge status card. Reuses `ConnectionStatusBadge` for the pulsing connected/disconnected dot. Separate from `BridgeMasterToggle` so Agent safety-rails can rearrange cards without losing state. |
|
||||
| `app/src/main/kotlin/.../ui/components/BridgePermissionChecklist.kt` | Phase 3 bridge-ui — four-row permission checklist (Accessibility / Screen Capture / Overlay / Notification Listener). Tap-to-open Android Settings via `ACTION_ACCESSIBILITY_SETTINGS`, `ACTION_MANAGE_OVERLAY_PERMISSION`, `enabled_notification_listeners`. Each launcher wrapped in `runCatching` for OEM-skin safety. Green check vs. red empty-circle status icons. |
|
||||
| `app/src/main/kotlin/.../ui/components/BridgeActivityLog.kt` | Phase 3 bridge-ui — scrollable activity log. `LazyColumn` bounded at `heightIn(max = 320.dp)` with tap-to-expand rows showing full timestamp, status, result text, and optional screenshot token. `java.time.DateTimeFormatter` for `HH:mm:ss` / `yyyy-MM-dd HH:mm:ss`. Status icons: hourglass/check/error/block for Pending/Success/Failed/Blocked. |
|
||||
| `app/src/main/kotlin/.../ui/components/MorphingSphere.kt` | ASCII morphing sphere — 3D lit character sphere. **New for voice mode**: `SphereState.Listening` (soft blue/purple, subtle wobble with `voiceAmplitude`) and `SphereState.Speaking` (vivid green/teal, dramatic core-warmth pulse with amplitude, data ring spin up to 4× on peak). New parameters `voiceAmplitude: Float = 0f` and `voiceMode: Boolean = false` (both defaulted — existing call sites unchanged). Radius scale capped at 1.08× in voiceMode — `baseRadius` is already 0.60× half-extent and the data ring at 1.55× would overflow past 1.1×. Three `@Preview` functions for Listening, Speaking-low, Speaking-peak. |
|
||||
| `app/src/main/kotlin/.../util/RelayErrorClassifier.kt` | `classifyError(Throwable?, context: String?) → HumanError(title, body, retryable, actionLabel)`. Single when-branch classifier — UnknownHostException → ConnectException → SocketTimeoutException → SSLException → SecurityException → IllegalStateException → IOException (message-scan for HTTP status 401/403/404/413/500/503) → default. Branch order is load-bearing (SSLException extends IOException). Context tags (`transcribe`, `synthesize`, `voice_config`, `record`, `pair`, `save_and_test`, `media_fetch`, `send_message`) shape the title and 404 body. Used by VoiceViewModel, ChatViewModel, ConnectionSettingsScreen. |
|
||||
| `app/src/main/kotlin/.../util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` writer — LRU eviction by mtime, MIME→extension map, returns `FileProvider` `content://` URIs |
|
||||
| `app/src/main/kotlin/.../accessibility/HermesAccessibilityService.kt` | Phase 3 accessibility — `AccessibilityService` subclass. `@Volatile instance` singleton set on `onServiceConnected` / cleared on unbind, so `BridgeCommandHandler` can reach the live service without DI. Caches foregrounded package from `TYPE_WINDOW_STATE_CHANGED`. Master enable lives in DataStore (`bridge_master_enabled`). `snapshotRoot()` wraps `rootInActiveWindow`. |
|
||||
| `app/src/main/kotlin/.../accessibility/ScreenReader.kt` | Phase 3 accessibility — UI tree → `ScreenContent(rootBounds, nodes[], truncated)` via `@Serializable`. Walks `AccessibilityNodeInfo` tree capped at `MAX_NODES=512`, collects text/contentDescription/bounds/class/viewId + clickable/scrollable/editable flags. `findNodeBoundsByText(root, needle)` for `/tap_text` and `findFocusedInput(root)` for `/type`. Recycles child nodes in per-iteration `try/finally`. |
|
||||
| `app/src/main/kotlin/.../accessibility/ActionExecutor.kt` | Phase 3 accessibility — `tap`/`tapText`/`swipe`/`scroll` via `GestureDescription` wrapped in `suspendCancellableCoroutine` so suspend form actually waits for `GestureResultCallback.onCompleted`. `typeText` uses `ACTION_SET_TEXT`. `pressKey` maps curated string vocab to `AccessibilityService.GLOBAL_ACTION_*` (no raw KeyEvent codes — no arbitrary injection). `wait(ms)` clamped to 15s. Returns `ActionResult(ok, data, error)`. |
|
||||
| `app/src/main/kotlin/.../accessibility/ScreenCapture.kt` | Phase 3 accessibility — `MediaProjection` → `VirtualDisplay` → `ImageReader` → PNG bytes → multipart upload to `POST /media/upload` on the relay. Crops `rowStride` padding before `Bitmap.copyPixelsFromBuffer`. 2.5s capture timeout. Co-located `MediaProjectionHolder` singleton holds the per-session grant; Bridge UI (Agent bridge-ui) is responsible for the `ActivityResultLauncher` flow calling `MediaProjectionHolder.onGranted(resultCode, data)`. **Blocked on bridge-server: `POST /media/upload` endpoint doesn't exist yet** — current `/media/register` is loopback-only + path-based, phone has no shared filesystem. |
|
||||
| `app/src/main/kotlin/.../accessibility/BridgeStatusReporter.kt` | Phase 3 accessibility — coroutine emitting `bridge.status` envelopes every 30s with `screen_on` (`PowerManager.isInteractive`), `battery` (`BatteryManager.BATTERY_PROPERTY_CAPACITY` + sticky-intent fallback), `current_app` (from service singleton), `accessibility_enabled` (true when service instance non-null). Owned + started by `ConnectionViewModel`. |
|
||||
| `app/src/main/kotlin/.../network/handlers/BridgeCommandHandler.kt` | Phase 3 accessibility — routes inbound `bridge.command` envelopes to `ActionExecutor` and emits `bridge.response`. Paths: `/ping`, `/tap`, `/tap_text`, `/type`, `/swipe`, `/scroll`, `/press_key`, `/wait`, `/screen`, `/screenshot`, `/current_app`. Gates everything except `/ping` + `/current_app` on master-enable; returns 503 if service not connected, 403 if soft master off. `/screen` serializes `ScreenContent` via `Json.encodeToJsonElement`. **Phase 3 safety-rails additions:** optional `safetyManager: BridgeSafetyManager?` constructor arg runs the Tier 5 three-stage check on every command — blocklist (currentApp vs DataStore) → destructive-verb confirmation (for `/tap_text` + `/type`, suspend-awaits the overlay modal) → reschedule auto-disable timer. Blocked packages return 403 `{"error": "blocked package <name>"}`, denied confirmations return 403 `{"error": "user denied destructive action", "reason": "confirmation_denied_or_timeout"}`. |
|
||||
| `app/src/main/kotlin/.../data/BridgeSafetyPreferences.kt` | Phase 3 safety-rails — DataStore-backed Tier 5 preferences. Keys: `bridge_blocklist` (JSON sorted list of package names, seeded with ~30 banking/payments/password-manager/2FA defaults via `DEFAULT_BLOCKLIST`), `bridge_destructive_verbs` (JSON sorted list, default `send`/`pay`/`delete`/`transfer`/`confirm`/`submit`/`post`/`publish`/`buy`/`purchase`/`charge`/`withdraw`), `bridge_auto_disable_minutes` (Int, default 30, clamped 5..120), `bridge_status_overlay_enabled` (Bool, default false), `bridge_confirmation_timeout_seconds` (Int, default 30, clamped 10..60). Sentinel `bridge_safety_initialized` distinguishes "user cleared blocklist" from "never touched" so defaults survive reinstall but respect user edits. |
|
||||
| `app/src/main/kotlin/.../bridge/BridgeSafetyManager.kt` | Phase 3 safety-rails — central enforcement point for Tier 5 safety. Process-singleton installed at `ConnectionViewModel` init time. Methods: `checkPackageAllowed(pkg)` (blocklist gate), `requiresConfirmation(method, text)` + `awaitConfirmation(method, text)` (destructive verb gate — suspends on `CompletableDeferred<Boolean>` under `withTimeout`, fails-closed on missing overlay permission), `rescheduleAutoDisable()` + `cancelAutoDisable()` (coroutine-owned idle timer — `delay(minutes.toMillis())` rather than WorkManager since androidx.work is not in the classpath). Exposes `settings: StateFlow<BridgeSafetySettings>` + `autoDisableAtMs: StateFlow<Long?>` for the Bridge screen countdown. Word-boundary regex verb matching via `\\b${Regex.escape(verb)}\\b`. |
|
||||
| `app/src/main/kotlin/.../bridge/BridgeForegroundService.kt` | Phase 3 safety-rails — persistent "Hermes agent has device control" foreground service. `foregroundServiceType=specialUse` on Android 14+ with `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` justification in the manifest. Two notification actions: **Disable** (service re-entry → `HermesAccessibilityService.setMasterEnabled(ctx, false)`), **Settings** (opens `MainActivity`; TODO to wire a deep-link extra for `BridgeSafetySettingsScreen`). Lifecycle driven entirely from `BridgeViewModel.init` — a `masterToggle.distinctUntilChanged().collect {}` calls `start()`/`stop()` companion methods. |
|
||||
| `app/src/main/kotlin/.../bridge/BridgeStatusOverlay.kt` | Phase 3 safety-rails — `WindowManager` overlay host. One singleton per process; implements `ConfirmationOverlayHost` so `BridgeSafetyManager` can reach both the destructive-verb modal and the optional floating status chip through the same slot. `TYPE_APPLICATION_OVERLAY` on API 26+, fallback to `TYPE_PHONE` below. Chip is `FLAG_NOT_FOCUSABLE` (taps fall through); modal is `FLAG_DIM_BEHIND` full-screen (intercepts touches). `ComposeView` attachments are wired to a synthetic `OverlayLifecycleOwner` that reports always-RESUMED + implements `ViewModelStoreOwner` — Compose's recomposer refuses to run without a `ViewTreeLifecycleOwner`. `SavedStateRegistryOwner` deliberately skipped (overlay content uses `remember`, not `rememberSaveable`). |
|
||||
| `app/src/main/kotlin/.../bridge/AutoDisableWorker.kt` | Phase 3 safety-rails — "turn the bridge off after idle" unit of work. NOT a real `androidx.work.CoroutineWorker` (androidx.work isn't in the classpath) but shaped like one: single suspend `run()` method. Flips master toggle via `HermesAccessibilityService.setMasterEnabled(ctx, false)` and posts a one-shot "Bridge auto-disabled" notification through `NotificationManagerCompat` on its own `bridge_auto_disable` channel. Skips the notify() call when `POST_NOTIFICATIONS` isn't granted on Android 13+. Upgrade path to real WorkManager documented at the top of the file. |
|
||||
| `app/src/main/kotlin/.../ui/screens/BridgeSafetySettingsScreen.kt` | Phase 3 safety-rails — Compose settings sub-screen for Tier 5. Five sections: blocklist (searchable LazyColumn of installed apps via `PackageManager.queryIntentActivities(CATEGORY_LAUNCHER)` unioned with `DEFAULT_BLOCKLIST` entries, checkboxes), destructive verbs (input field + chip list, word-boundary matching), auto-disable timer slider 5..120 min, status-overlay switch (walks the user through `ACTION_MANAGE_OVERLAY_PERMISSION` on first enable), confirmation timeout slider 10..60 s. Re-probes `Settings.canDrawOverlays` on `Lifecycle.Event.ON_RESUME`. |
|
||||
| `app/src/main/kotlin/.../ui/components/DestructiveVerbConfirmDialog.kt` | Phase 3 safety-rails — confirmation modal content rendered inside the `BridgeStatusOverlay` ComposeView. NOT a Compose `Dialog` (those require an Activity window, we're in a `WindowManager` overlay). Shows the method name + flagged verb + full payload text so the user isn't guessing what they're allowing. Red "Allow" + outlined "Deny" — Deny is the safe default button placement (left). Same file also hosts `BridgeStatusOverlayChip` (the small floating "Hermes active" pill). Three `@Preview` functions for `/tap_text`, `/type`, and the chip. |
|
||||
| `app/src/main/kotlin/.../ui/components/BridgeSafetySummaryCard.kt` | Phase 3 safety-rails — replaces the inert `SafetyPlaceholderCard` in `BridgeScreen`. Reads `BridgeSafetyManager.peek()?.settings` + `.autoDisableAtMs` and displays blocklist count / destructive-verb count / countdown timer (`in MM:SS` when active, else `N min idle`). `LaunchedEffect` ticker recomposes every 1s while the countdown is active. Tap → navigate to `BridgeSafetySettingsScreen`. |
|
||||
| `app/src/main/kotlin/.../data/MediaSettings.kt` | DataStore-backed settings (max inbound size, auto-fetch threshold [persisted-not-enforced], auto-fetch on cellular, cached media cap) |
|
||||
| `app/src/main/kotlin/.../ui/components/InboundAttachmentCard.kt` | Discord-style attachment card — dispatches by `(AttachmentState × AttachmentRenderMode)`; images inline, video/audio/pdf/text/generic as tap-to-open via `ACTION_VIEW` + `FLAG_GRANT_READ_URI_PERMISSION`. Handles outbound attachments too (default `state=LOADED`). |
|
||||
| `app/src/main/res/xml/file_provider_paths.xml` | FileProvider path config — `<cache-path name="hermes-media" path="hermes-media/"/>` |
|
||||
| `app/src/main/AndroidManifest.xml` | Declares `androidx.core.content.FileProvider` with authority `${applicationId}.fileprovider` |
|
||||
| `plugin/pair.py` | QR pairing implementation — probes local relay, pre-registers pairing code via `/pairing/register`, embeds relay URL + code in QR (pure-Python, uses segno). Invoked via `python -m plugin.pair`, wrapped by the `hermes-pair` shell shim and the `hermes-relay-pair` skill. |
|
||||
| `plugin/cli.py` | Registers plugin CLI sub-commands via the v0.8.0 plugin CLI API. **Note:** the top-level `hermes pair` sub-command is not currently reachable — upstream `hermes_cli/main.py` only reads `plugins.memory.discover_plugin_cli_commands()` and never consults the generic plugin CLI dict. Use `/hermes-relay-pair` (skill) or `hermes-pair` (shell shim) instead. |
|
||||
| `skills/devops/hermes-relay-pair/SKILL.md` | `/hermes-relay-pair` slash command — canonical category layout (`devops`), matches `metadata.hermes.category` frontmatter. Discovered via `skills.external_dirs` entry added by the installer. |
|
||||
| `plugin/relay/_env_bootstrap.py` | `load_hermes_env() → list[Path]` — loads `~/.hermes/.env` into `os.environ` before the relay server imports anything that reads env vars. Prefers `hermes_cli.env_loader.load_hermes_dotenv` when importable, falls back to direct `python-dotenv`, silent no-op in stripped containers. Called from both `plugin/relay/__main__.py` and `relay_server/__main__.py` so both entry points bootstrap identically. This is why neither `hermes-relay.service` nor `hermes-gateway.service` carries an `EnvironmentFile=` directive. |
|
||||
| `install.sh` | Canonical installer — 6 steps + 1 sub-step. [1] clone repo, [2] `pip install -e`, [3] symlink plugin, [4] register skills in `config.yaml`, [5] write three shell shims (`hermes-pair`, `hermes-status`, `hermes-relay-update`), [6] systemd user unit (optional — skipped on macOS/WSL-without-systemd/containers, or via `$HERMES_RELAY_NO_SYSTEMD`), [6b] OFFER (don't force) hermes-gateway restart so re-imported plugin tools take effect. Idempotent: the relay-restart path uses explicit `systemctl --user restart` instead of `enable --now` (the latter is a no-op on already-active services and was the source of the 2026-04-12 "install ran clean but relay is still on stale code" debug rabbit hole). The canonical update flow is `hermes-relay-update` (a thin shim that re-runs the curl pipe) or the curl pipe itself — both are equivalent. |
|
||||
| `plugin/pair.py::register_code_command` | Manual fallback for pre-registering an arbitrary 6-char pairing code with the relay without going through QR rendering. Invoked via `hermes-pair --register-code ABCD12 [--ttl 30d --grants chat:never,bridge:7d]`. Use case: phone is the only device with a camera, host is SSH-only. The phone's app-side "Manual pairing code (fallback)" card in Settings → Connection generates a code, the user types it into this CLI on the host, then taps Connect in the app. The fallback path in `AuthManager.authenticate()` sends `_pairingCode.value` as the `pairing_code` field when no `serverIssuedCode` is set. Tests: `plugin/tests/test_register_code.py` (25 stdlib unittest cases). |
|
||||
| `~/.local/bin/hermes-relay-update` | Discoverable shell shim for "update Hermes-Relay". Two-line wrapper around the canonical curl pipe (`curl -fsSL .../install.sh \| bash -s -- "$@"`) — re-fetches the latest install.sh on every invocation, so improvements to install.sh itself take effect immediately. Forwards args via `bash -s --`. Honors `HERMES_RELAY_RESTART_GATEWAY=1` / `HERMES_RELAY_NO_RESTART_GATEWAY=1` env vars naturally. |
|
||||
| `app/build.gradle.kts` (Phase 3) | `flavorDimensions += "track"` + `googlePlay` / `sideload` product flavors. `sideload` gets `applicationIdSuffix = ".sideload"` + `versionNameSuffix = "-sideload"` so both tracks coexist on the same device; `googlePlay` keeps the canonical applicationId for clean Play Store upgrades from v0.2.0. |
|
||||
| `app/src/googlePlay/AndroidManifest.xml` | Google Play flavor manifest overlay — declares `BridgeAccessibilityService` with the conservative use-case description (`@string/a11y_description_googleplay`) and the conservative `@xml/accessibility_service_config`. Merged onto `app/src/main/AndroidManifest.xml` by AGP at build time. |
|
||||
| `app/src/googlePlay/res/xml/accessibility_service_config.xml` | Conservative a11y config — `typeWindowStateChanged\|typeWindowContentChanged\|typeViewClicked` event subset, `flagDefault` only, no gestures. Targeted at Play Store policy review. |
|
||||
| `app/src/googlePlay/res/values/strings.xml` | `a11y_description_googleplay` — "notifications / summarize / reply with confirmation" language. Reviewed by Play Store policy. |
|
||||
| `app/src/sideload/AndroidManifest.xml` | Sideload flavor manifest overlay — same `BridgeAccessibilityService` reference, full-capability `@string/a11y_description_sideload` description. Not shipped through Google Play. |
|
||||
| `app/src/sideload/res/xml/accessibility_service_config.xml` | Full a11y config — `typeAllMask`, `flagRetrieveInteractiveWindows\|flagReportViewIds\|flagRequestTouchExplorationMode`, `canPerformGestures="true"`. |
|
||||
| `app/src/sideload/res/values/strings.xml` | `a11y_description_sideload` — full agent-control language (voice + vision + logging + confirmations). |
|
||||
| `app/src/main/kotlin/.../data/FeatureFlags.kt::BuildFlavor` | Compile-time flavor gating. Exposes `current` (from `BuildConfig.FLAVOR`), `displayName` for the Settings → About badge, and six `bridgeTier1..6` flags. Tier 3/4/6 are `get() = current == SIDELOAD` so R8 can fold them in the Play release build. |
|
||||
| `plugin/relay/channels/notifications.py` | **Phase 3 / Wave 1 / notif-listener** — `NotificationsChannel` holds a bounded `collections.deque` (maxlen 100) of recent notification metadata forwarded by the phone over the `notifications` WSS channel. Append on `notification.posted`, read via `get_recent(limit)` (newest-first, clamped to `[1, max_entries]`). In-memory only — wiped on relay restart by design (matches the smartwatch-companion semantics). |
|
||||
| `plugin/tools/android_notifications.py` | **Phase 3 / Wave 1 / notif-listener** — Registers `android_notifications_recent(limit=20)` Hermes tool. Calls `http://127.0.0.1:8767/notifications/recent?limit=N` over loopback (no bearer needed for loopback callers — same trust model as `/media/register`), parses JSON, returns structured envelope. Stdlib `urllib.request` only — no requests/httpx dependency. Honours `RELAY_PORT` env. |
|
||||
| `plugin/relay/server.py` (Phase3-notif-listener additions) | `handle_notifications_recent` HTTP route — loopback callers skip bearer, remote callers go through `_require_bearer_session`. Limit clamped via `NotificationsChannel.get_recent`. Channel dispatch in `_on_message` routes `channel == "notifications"` envelopes to `server.notifications.handle()`. Markers: `# === PHASE3-notif-listener: ... === / # === END PHASE3-notif-listener ===`. |
|
||||
| `app/src/main/kotlin/.../notifications/HermesNotificationCompanion.kt` | **Phase 3 / Wave 1 / notif-listener** — `NotificationListenerService` subclass. Opt-in via `Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS` (the same Android API Wear OS / Android Auto / Tasker use). On `onNotificationPosted`, builds a `NotificationEntry`, serializes to a `notifications/notification.posted` envelope, and sends via the static `companion.multiplexer` slot. Cold-start buffer (`pendingEnvelopes`, capped at 50) preserves order before the multiplexer is wired. `isAccessGranted(context)` is a synchronous check against `Settings.Secure.enabled_notification_listeners`. |
|
||||
| `app/src/main/kotlin/.../notifications/NotificationModels.kt` | **Phase 3 / Wave 1 / notif-listener** — `@Serializable data class NotificationEntry(packageName, title, text, subText, postedAt, key)` with `kotlinx.serialization` `@SerialName` mappings to the snake_case Python wire format. |
|
||||
| `app/src/main/kotlin/.../ui/screens/NotificationCompanionSettingsScreen.kt` | **Phase 3 / Wave 1 / notif-listener** — Compose settings screen mirrored on `VoiceSettingsScreen`. Sections: About (plain-language explanation + revoke instructions), Status (live grant indicator via `LifecycleEventObserver` ON_RESUME re-check + "Open Android Settings" button), Test (pulls `service.activeNotifications` from the bound listener for end-to-end verification without requiring a relay round-trip). |
|
||||
| `app/src/main/kotlin/.../network/ChannelMultiplexer.kt` (Phase3-notif-listener additions) | `sendNotification(envelope)` — thin wrapper over `send()` for the `HermesNotificationCompanion` outbound path. Drops on the floor when no `sendCallback` is wired (relay disconnected) — the service owns the cold-start buffer in its own `pendingEnvelopes` queue. Marked with `// === PHASE3-notif-listener: notification outbound routing ===`. |
|
||||
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
|
||||
| **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()`; 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/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 |
|
||||
| `network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `data/FeatureFlags.kt` | Feature gating — DEV_MODE + DataStore overrides; `BuildFlavor` (googlePlay/sideload Tier flags) |
|
||||
| **App — Auth** | |
|
||||
| `auth/AuthManager.kt` | Wires SessionTokenStore + CertPinStore; parses auth.ok; `applyServerIssuedCodeAndReset()` |
|
||||
| `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 → 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()` |
|
||||
| `accessibility/ActionExecutor.kt` | Gesture/text dispatch via GestureDescription + ACTION_SET_TEXT; pressKey maps vocab only |
|
||||
| **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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| **Relay — Server** | |
|
||||
| `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`; 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 |
|
||||
| `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 |
|
||||
| **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-desktop.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 |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
- **Don't use XML layouts** — Compose only
|
||||
- **Don't use Gson** — kotlinx.serialization
|
||||
- **Don't use Ktor for networking** — OkHttp for WebSocket
|
||||
- **Don't build terminal or bridge channels yet** — Phase 2 and 3. Stubbed with `TODO`.
|
||||
- **Don't use plaintext WebSocket** — `wss://` only, even in development
|
||||
- **Don't put documentation in root** — long-form docs go in `docs/`
|
||||
- **Don't forget DEVLOG.md** — update it
|
||||
@@ -294,8 +299,6 @@ Two MCP servers are configured for AI-assisted development. See `docs/mcp-toolin
|
||||
| `android-tools-mcp` | IDE/Build — Compose previews, Gradle, code search, Android docs | Android Studio running with project open |
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
|
||||
Together they cover the full loop: code → preview → build → deploy → interact → screenshot.
|
||||
|
||||
## Dev Workflow
|
||||
|
||||
```bash
|
||||
@@ -308,163 +311,96 @@ scripts/dev.bat version # Show current version from libs.versions.toml
|
||||
scripts/dev.bat relay # Start relay server (dev mode, no SSL)
|
||||
```
|
||||
|
||||
Open repo root in Android Studio for Compose previews and device deployment.
|
||||
### Bridge smoke test (run on hermes-host, not local PC)
|
||||
|
||||
### Typical Dev Loop (Claude-driven edits + Bailey's local testing)
|
||||
```bash
|
||||
scripts/bridge-smoke.sh # full suite, destructive ON
|
||||
scripts/bridge-smoke.sh --no-destructive # read-only paths only
|
||||
scripts/bridge-smoke.sh --filter open_app # re-run a single test
|
||||
scripts/bridge-smoke.sh --pair ABCDEF # register pairing code first
|
||||
```
|
||||
|
||||
The standard flow when Claude is editing code and Bailey is testing against the live hermes-agent install on the LAN server:
|
||||
Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regression class (Python relay registers a route but Kotlin dispatcher's `when (path)` has no matching branch). Run after every relay restart.
|
||||
|
||||
1. **Edit locally** in the Windows checkout (`C:\Users\Bailey\Desktop\Open-Projects\hermes-android\`). Both Python plugin (`plugin/`) and Android app (`app/`) live here. All code edits, tests, and doc updates happen in this one tree.
|
||||
2. **Python changes** → run `python -m py_compile plugin/<file>.py` for quick syntax check. Full `pytest` / `unittest` runs happen on the server (the Windows env doesn't have the `aiohttp` + venv set up).
|
||||
3. **Kotlin changes** → **do NOT run `gradle build`**. Bailey builds and runs the app from Android Studio's green ▶ button directly onto the physical device. This is a hard convention — never `adb install`, never `gradle assembleDebug` from Claude. Rely on type checks, `grep` for obvious structural bugs, and let Android Studio catch compile errors on Bailey's next build attempt. Errors surface in his IDE and he pastes them back for fast fixes.
|
||||
4. **Commit + push to `origin/main`** via `git push origin main`. `main` is the deploy branch for this project. Feature branches are fine for longer work but the normal cycle is straight to main (SYSTEM.md convention: *"Git as handoff: Always commit + push before handing off between sessions"*).
|
||||
5. **Pull on the Linux server** where the live relay + hermes-agent run. The hermes-relay plugin is **editable-installed** (`pip install -e`) against the hermes-agent venv, so `git pull` inside the server clone is all it takes for Python code changes to take effect on the **next import** per process. See "Server Deployment" below for the exact paths and commands.
|
||||
6. **Restart running Python processes** if needed. Editable installs only take effect on fresh imports — any process that already imported the old code is holding it in memory. In practice this means:
|
||||
- `hermes-gateway.service` (user systemd) — restart via `systemctl --user restart hermes-gateway` when plugin *tool* code changes (e.g., `plugin/tools/android_tool.py`), because the gateway process imports and caches tools.
|
||||
- `hermes-relay.service` (user systemd, installed by `install.sh` step [6/6] as of 2026-04-12) — restart via `systemctl --user restart hermes-relay` when plugin *relay* code changes (files under `plugin/relay/`). The unit's Python entry point calls `plugin/relay/_env_bootstrap.py::load_hermes_env()` before importing the server module, so `~/.hermes/.env` values are re-read on every restart — no shell sourcing, no stale API keys. Logs: `journalctl --user -u hermes-relay -f`.
|
||||
- Plugin skill changes (files under `skills/`) — picked up **automatically** on every hermes-agent invocation via the `external_dirs` scan. No restart needed.
|
||||
7. **Run tests on the server** (the venv there has all dependencies). Prefer `python -m unittest plugin.tests.test_<name>` — `python -m pytest` sometimes chokes on a pre-existing `conftest.py` that imports the `responses` module (not installed). `unittest` bypasses the conftest entirely.
|
||||
8. **Test on the phone** — Bailey builds from Android Studio, installs to his Samsung device connected via USB (or the Studio device chooser), scans the pair QR from `/hermes-relay-pair` or `hermes-pair` on the server, and exercises the feature. He reports errors by pasting the Studio Logcat or the Kotlin compile error into the chat.
|
||||
### Typical Dev Loop
|
||||
|
||||
### Server Deployment (the "local instance" Bailey tests against)
|
||||
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. **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`.
|
||||
|
||||
The server is a Linux box on Bailey's LAN running hermes-agent with the hermes-relay plugin editable-installed. Canonical paths and commands (see `~/SYSTEM.md` on the server for the authoritative / sensitive details — host IP, user, services list):
|
||||
### Server Deployment
|
||||
|
||||
Server is a Linux box running hermes-agent with hermes-relay editable-installed (`pip install -e`). Sensitive details (IP, user, secrets) in `~/SYSTEM.md` on the server — not in this repo.
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| hermes-agent repo (upstream fork) | `~/.hermes/hermes-agent/` |
|
||||
| hermes-agent venv (python + all deps) | `~/.hermes/hermes-agent/venv/` |
|
||||
| hermes-relay clone (this repo) | `~/.hermes/hermes-relay/` |
|
||||
| hermes-agent repo | `~/.hermes/hermes-agent/` |
|
||||
| hermes-relay clone | `~/.hermes/hermes-relay/` |
|
||||
| Plugin symlink | `~/.hermes/plugins/hermes-relay` → `~/.hermes/hermes-relay/plugin` |
|
||||
| Editable install verification | `~/.hermes/hermes-agent/venv/bin/pip show hermes-relay` → `Editable project location: ~/.hermes/hermes-relay` |
|
||||
| Config (yaml + env) | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| QR-secret (HMAC pair signing) | `~/.hermes/hermes-relay-qr-secret` (32 bytes, 0o600, auto-created by `hermes-pair` first run) |
|
||||
| Relay log | `journalctl --user -u hermes-relay` (systemd user journal). Legacy `~/hermes-relay.log` is from the historical nohup era — no longer written to. |
|
||||
| `hermes-gateway.service` | User systemd unit; runs `python -m hermes_cli.main gateway run --replace` on port 8642 |
|
||||
| `hermes-relay.service` | User systemd unit (installed by `install.sh` step [6/6] as of 2026-04-12); runs `%h/.hermes/hermes-agent/venv/bin/python -m plugin.relay --no-ssl --log-level INFO` on port 8767. Template lives at `relay_server/hermes-relay.service` and uses `%h` for home-dir expansion so it's user-agnostic. **No `EnvironmentFile=`** — `plugin/relay/_env_bootstrap.py` loads `~/.hermes/.env` at Python import time, exactly like `hermes_cli/main.py` does for the gateway. |
|
||||
| Config | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| Relay log | `journalctl --user -u hermes-relay -f` |
|
||||
|
||||
**Standard update cycle on the server** (run manually via `ssh` — connection details in `~/SYSTEM.md` server-side):
|
||||
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
|
||||
|
||||
```bash
|
||||
# CANONICAL ONE-LINER (idempotent — covers everything below in one command):
|
||||
hermes-relay-update # if the shim is installed
|
||||
HERMES_RELAY_RESTART_GATEWAY=1 hermes-relay-update # opt into auto gateway restart
|
||||
|
||||
# Or via the curl pipe directly (same thing — the shim is just a wrapper):
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
|
||||
# IMPORTANT: when running over `ssh user@host '...'` non-interactively, use
|
||||
# `bash -c` to wrap the env var so it actually inherits to the bash subshell:
|
||||
ssh user@host 'bash -c "HERMES_RELAY_RESTART_GATEWAY=1 curl -fsSL .../install.sh | bash"'
|
||||
# (Naked `HERMES_RELAY_RESTART_GATEWAY=1 curl ... | bash` only sets the var
|
||||
# for curl, NOT for the bash subprocess that actually reads it. Confirmed
|
||||
# the hard way on 2026-04-12.)
|
||||
|
||||
# Manual breakdown if you need to do it piecewise:
|
||||
cd ~/.hermes/hermes-relay
|
||||
git pull --ff-only origin main
|
||||
|
||||
# If plugin tools changed (android_tool.py etc.):
|
||||
systemctl --user restart hermes-gateway
|
||||
|
||||
# If relay server code changed (plugin/relay/*.py):
|
||||
systemctl --user restart hermes-relay
|
||||
|
||||
# Verify health
|
||||
curl -s http://localhost:8767/health
|
||||
hermes-status # pretty version of the same + bridge state
|
||||
|
||||
# Verify the process has ~/.hermes/.env loaded (all expected keys as <set>):
|
||||
PID=$(systemctl --user show -p MainPID --value hermes-relay)
|
||||
cat /proc/$PID/environ | tr '\0' '\n' | grep -E 'VOICE_TOOLS_OPENAI_KEY|ELEVENLABS_API_KEY|ANTHROPIC_API_KEY' | sed 's/=.*/=<set>/'
|
||||
```
|
||||
|
||||
If you ever see a stray `python -m plugin.relay` in `pgrep` output that doesn't belong to the service (orphan from a pre-systemd manual launch), kill it first: `pkill -f "python -m plugin.relay" && systemctl --user restart hermes-relay`. The nohup-era workflow (`nohup … & disown`) is no longer the canonical path as of 2026-04-12 — see the DEVLOG entry for that date.
|
||||
|
||||
**Running tests on the server:**
|
||||
|
||||
```bash
|
||||
cd ~/.hermes/hermes-relay
|
||||
# Specific modules to skip the conftest.py that imports `responses`:
|
||||
~/.hermes/hermes-agent/venv/bin/python -m unittest \
|
||||
plugin.tests.test_qr_sign \
|
||||
plugin.tests.test_session_grants \
|
||||
plugin.tests.test_sessions_routes \
|
||||
plugin.tests.test_rate_limit_clear \
|
||||
plugin.tests.test_media_registry \
|
||||
plugin.tests.test_relay_media_routes
|
||||
```
|
||||
|
||||
**Important conventions:**
|
||||
|
||||
- **Never install APKs from Claude.** Bailey deploys via Android Studio's run button. No `adb install`, no gradle builds from the tool side.
|
||||
- **Never run `pytest` from Claude-side on the server** without the `unittest` escape above, unless you've first checked that `responses` is in the venv — the pre-existing conftest will fail the collector.
|
||||
- **Use `systemctl --user restart hermes-relay` to restart the relay.** The nohup + disown dance is historical — as of 2026-04-12 the relay runs as a user systemd unit and `_env_bootstrap.py` loads `.env` at Python import time. If SSH is exiting before the command completes, wrap the restart with `nohup systemctl --user restart hermes-relay </dev/null &`.
|
||||
- **Sensitive info lives in `~/SYSTEM.md` on the server and `~/.hermes/.env`**, NOT in this repo. Bailey's SSH user, IP, and secrets don't belong in `CLAUDE.md`. Claude reads the server's SYSTEM.md on first SSH if orientation is needed (`cat ~/SYSTEM.md`).
|
||||
- **The phone re-pairs after each relay restart** — in-memory `SessionManager` state is wiped on restart, so the phone's stored session token becomes stale. `/pairing/register` clears rate-limit blocks automatically (per ADR 15) so re-pair via `/hermes-relay-pair` (in-chat) or `hermes-pair` (shell shim) works immediately without waiting for a block to expire.
|
||||
**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
|
||||
- `_env_bootstrap.py` loads `~/.hermes/.env` on every relay start — no stale API keys
|
||||
|
||||
### Where Python vs. Kotlin changes land
|
||||
|
||||
| Change type | Who rebuilds/restarts? | How? |
|
||||
| Change type | Who restarts? | Command |
|
||||
|---|---|---|
|
||||
| Python plugin tool (e.g., `android_tool.py`) | `hermes-gateway.service` restart | `systemctl --user restart hermes-gateway` |
|
||||
| Python plugin relay (`plugin/relay/*.py`) | `hermes-relay.service` restart | `systemctl --user restart hermes-relay` (loads `~/.hermes/.env` on every start via `_env_bootstrap.py`) |
|
||||
| Python plugin pair CLI (`plugin/pair.py`, `plugin/cli.py`) | Next invocation picks up new code | No restart needed — fresh process per invocation |
|
||||
| Skill files (`skills/**/SKILL.md`) | Next hermes-agent invocation scans `external_dirs` | No restart needed |
|
||||
| Android app (`app/**`) | Bailey rebuilds in Android Studio | Studio run button → device |
|
||||
| Docs (`docs/`, `user-docs/`, `DEVLOG.md`, `CLAUDE.md`, `README.md`) | — | No runtime effect |
|
||||
| Plugin tool (`android_tool.py` etc.) | `hermes-gateway.service` | `systemctl --user restart hermes-gateway` |
|
||||
| Relay code (`plugin/relay/*.py`) | `hermes-relay.service` | `systemctl --user restart hermes-relay` |
|
||||
| Pair CLI / skill files | — | No restart — fresh process / scanned on invocation |
|
||||
| Android app | Bailey (Studio) | Studio run button |
|
||||
|
||||
### Release Process
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for the full release recipe — versioning conventions, keystore setup, Play Console upload (manual + automated via `gradle-play-publisher`), GitHub release workflow, and troubleshooting.
|
||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
Quick reference:
|
||||
- **Version source of truth**: `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`)
|
||||
- **SemVer with optional prereleases**: `v0.1.0`, `v0.1.1`, `v0.2.0-beta.1`, `v1.0.0-rc.1`
|
||||
- **`appVersionCode` is monotonic** — always increment, even across prereleases (Play Console rejects collisions)
|
||||
- **Build AAB locally**: `scripts/dev.bat bundle` → `app/build/outputs/bundle/<flavor>Release/hermes-relay-<version>-<flavor>-release.aab`. Product flavors nest outputs: `googlePlayRelease/hermes-relay-0.3.0-googlePlay-release.aab`, `sideloadRelease/hermes-relay-0.3.0-sideload-release.aab`. Every artifact is version-prefixed via `archivesName` in `app/build.gradle.kts`.
|
||||
- **Verify signing**: `keytool -list -printcert -jarfile <aab>` — must NOT show `CN=Android Debug`
|
||||
- **Cut a release**: bump version → commit → `git tag vMAJOR.MINOR.PATCH` → `git push origin <tag>` → CI builds APK + AAB and creates GitHub Release
|
||||
- **Required GitHub Secrets** for signed CI builds: `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD` (without these the workflow still runs but produces debug-signed artifacts that Play Console will reject)
|
||||
- **Optional automated upload**: `gradlew publishReleaseBundle --track=internal` (requires `play-service-account.json` at repo root, gitignored)
|
||||
- **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
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Surface | Standard Endpoint | Non-Standard Fallback |
|
||||
|---------|-------------------|----------------------|
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` (structured tool events) | `POST /api/sessions/{id}/chat/stream` (inline tool text) |
|
||||
| Chat (OpenAI compat) | `POST /v1/chat/completions` (stream=true) | — |
|
||||
| Session CRUD | `X-Hermes-Session-Id` header on `/v1/chat/completions` | `GET/POST/PATCH/DELETE /api/sessions` (non-standard) |
|
||||
| Personalities | Read from `~/.hermes/config.yaml` | `GET /api/config` (non-standard) |
|
||||
| Server skills | — | `GET /api/skills` (non-standard) |
|
||||
| Health check | `GET /health` or `GET /v1/health` | — |
|
||||
| Models | `GET /v1/models` | — |
|
||||
| Plugin tools | `android_*` via `plugin/` | — |
|
||||
| Relay health | `GET /health` on `plugin/relay/server.py` (default `:8767`) | — |
|
||||
| Relay pairing (QR flow) | `POST /pairing/register` (loopback only) — driven by `/hermes-relay-pair` skill or `hermes-pair` shell shim | — |
|
||||
| QR payload schema | `HermesPairingPayload` (see `plugin/pair.py` + `QrPairingScanner.kt`) — top-level API fields + optional `relay: { url, code }` block | Old API-only QRs still parse (relay field is nullable + `ignoreUnknownKeys = true`) |
|
||||
| Inbound media register | `POST /media/register` on the relay (loopback only) — called by host-local tools via `plugin/relay/client.py::register_media()` | Tool falls back to bare `MEDIA:<path>` text + `⚠️ Image unavailable` placeholder if the relay isn't reachable |
|
||||
| Inbound media fetch (token) | `GET /media/{token}` on the relay — `Authorization: Bearer <session_token>` (same token WSS uses) | — |
|
||||
| Inbound media fetch (path) | `GET /media/by-path?path=<abs>` on the relay — same bearer auth. **Permissive by default since 2026-04-11**: serves any absolute readable file under the size cap, regardless of `allowed_roots`. Opt in to strict-mode allowlist enforcement via `RELAY_MEDIA_STRICT_SANDBOX=1`. Rationale: the LLM already has filesystem-reading tools, so the allowlist was defense-in-depth that mostly manifested as false positives ("Path not allowed" cards for legitimate `~/projects/foo/readme.png` fetches). Used for LLM-emitted `MEDIA:/abs/path` markers (upstream `agent/prompt_builder.py` instructs the LLM to emit this form). | — |
|
||||
| Inbound media marker (tool) | `MEDIA:hermes-relay://<token>` — emitted by host-local tools that called `register_media()` via loopback | — |
|
||||
| Inbound media marker (LLM) | `MEDIA:/abs/path.ext` — emitted by the LLM directly per the upstream system prompt. Phone fetches via `/media/by-path`, falls back to `⚠️ Image unavailable` on any failure | — |
|
||||
| Paired devices list | `GET /sessions` on the relay — `Authorization: Bearer <session_token>`. Returns all active sessions with metadata (device name, token_prefix, expires_at, grants, transport_hint, is_current). Full tokens are NEVER included. Phone reads this for the Paired Devices screen. | — |
|
||||
| Device revocation | `DELETE /sessions/{token_prefix}` on the relay — bearer-auth'd, matches on first-4+ chars. 200 on exact, 404 on zero, 409 on ambiguous. Self-revoke flagged via `revoked_self: true`. | — |
|
||||
| Session TTL/grant update | `PATCH /sessions/{token_prefix}` on the relay — bearer-auth'd. Body `{ttl_seconds?, grants?}`. TTL restarts the clock from now; omitted grants re-clamp automatically to the new session lifetime. Backs the Android Paired Devices "Extend" button via `RelayHttpClient.extendSession` and `ConnectionViewModel.extendDevice`. | — |
|
||||
| Pairing metadata | `POST /pairing/register` body now accepts optional `ttl_seconds` / `grants` / `transport_hint`. Host metadata wins over phone-sent values. Clears all rate-limit blocks on success so re-pair isn't blocked by stale failures. | — |
|
||||
| Bidirectional pairing (Phase 3) | `POST /pairing/approve` — loopback-only stub, same wire shape as `/pairing/register`. Real UX tied to Phase 3 bridge implementation. | — |
|
||||
| QR payload v2 | `HermesPairingPayload.hermes = 2` when any v2 field present. `relay.ttl_seconds` (0 = never), `relay.grants` (seconds-from-now, clamped to session), `relay.transport_hint` (`"wss"`/`"ws"`), top-level `sig` (HMAC-SHA256 over canonicalized payload via `plugin/relay/qr_sign.py`). Phone parses + stores `sig` but does NOT verify it yet (secret distribution TBD). | Old v1 QRs (no `hermes` field or `hermes: 1`) still parse via `ignoreUnknownKeys = true`. |
|
||||
| Voice transcribe | `POST /voice/transcribe` on the relay — `multipart/form-data` with audio file, bearer-auth'd. Response: `{"text": "...", "provider": "openai", "success": true}`. Relay saves to temp file → calls `tools.transcription_tools.transcribe_audio` (sync, wrapped in `asyncio.to_thread`) → unlinks temp. Upstream reads `stt:` section from `~/.hermes/config.yaml` internally. | — |
|
||||
| Voice synthesize | `POST /voice/synthesize` on the relay — JSON body `{"text": "..."}`, bearer-auth'd, max 5000 chars. Response: `audio/mpeg` file (full mp3, not chunked). Relay calls `tools.tts_tool.text_to_speech_tool` (sync, wrapped in `asyncio.to_thread`) which writes to `~/voice-memos/tts_<ts>.mp3` and returns JSON with the path → relay serves via `web.FileResponse`. Phone caches bytes to `cacheDir/voice_tts_<ts>.mp3`. Upstream reads `tts:` section from `~/.hermes/config.yaml` internally. Client-side sentence-boundary chunking is what makes playback feel streaming — server returns whole files per sentence. | — |
|
||||
| Voice config | `GET /voice/config` on the relay — bearer-auth'd. Returns `{"tts": {provider, voice?, model?}, "stt": {provider, enabled, language?}}` so Voice Settings can show current provider + read-only voice/model. Reads `_load_tts_config()` / `_load_stt_config()` + `check_voice_requirements()` — private upstream helpers, clearly marked as such in the handler. | — |
|
||||
| Auth envelope (pairing mode) | `{pairing_code, ttl_seconds?, grants?, device_name, device_id}` — phone adds ttl/grants from the TTL picker dialog. | — |
|
||||
| Auth envelope (session mode) | `{session_token, device_name, device_id}` — ttl/grants NOT re-sent; server keeps the grant table keyed on the original pair. | — |
|
||||
| `auth.ok` payload | `{session_token, server_version, profiles, expires_at, grants, transport_hint}` — new fields: expires_at as epoch (null = never), grants as `{channel: epoch\|null}`, transport_hint as `"wss"`/`"ws"`/`"unknown"`. | Old phones without v2 parser ignore the unknown fields. |
|
||||
| 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 (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; 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
When working on features that interface with hermes-agent, consult these source files directly:
|
||||
|
||||
| Topic | Upstream File |
|
||||
|-------|--------------|
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Contributing to Hermes-Relay
|
||||
|
||||
Thanks for your interest in contributing! Hermes-Relay is an indie, open-source project and every contribution — code, bug reports, docs tweaks, feature ideas — genuinely shapes where it goes next.
|
||||
|
||||
This guide covers the developer setup. For the release recipe see [RELEASE.md](RELEASE.md); for architecture context see [docs/spec.md](docs/spec.md) and [docs/decisions.md](docs/decisions.md).
|
||||
|
||||
## Quick Start (Android)
|
||||
|
||||
1. **File > Open** the repo root in Android Studio
|
||||
2. Wait for Gradle sync
|
||||
3. **Run** (Shift+F10) to deploy to emulator or device
|
||||
|
||||
That's it — no extra setup or credentials required for a debug build.
|
||||
|
||||
## Dev Scripts
|
||||
|
||||
Helper scripts for common development tasks:
|
||||
|
||||
```bash
|
||||
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)
|
||||
```
|
||||
|
||||
Linux/macOS equivalent lives at `scripts/dev.sh`.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── plugin/ # Hermes agent plugin + relay server (Python + aiohttp)
|
||||
│ ├── relay/ # Canonical relay server (channels, auth, media, voice)
|
||||
│ ├── tools/ # android_* tool implementations
|
||||
│ └── pair.py # QR pairing CLI
|
||||
├── skills/ # Hermes agent skills (pair, self-setup)
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, architecture decisions, security notes
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines
|
||||
└── gradle/ # Wrapper + version catalog
|
||||
```
|
||||
|
||||
The legacy `relay_server/` directory is a thin compatibility shim around `plugin.relay` that keeps the `python -m relay_server` entry point working.
|
||||
|
||||
## 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, signed APK artifacts) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
## Running the Relay Locally
|
||||
|
||||
Only needed if you're working on the bridge, voice, notifications, or media features. Chat alone doesn't need the relay.
|
||||
|
||||
```bash
|
||||
# From the hermes-agent venv (if you installed via the one-liner):
|
||||
hermes relay start --no-ssl
|
||||
|
||||
# Or from a repo checkout:
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
|
||||
See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, Docker, and full configuration.
|
||||
|
||||
## Plugin Development
|
||||
|
||||
End users should install via the one-liner in the README. For local development from a clone:
|
||||
|
||||
```bash
|
||||
# One-shot copy:
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
|
||||
# Or symlink for live edits:
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
After the plugin is in place, restart hermes and verify pairing with `hermes-pair` (shell shim) or `/hermes-relay-pair` in any Hermes chat surface. The 18 `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.
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
|
||||
|
||||
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
|
||||
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a `release: vX.Y.Z` PR merges `dev` → `main` with `--no-ff`. The tag is cut from `main` after the merge. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into two path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes) and `.github/workflows/ci-relay.yml` (syntax check + unittest discover on plugin/Python changes). Both run on pushes to `main` and `dev` and on PRs targeting either.
|
||||
|
||||
## Questions?
|
||||
|
||||
- **Architecture context?** [docs/spec.md](docs/spec.md) covers protocols, UI layouts, and the channel model. [docs/decisions.md](docs/decisions.md) covers the forks in the road and why we picked what we did.
|
||||
- **Something unclear?** [Open an issue](https://github.com/Codename-11/hermes-relay/issues/new) — we read every one, and "this contributing guide is confusing" is a completely fair bug report.
|
||||
@@ -5,13 +5,15 @@
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
|
||||
<p align="center">
|
||||
Native Android client for the Hermes agent platform.<br>
|
||||
Chat, control, and connect — one app for your AI agent.
|
||||
<strong>One Hermes agent. Two ways to use it.</strong><br>
|
||||
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
|
||||
use a server-deployed Hermes from your laptop as if it were running locally.
|
||||
</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://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI-orange.svg" alt="Desktop CLI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
@@ -29,14 +31,25 @@
|
||||
|
||||
---
|
||||
|
||||
## Two surfaces, one pair
|
||||
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop CLI](#1b-desktop-cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local** — same shell, same TUI, same `Win+Shift+S` → `Ctrl+A v` paste flow, same conversation continuity. The remote agent can also reach back through the relay and run tools on YOUR machine. | **Experimental** — `desktop-v0.3.0-alpha.14` (one-binary install, no Node required) |
|
||||
|
||||
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Two steps: install the Android app on your phone, then install the plugin on your Hermes server.
|
||||
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
|
||||
|
||||
### 1. Install the Android app
|
||||
### 1a. Android app
|
||||
|
||||
<!-- TODO: Uncomment when Play Store listing is live
|
||||
<a href="https://play.google.com/store/apps/details?id=com.hermesandroid.relay"><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 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>
|
||||
-->
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
@@ -53,6 +66,41 @@ Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
|
||||
|
||||
### 1b. Desktop CLI (experimental)
|
||||
|
||||
A single-binary thin client (`hermes-relay`) that talks to a server-deployed Hermes over WSS — same shell, same TUI, same `/paste` flow as a local install. The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
|
||||
|
||||
**Install** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay # interactive Hermes TUI in tmux
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
hermes-relay daemon # headless tool router (agent reaches you anytime)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
|
||||
|
||||
**No Node required** — Bun-compiled native binaries (~60–110 MB per platform) installed via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias, self-update via `hermes-relay update`. Binaries are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected; the install scripts show the one-line escape hatches. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
|
||||
|
||||
- **Docs**: [Desktop CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
@@ -61,35 +109,37 @@ On the machine running your Hermes agent:
|
||||
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 installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
|
||||
|
||||
- **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 any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. 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`.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
|
||||
|
||||
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.
|
||||
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
|
||||
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
|
||||
|
||||
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
|
||||
|
||||
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26), [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+.
|
||||
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
|
||||
|
||||
### For AI Agents
|
||||
|
||||
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
|
||||
|
||||
```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 desktop 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`
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
@@ -98,56 +148,98 @@ Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes
|
||||
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — all native on Android.
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
|
||||
|
||||
| Channel | What | Status |
|
||||
|---------|------|--------|
|
||||
| **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| **Bridge** | Agent controls the phone — taps, types, screenshots | Phase 3 |
|
||||
| Surface | Channel | What | Status |
|
||||
|---------|---------|------|--------|
|
||||
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
|
||||
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
|
||||
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
|
||||
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
|
||||
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
|
||||
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
|
||||
|
||||
## What's new in v0.6.0
|
||||
|
||||
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
|
||||
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
|
||||
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
|
||||
|
||||
See the [changelog](CHANGELOG.md) for the full list.
|
||||
|
||||
## Features
|
||||
|
||||
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering
|
||||
- **Voice mode** — Real-time voice conversation via the relay; sphere listens with you, 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)
|
||||
- **Smooth auto-scroll** — Live-follow streaming responses with a "scrolled up to read" pause/resume gesture
|
||||
- **Session management** — Create, switch, rename, delete chat sessions
|
||||
- **Tool visualization** — See agent tool calls as they execute (compact or detailed cards)
|
||||
- **Personalities** — Switch between agent personalities with a picker
|
||||
- **Slash commands** — 29+ gateway commands, searchable command palette
|
||||
- **File attachments** — Send images, documents, any file type
|
||||
- **Message queuing** — Send messages while the agent is still streaming
|
||||
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health
|
||||
- **Security** — Encrypted local storage (AES-256-GCM), HTTPS enforced
|
||||
- **QR pairing** — Scan a QR code to auto-configure your server connection
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
|
||||
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
|
||||
- **Voice mode** — 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.
|
||||
|
||||
### Desktop CLI
|
||||
|
||||
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
|
||||
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
|
||||
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval.
|
||||
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
|
||||
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
|
||||
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
|
||||
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
|
||||
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
|
||||
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install the app** from the link above
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding
|
||||
**Android:**
|
||||
|
||||
1. **Install the app** from the [link above](#1a-android-app)
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
**Desktop CLI:**
|
||||
|
||||
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
|
||||
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
|
||||
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) --> Relay Server (:8767) [terminal, bridge — future]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Relay Server (:8767) [voice routes — API key or relay session]
|
||||
Phone (WSS/HTTP) --> Relay Server (:8767) [terminal, bridge, media, sessions]
|
||||
Desktop CLI (WSS) --> Relay Server (:8767) [tui, terminal, desktop tools]
|
||||
```
|
||||
|
||||
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).
|
||||
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
|
||||
|
||||
## 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 |
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, pairing, subcommands, local tool routing |
|
||||
| [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 |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `v*` and desktop `desktop-v*`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -176,15 +268,21 @@ scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp)
|
||||
├── plugin/ # Hermes agent plugin (14 android_* tools + pair module)
|
||||
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/ # Hermes agent skills
|
||||
│ └── devops/
|
||||
│ └── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
├── user-docs/ # VitePress documentation site
|
||||
│ ├── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
│ ├── hermes-relay-self-setup/ # AI-agent setup recipe (Android + desktop)
|
||||
│ └── hermes-relay-desktop-setup/ # AI-agent recipe specifically for the desktop CLI
|
||||
├── user-docs/ # VitePress documentation site (Android + desktop sections)
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-relay / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
@@ -193,13 +291,14 @@ hermes-relay/
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact) |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
### Relay Server (optional — terminal/bridge only)
|
||||
### Relay Server (optional — bridge, terminal, TUI, media, and voice routes)
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
@@ -225,7 +324,7 @@ cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 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` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
|
||||
## Hermes Agent
|
||||
|
||||
@@ -233,7 +332,7 @@ Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-
|
||||
|
||||
## 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" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+152
-49
@@ -71,9 +71,27 @@ you the next steps. It deliberately does NOT commit, tag, or touch
|
||||
|
||||
## 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
|
||||
`release: vX.Y.Z` 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 +102,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 +122,31 @@ 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.
|
||||
|
||||
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.
|
||||
The version-bump commit lives on `dev`, created via
|
||||
`scripts/bump-version.sh`, as the last commit of the release-prep work.
|
||||
It's a dedicated commit with the message `release: vX.Y.Z` that also
|
||||
lands the CHANGELOG and RELEASE_NOTES updates. A release PR then merges
|
||||
`dev` → `main` with `--no-ff`, and the `v<version>` 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 + Relay) 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
|
||||
|
||||
@@ -183,17 +204,40 @@ file afterward.
|
||||
|
||||
### 2. Google Play Console developer account
|
||||
|
||||
Hermes-Relay ships under the **Axiom-Labs, LLC** Play Console account
|
||||
(D-U-N-S verified organization). The applicationId is
|
||||
`com.axiomlabs.hermesrelay` (googlePlay flavor) and
|
||||
`com.axiomlabs.hermesrelay.sideload` (sideload flavor — not shipped through
|
||||
Play at all). The Kotlin namespace / source tree stays at
|
||||
`com.hermesandroid.relay` for historical reasons; see `app/build.gradle.kts`
|
||||
for the decoupling rationale.
|
||||
|
||||
If you're setting up a fresh account (for a fork or a new downstream):
|
||||
|
||||
1. Register at <https://play.google.com/console/signup> ($25 one-time fee).
|
||||
2. Complete identity verification (personal accounts need a government ID;
|
||||
organization accounts need a D-U-N-S number).
|
||||
3. Create the app listing: name, language, free/paid, declarations.
|
||||
|
||||
**New personal accounts only**: Google requires an app to run in
|
||||
**closed testing** with **at least 12 opted-in testers** for **14
|
||||
continuous days** before it can be promoted to production. Internal testing
|
||||
does NOT satisfy this requirement — only the closed testing track starts
|
||||
the 14-day clock. Organization (D-U-N-S) accounts are exempt. See
|
||||
[Google's policy](https://support.google.com/googleplay/android-developer/answer/14151465).
|
||||
**The 14-day closed-testing rule does NOT apply to Hermes-Relay.** Google
|
||||
requires *new personal* developer accounts to run an app in closed testing
|
||||
with ≥12 opted-in testers for 14 continuous days before promotion to
|
||||
production. Organization accounts with a verified D-U-N-S number are exempt
|
||||
from this policy, and Axiom-Labs is a D-U-N-S-verified org account. See
|
||||
[Google's policy](https://support.google.com/googleplay/android-developer/answer/14151465)
|
||||
for the full text.
|
||||
|
||||
> **Historical note (2026-04-13 migration):** v0.1.x through v0.3.0 shipped
|
||||
> on Internal testing under Bailey's personal Play Console account with
|
||||
> applicationId `com.hermesandroid.relay`. That listing was retired as part
|
||||
> of the org-account migration. Play Store package names are permanently
|
||||
> reserved once used — `com.hermesandroid.relay` can never be reclaimed —
|
||||
> so all releases from v0.3.1 onwards ship fresh under the new
|
||||
> `com.axiomlabs.hermesrelay` listing. The upload keystore identity is
|
||||
> unchanged (same `CN=Bailey Dixon, Codename-11` cert, same SHA256
|
||||
> fingerprint), so existing GitHub Secrets and the CI signing flow need no
|
||||
> changes. Google Play App Signing mints a new server-side app signing key
|
||||
> per listing — that's invisible to us since App Signing is enabled.
|
||||
|
||||
### 3. Play Developer API service account (optional)
|
||||
|
||||
@@ -223,6 +267,31 @@ 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** (`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)
|
||||
@@ -247,15 +316,31 @@ three carrying the new version string.
|
||||
|
||||
### 2. Update release notes and changelog
|
||||
|
||||
- `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.
|
||||
|
||||
### 3. Build and verify locally
|
||||
|
||||
@@ -278,18 +363,27 @@ 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 `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 add gradle/libs.versions.toml pyproject.toml plugin/relay/__init__.py \
|
||||
RELEASE_NOTES.md CHANGELOG.md \
|
||||
app/src/main/assets/whats_new.txt docs/play-store-listing.md
|
||||
git commit -m "release: v0.3.0"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag v0.3.0
|
||||
git push origin v0.3.0
|
||||
```
|
||||
@@ -311,8 +405,9 @@ run under the **Actions** tab.
|
||||
Release assets (for example, `hermes-relay-0.3.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** (or **Closed
|
||||
testing** for the 14-day clock).
|
||||
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).
|
||||
3. **Create new release** > upload the AAB.
|
||||
4. Paste `RELEASE_NOTES.md` into the release notes field.
|
||||
5. **Review release** > **Start rollout.**
|
||||
@@ -340,8 +435,9 @@ gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
|
||||
Typical path:
|
||||
|
||||
1. **Internal testing** — personal smoke test (no tester or time minimum)
|
||||
2. **Closed testing (alpha)** — starts the 14-day clock for new personal
|
||||
accounts; needs at least 12 opted-in testers
|
||||
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
|
||||
|
||||
@@ -394,16 +490,23 @@ On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
## Hotfix Recipe
|
||||
|
||||
When production has a bug and you need to ship a fix without picking up
|
||||
unrelated `main` changes:
|
||||
unreleased work from `dev`:
|
||||
|
||||
1. `git checkout -b fix/short-name v0.1.0` — branch from the released tag.
|
||||
1. `git checkout -b fix/short-name v0.1.0` — branch from the released
|
||||
tag (not from `main` or `dev`).
|
||||
2. Apply the fix, add a test, commit.
|
||||
3. Bump `appVersionName` and `appVersionCode` in
|
||||
`gradle/libs.versions.toml`.
|
||||
`gradle/libs.versions.toml` (and the other two version sources via
|
||||
`scripts/bump-version.sh`).
|
||||
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.
|
||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
||||
6. `git tag v0.1.1` from the new `main` tip and `git push origin v0.1.1`
|
||||
— 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 version bumps. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next release
|
||||
bump collides.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+39
-145
@@ -1,170 +1,64 @@
|
||||
# Hermes-Relay v0.3.0
|
||||
# Hermes-Relay v0.6.1
|
||||
|
||||
**Release Date:** April 13, 2026
|
||||
**Since v0.2.0:** 56 commits · 8 Phase 3 feature merges · 2 new build flavors · 1 new agent-control channel
|
||||
**Release Date:** May 6, 2026
|
||||
**Since v0.6.0:** API-key voice auth, durable pairing/session recovery, route failover hardening, dashboard pairing UI polish, and full Android bridge media/MMS handoff support.
|
||||
|
||||
> **Phase 3 — Bridge.** 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.
|
||||
v0.6.1 is a compatibility and bridge-contract release. It keeps the v0.6.0 multi-connection model, then tightens the real-world paths we just exercised: LAN/Tailscale pairing, API-key voice mode, stale session recovery, and Android phone bridge tools.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Download
|
||||
## Download
|
||||
|
||||
v0.3.0 ships in **two build flavors**. APK filenames are version-tagged, so every file carries its release number:
|
||||
v0.6.1 ships in two 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 Phase 3 stack — bridge channel, voice-to-bridge intents (Tier 3), vision-driven `android_navigate` (Tier 4). 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 Tier 1+2+5 feature set (chat, voice, safety rails) 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. |
|
||||
| sideload | `hermes-relay-0.6.1-sideload-release.apk` | Recommended for full bridge/device-control features, including share/MMS/file handoff. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| Google Play | `hermes-relay-0.6.1-googlePlay-release.aab` | Conservative Play-track build for chat and voice without sideload-only bridge-control surfaces. |
|
||||
| googlePlay APK | `hermes-relay-0.6.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.6.1-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 Phase 3 stack and installs with a `.sideload` applicationId suffix so both can coexist on the same device — the sideload launcher is labelled **"Hermes Dev"** for disambiguation.
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for install steps.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
## Highlights
|
||||
|
||||
- **Phase 3 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 → Tier 5 safety rails.
|
||||
### Voice auth and pairing durability
|
||||
|
||||
- **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`.
|
||||
- Voice routes now accept either Relay session voice grants or a saved Hermes API bearer token for `/voice/config`, `/voice/transcribe`, and `/voice/synthesize`.
|
||||
- Chat and voice can work from manual API URL + API key setup without a full QR pairing. Bridge, terminal, Android control, media, clipboard, and pair-session features still use the Relay session path.
|
||||
- Relay sessions can recover through trusted device refresh after server restarts/updates, reducing forced re-pairing.
|
||||
- Pairing and endpoint resolution were hardened for LAN/Tailscale transitions, including route normalization and clearer VPN fallback behavior.
|
||||
|
||||
- **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 / scroll / back / home.
|
||||
### Android bridge media, files, and MMS handoff
|
||||
|
||||
- **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.
|
||||
- Added `android_share_media` for text, files, relay media tokens, screenshots, and attachment lists through Android's native share sheet.
|
||||
- Added `android_send_mms` for MMS compose/share handoff with body text and attachments.
|
||||
- Added the missing `/return_to_hermes` relay route and `android_return_to_hermes` tool so docs, tools, relay HTTP, and the phone command contract match.
|
||||
- `android_send_sms` remains text-only and now returns structured states such as `sent`, `blocked`, `awaiting_confirmation`, `timeout`, and `failed`.
|
||||
- The active plugin import now uses `plugin.tools.android_tool` as the single source of truth, preventing Hermes tool exposure drift.
|
||||
|
||||
- **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.
|
||||
### Dashboard and operator flow
|
||||
|
||||
- **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.
|
||||
|
||||
- **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**.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
- Dashboard-minted pairing QRs and the QR modal styling were corrected so the plugin owns its dialog layout instead of inheriting host dashboard card styling.
|
||||
- The relay CLI can toggle insecure LAN API-key voice auth at runtime with `hermes relay insecure-api-key status|on|off`.
|
||||
- Docs now spell out the bridge HTTP routes, SMS schema, `current_app` limitations, share/MMS handoff behavior, sideload-only restrictions, and direct HTTP fallback route contract.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Phase 3 Bridge Channel
|
||||
## Verification
|
||||
|
||||
The headline feature. Everything in this section is gated behind the five-stage safety system documented above.
|
||||
- Relay/tool regression slice: `134 passed`
|
||||
- Android Kotlin compile: `:app:compileSideloadDebugKotlin` passed
|
||||
- Android Kotlin compile: `:app:compileGooglePlayDebugKotlin` passed
|
||||
- Dashboard build passed before deployment
|
||||
- Remote staging deploy restarted `hermes-relay` and `hermes-gateway`, both active
|
||||
|
||||
### 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
|
||||
## Post-install smoke
|
||||
|
||||
### Tier 5 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
|
||||
- 14 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`
|
||||
- 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`** — Phase 3 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). Agent-team branches (Phase 3 α–θ) were implemented by Claude Code working on isolated feature branches with `--no-ff` merges — each branch name encodes which agent shipped it.
|
||||
|
||||
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.
|
||||
- Install the sideload APK over the existing sideload app with `adb install -r`.
|
||||
- Existing pairing should survive a normal same-flavor update. Re-pair only if you uninstall app data, switch flavor/applicationId, revoke the device, or the server session store was intentionally cleared.
|
||||
- Confirm chat works over the selected route, then test voice with the saved Hermes API key.
|
||||
- For bridge media: try `android_share_media` or `POST /share_media` with a relay media token or host file path. The phone should show an on-device confirmation and then Android's native share UI.
|
||||
- For MMS: `android_send_mms` opens the composer with the attachment; it does not silently send MMS.
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# Hermes-Relay Roadmap
|
||||
|
||||
> Where Hermes-Relay is headed. Short, high-level, grouped by release milestone. For detailed implementation plans of active work see [`docs/plans/`](docs/plans/); for shipped work see [`CHANGELOG.md`](CHANGELOG.md); for the session-by-session narrative see [`DEVLOG.md`](DEVLOG.md).
|
||||
|
||||
## Vision
|
||||
|
||||
Native Android companion for the [Hermes agent platform](https://github.com/NousResearch/hermes-agent) — chat, voice, and full phone control in one app. We're building toward a world where your AI agent has safe, graceful hands on your phone for the tasks where that matters most: messaging, navigation, music, day-to-day automation, and anything else that's currently a tap-through chore.
|
||||
|
||||
## Shipped
|
||||
|
||||
- **v0.3.0** — Bridge channel (sideload), voice mode, notification companion, two build flavors, full safety rails system. [CHANGELOG](CHANGELOG.md#030---2026-04-13)
|
||||
- **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: `desktop-v*` (separate cadence from Android `v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
|
||||
**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 — experimental desktop computer-use MVP:** Plan at [`docs/plans/desktop-computer-use-mvp.md`](docs/plans/desktop-computer-use-mvp.md). Current slice extends the existing desktop tool channel with `desktop_computer_*` contracts for status, screenshot observe mode, grant request/cancel, and Windows-only action execution behind desktop-tool consent and a visible, task-scoped grant approval. It does not implement unrestricted or silent mouse/keyboard automation and does not depend on npm publication.
|
||||
|
||||
**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.
|
||||
- **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 today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls GitHub Releases API (`/repos/Codename-11/hermes-relay/releases/latest`), compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **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-desktop.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.
|
||||
|
||||
## Next — v0.4: Bridge feature expansion
|
||||
|
||||
Detailed plan: [`docs/plans/2026-04-13-bridge-feature-expansion.md`](docs/plans/2026-04-13-bridge-feature-expansion.md).
|
||||
|
||||
Expands the bridge channel's tool surface substantially, ports reliability patterns from the broader Hermes-Android ecosystem, and ships a per-app playbook skill so the agent has ready-made procedures for common apps out of the box.
|
||||
|
||||
**Core gestures.** Long press, drag, and pinch — foundational interactions currently missing from the toolkit.
|
||||
|
||||
**Screen efficiency.** Lightweight screen hashing + diff for cheap change detection in navigation loops; targeted node search (`find_nodes`) to avoid dumping full accessibility trees; detailed node introspection (`describe_node`) for richer LLM context.
|
||||
|
||||
**System integration.** Clipboard bridge (read + write), system-wide media playback control (play / pause / next / previous), sequential macro execution for batched workflows.
|
||||
|
||||
**Reliability.** Wake-lock wrapping on gesture-dispatching actions, three-tier `tap_text` fallback cascade for apps that wrap clickable content in non-clickable views, multi-window accessibility tree traversal.
|
||||
|
||||
**Per-app playbook skill.** `skills/android/SKILL.md` gives the LLM ready-made step-by-step procedures for Uber, WhatsApp, Spotify, Maps, Settings, and Tinder — plus a hard "do not loop" rule for bounded tool-call budgets.
|
||||
|
||||
**Sideload-only additions.** Location (`ACCESS_FINE_LOCATION`), contact search (`READ_CONTACTS`), direct SMS (`SEND_SMS`), and direct-dial calling (`CALL_PHONE`). All gated behind the existing sideload flavor to preserve Play Store policy compliance on the `googlePlay` track.
|
||||
|
||||
## Next-next — v0.4.1: Bridge fast-follows
|
||||
|
||||
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.~~ **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~~ — 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:
|
||||
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**What the middleware can do (near-term, ships via install.sh).** New aiohttp middleware in `hermes_relay_bootstrap/_command_middleware.py`, installed at the same `_PatchedApplication.__setitem__` hook as the current route injection so it lands before `AppRunner.setup()` freezes the app. Filters by `request.path in ("/v1/runs", "/v1/chat/completions")` — zero-cost fast path for everything else. On chat paths: parses the body, lazy-imports `GATEWAY_KNOWN_COMMANDS` + `resolve_command()` + `gateway_help_lines()` from `hermes_cli.commands`, and splits on command type:
|
||||
- **Stateless commands** (`/help`, `/commands`, and any others the upstream Option B PR ends up supporting without router state) — actually dispatch, emit a synthetic SSE stream matching the runs handler's existing event shape so the Android client at `HermesApiClient.kt:655-715` renders it as a normal assistant turn.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (post-PR-#8556) or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
|
||||
**On no match** (unknown command, cli-only command, or plain text): falls through to `handler(request)` unchanged. Fork-detects the same way the existing injection does — if the upstream preprocessor PR lands first, the middleware no-ops.
|
||||
|
||||
**Ceiling.** This middleware can **never** make `/model` actually switch models on `/v1/runs`, because there is no persistent session on that endpoint to switch. That's a Phase 2 follow-up (below), not a flaw in the middleware.
|
||||
|
||||
**Files.** New `hermes_relay_bootstrap/_command_middleware.py` (~150 LOC), one-line append in `_patch.py` inside `_maybe_register_routes`, stdlib `unittest` coverage in `plugin/tests/test_bootstrap_command_middleware.py` mirroring the existing `test_bootstrap_patch.py` harness. Mirrors the upstream Option B PR exactly so the two can be reviewed side-by-side.
|
||||
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (post PR #8556).** Once PR #8556 merges and `/api/sessions/{id}/chat/stream` ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`. Blocked on #8556 landing.
|
||||
|
||||
## Future — v0.5+
|
||||
|
||||
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)
|
||||
- Short MP4 screen recording for visual bug reports and "show me what happens when you tap this" flows
|
||||
- Annotated failure screenshots — auto-capture a screenshot with the intended target highlighted when a tap or wait fails, so the LLM can self-correct with visual context
|
||||
- Generalized loop guardrails across all bridge tools — extend `android_navigate`'s `max_iterations` cap into a per-session rolling counter covering every bridge tool call
|
||||
- Raw Intent / Broadcast escape hatch for power workflows
|
||||
|
||||
### Automation & triggers
|
||||
- **Scheduled automations** — "every weekday at 7am, open Maps, check my commute, report the time." Wires bridge commands to hermes-agent's existing cron tool
|
||||
- **Event-triggered actions** — "when a notification from X arrives, do Y." Reactive rule engine on top of the notification companion + accessibility event stream
|
||||
- **User-recorded macros** — watch a workflow once, replay it on demand via `android_macro`
|
||||
- **Multi-phone pairing UX** — explicit "add another device" flow, per-device routing for tool calls
|
||||
- **Phone → server file transfer** — reverse direction of the existing inbound media pipeline ("fetch my latest photo")
|
||||
|
||||
### Voice assistant
|
||||
- Always-listening wake-word mode (Porcupine or equivalent), off-by-default with explicit opt-in
|
||||
- Phone call handling — agent answers incoming calls, speaks via TTS, transcribes incoming audio, takes messages ("answer my phone, take a message, tell them I'll call back")
|
||||
|
||||
### Research horizon
|
||||
- **On-device local model execution** — Gemma / Qwen running directly on the phone via MediaPipe or llama.cpp, for offline fallback and hybrid routing (simple tasks local, complex tasks remote)
|
||||
- **Cross-app workflow execution** with inter-step state carry-over — "find a restaurant on Maps, share it on WhatsApp, book an Uber there"
|
||||
- **Web dashboard** for monitoring bridge activity server-side
|
||||
- **iOS support** via Shortcuts + accessibility bridge + App Intents (evaluate feasibility before committing)
|
||||
- **Developer-mode embedded HTTP server** on the phone — a Ktor/Netty server on a local port for direct-to-phone testing over USB or LAN without routing through the relay (dev ergonomics, not user-facing)
|
||||
|
||||
### Vision
|
||||
Dedicated **"Hermes Phone"** — a device (or phone ROM) that boots straight into agent mode, where the OS itself is the agent. Long-term north star, not a concrete deliverable.
|
||||
|
||||
---
|
||||
|
||||
## How this roadmap evolves
|
||||
|
||||
New ideas enter via: direct proposals in GitHub issues, comparison passes against similar projects, community feedback from users and contributors, or internal research that turns into a shipped prototype.
|
||||
|
||||
Active work waves (like the v0.4 bridge feature expansion above) get their detailed implementation plans in [`docs/plans/`](docs/plans/). When a plan wave ships, its plan file is archived or removed and the items migrate into [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
Have an idea? [Open an issue](https://github.com/Codename-11/hermes-relay/issues/new) — every one is read.
|
||||
@@ -21,7 +21,10 @@ Things to look into:
|
||||
- **Tool registration discoverability** — `android_*` tools register at gateway import time. There's no canonical "list installed plugin tools" API. Would adding one to upstream make sense, or is `gateway tool list` already enough?
|
||||
- **Versioning + compatibility ranges** — `pip install -e` doesn't enforce version pins between hermes-agent and our plugin. A breaking change in upstream's plugin loader could silently break us. Do we need a `hermes_compat: ">=0.8.0,<1.0.0"` field somewhere?
|
||||
- **`hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 will eventually let us delete it. Track that PR's status periodically.
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Blocked on PR #8556 merging. Once session primitives ship upstream, add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
|
||||
When the answer becomes clearer, this section becomes either an ADR in `docs/decisions.md` or a Plan under `Plans/`.
|
||||
|
||||
|
||||
+52
-70
@@ -1,4 +1,3 @@
|
||||
import java.io.File
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
@@ -19,11 +18,24 @@ base {
|
||||
}
|
||||
|
||||
android {
|
||||
// Kotlin package / on-disk source layout / R class namespace. Decoupled
|
||||
// from `applicationId` below as of the Axiom-Labs org-account migration:
|
||||
// the repo's source tree stays under `com.hermesandroid.relay` (so all
|
||||
// 130+ Kotlin files and their package declarations keep working) while
|
||||
// the Play Store / Android-system identity lives under `com.axiomlabs.*`.
|
||||
// This is an AGP-supported pattern — `namespace` is a build-time concept
|
||||
// and `applicationId` is the runtime install identity; they don't have
|
||||
// to match.
|
||||
namespace = "com.hermesandroid.relay"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.hermesandroid.relay"
|
||||
// Axiom-Labs, LLC Play Console listing. Changed from the original
|
||||
// `com.hermesandroid.relay` on 2026-04-13 during the org-account
|
||||
// migration. The old Internal-testing listing under Bailey's personal
|
||||
// account is being deleted; the DUNS-verified Axiom-Labs account is
|
||||
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
||||
applicationId = "com.axiomlabs.hermesrelay"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
@@ -72,11 +84,18 @@ android {
|
||||
// tiers enabled.
|
||||
//
|
||||
// applicationIdSuffix decision: sideload gets `.sideload` so both tracks can
|
||||
// coexist on the same device. The Play build keeps the canonical
|
||||
// `com.hermesandroid.relay` applicationId so existing installs upgrade
|
||||
// cleanly from v0.2.0 and Play Console keeps its history. Cost: anyone with
|
||||
// both installed sees two launcher icons — we'll differentiate with a label
|
||||
// suffix once the flavored strings.xml lands.
|
||||
// coexist on the same device. The Play build keeps the base
|
||||
// `com.axiomlabs.hermesrelay` applicationId as the canonical Play Store
|
||||
// install; sideload becomes `com.axiomlabs.hermesrelay.sideload`. Cost:
|
||||
// anyone with both installed sees two launcher icons — we differentiate
|
||||
// via the flavored strings.xml label suffix.
|
||||
//
|
||||
// Note: the previous `com.hermesandroid.relay` applicationId (Internal
|
||||
// testing under Bailey's personal Play account) is being retired as part
|
||||
// of the Axiom-Labs org-account migration. Play Store package names are
|
||||
// permanently reserved once used, so the old ID can never be reclaimed —
|
||||
// existing Internal-testing installs won't auto-upgrade to the new listing
|
||||
// and will need a manual reinstall (limited blast radius, single tester).
|
||||
flavorDimensions += "track"
|
||||
productFlavors {
|
||||
create("googlePlay") {
|
||||
@@ -134,6 +153,20 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// Google Play Publisher — optional automated upload to Play Console.
|
||||
@@ -174,6 +207,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)
|
||||
@@ -185,6 +219,13 @@ 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)
|
||||
@@ -220,71 +261,12 @@ dependencies {
|
||||
testImplementation(libs.mockk)
|
||||
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)
|
||||
}
|
||||
|
||||
// ─── Logcat noise suppression ────────────────────────────────────────────────
|
||||
// Compose on Android 15 (API 35) calls View.setRequestedFrameRate() on every
|
||||
// draw pass, and for certain internal zero-sized helper views the frame-rate
|
||||
// math in Compose's VRR decay logic produces NaN. Android's View class logs
|
||||
// each NaN call at Info level under the "View" tag, spamming logcat at the
|
||||
// display's refresh rate (~120 entries/sec on a 120Hz phone). This is a
|
||||
// Compose library bug upstream — our code's amplitude NaN guards were
|
||||
// correct but can't fix it because the NaN is generated inside Compose,
|
||||
// not from our values.
|
||||
//
|
||||
// Workaround: silence the "View" log tag at the Android log daemon level
|
||||
// via `setprop log.tag.View SILENT`. The setting is device-wide and survives
|
||||
// until the next reboot. By hooking this task as `finalizedBy` on installDebug,
|
||||
// it runs automatically every time Android Studio builds and installs the
|
||||
// app, so the device is always silenced after a dev cycle.
|
||||
//
|
||||
// Remove this task when the Compose bug is fixed in a future compose-bom bump.
|
||||
// Resolve the Android SDK path without touching the deprecated
|
||||
// `android.sdkDirectory` accessor (removed from AGP 8's public
|
||||
// ApplicationExtension). Preference order:
|
||||
// 1. local.properties `sdk.dir` (what Android Studio writes)
|
||||
// 2. $ANDROID_HOME (standard env var)
|
||||
// 3. $ANDROID_SDK_ROOT (legacy fallback)
|
||||
val androidSdkPath: String? = run {
|
||||
val localProps = rootProject.file("local.properties")
|
||||
val fromProps: String? = if (localProps.exists()) {
|
||||
val props = Properties()
|
||||
localProps.inputStream().use { stream -> props.load(stream) }
|
||||
props.getProperty("sdk.dir")
|
||||
} else null
|
||||
fromProps
|
||||
?: System.getenv("ANDROID_HOME")
|
||||
?: System.getenv("ANDROID_SDK_ROOT")
|
||||
}
|
||||
|
||||
tasks.register<Exec>("silenceAndroidViewLogs") {
|
||||
description = "Silence Android's 'View' tag in logcat to work around " +
|
||||
"Compose setRequestedFrameRate=NaN spam on API 35+."
|
||||
group = "hermes"
|
||||
|
||||
val adbRelPath = if (org.gradle.internal.os.OperatingSystem.current().isWindows)
|
||||
"platform-tools/adb.exe"
|
||||
else
|
||||
"platform-tools/adb"
|
||||
val adbFullPath = androidSdkPath?.let { sdk -> "$sdk/$adbRelPath" }
|
||||
// If we couldn't locate the SDK, skip — this task is a nice-to-have.
|
||||
onlyIf { adbFullPath != null && File(adbFullPath).exists() }
|
||||
executable = adbFullPath ?: "true"
|
||||
args("shell", "setprop", "log.tag.View", "SILENT")
|
||||
|
||||
// Don't fail the build if there's no device attached or adb isn't happy —
|
||||
// the setprop is a nice-to-have, not a build requirement.
|
||||
isIgnoreExitValue = true
|
||||
}
|
||||
|
||||
// Hook silenceAndroidViewLogs onto every install task (installDebug,
|
||||
// installRelease, etc.) so it runs after Android Studio's run-button install.
|
||||
afterEvaluate {
|
||||
tasks.matching { it.name.startsWith("install") && !it.name.contains("Test") }
|
||||
.configureEach {
|
||||
finalizedBy("silenceAndroidViewLogs")
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
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.onNode
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
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.onNode
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
+32
-3
@@ -1,6 +1,31 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.handlers.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
|
||||
/**
|
||||
* Local in-process bridge dispatcher type. The Play flavor never invokes
|
||||
* this — phone-control actions are sideload-only — but the typealias has
|
||||
* to exist in the googlePlay source set so the shared `VoiceViewModel`
|
||||
* call site compiles regardless of active flavor.
|
||||
*
|
||||
* Return type mirrors the sideload flavor's updated shape so the shared
|
||||
* typealias binding site in VoiceViewModel compiles against either source
|
||||
* set without #if gating.
|
||||
*/
|
||||
typealias LocalBridgeDispatcher = suspend (Envelope) -> LocalDispatchResult
|
||||
|
||||
/** @see com.hermesandroid.relay.voice.VoiceIntentResultCallback in the sideload flavor. */
|
||||
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
|
||||
|
||||
/**
|
||||
* === PHASE3-voice-intents (googlePlay flavor): factory ===
|
||||
@@ -13,12 +38,16 @@ import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
* exact signature + package so `VoiceViewModel` has a single static call
|
||||
* site and no reflection.
|
||||
*
|
||||
* The [multiplexer] parameter is accepted for signature parity with the
|
||||
* sideload flavor (which actually uses it to emit bridge envelopes). On
|
||||
* Play it is simply ignored.
|
||||
* All parameters accepted for signature parity with sideload and silently
|
||||
* ignored — the Play APK deliberately never references any bridge or
|
||||
* accessibility class so the conservative Play feature description stays
|
||||
* honest.
|
||||
*/
|
||||
fun createVoiceBridgeIntentHandler(
|
||||
multiplexer: ChannelMultiplexer?,
|
||||
localBridgeDispatcher: LocalBridgeDispatcher? = null,
|
||||
onDispatchResult: VoiceIntentResultCallback? = null,
|
||||
onCountdownStart: VoiceIntentCountdownCallback? = null,
|
||||
): VoiceBridgeIntentHandler = NoopVoiceBridgeIntentHandler()
|
||||
|
||||
// === END PHASE3-voice-intents (googlePlay) ===
|
||||
|
||||
+2
@@ -24,6 +24,8 @@ internal class NoopVoiceBridgeIntentHandler : VoiceBridgeIntentHandler {
|
||||
override fun cancelPending() {
|
||||
// no-op — nothing to cancel on Play.
|
||||
}
|
||||
|
||||
override fun hasPendingDestructive(): Boolean = false
|
||||
}
|
||||
|
||||
// === END PHASE3-voice-intents (googlePlay) ===
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
-->
|
||||
<resources>
|
||||
<string name="a11y_service_label">Hermes-Bridge</string>
|
||||
<string name="a11y_description_googleplay">Hermes assists you by reading notifications, summarizing messages, and replying with your confirmation. The service is dormant until you explicitly enable Bridge mode in the app.</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>
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
<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
|
||||
@@ -19,7 +24,12 @@
|
||||
screenshots. POST_NOTIFICATIONS is required on API 33+ for that
|
||||
same foreground-service notification. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<!-- FOREGROUND_SERVICE_MEDIA_PROJECTION moved to sideload manifest.
|
||||
googlePlay doesn't need screen recording: /screenshot route is
|
||||
gated sideload-only in BridgeCommandHandler. Declaring the
|
||||
permission on the Play track would flag review since our
|
||||
accessibility use-case ("read-only screen reading") doesn't
|
||||
justify screen capture. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- === END PHASE3-accessibility === -->
|
||||
|
||||
@@ -40,6 +50,30 @@
|
||||
|
||||
<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"
|
||||
@@ -53,6 +87,8 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -139,10 +175,10 @@
|
||||
<service
|
||||
android:name=".bridge.BridgeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse|mediaProjection">
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Persistent indicator that the Hermes agent has device control, per the Tier 5 safety-rails design." />
|
||||
android:value="Maintains a persistent WebSocket connection to the user's Hermes server for real-time chat relay and notification mirroring. The service is dormant until the user explicitly enables Bridge mode in the app." />
|
||||
</service>
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
|
||||
@@ -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,16 @@
|
||||
v0.1.0 — First Release
|
||||
v0.6.1 - Voice auth, route recovery, and media bridge
|
||||
|
||||
Chat
|
||||
• Direct API chat via SSE streaming
|
||||
• Markdown rendering — code blocks, bold, italic, links
|
||||
• Session management — create, switch, rename, delete
|
||||
• Message history with auto-titles
|
||||
• Reasoning display (collapsible thinking blocks)
|
||||
• Personality picker with dynamic server personalities
|
||||
• Command palette — 29+ commands + server skills
|
||||
• Token & cost tracking per message
|
||||
• File attachments — images, documents, any file type
|
||||
• Message queuing — send while agent is streaming
|
||||
Voice and pairing
|
||||
* Voice mode can now use your saved Hermes API key directly.
|
||||
* Relay sessions recover better after server restarts and updates.
|
||||
* LAN/Tailscale pairing and route fallback are more reliable.
|
||||
|
||||
Animation
|
||||
• ASCII morphing sphere on empty chat screen
|
||||
• Ambient mode — fullscreen sphere (toggle in header)
|
||||
• Subtle sphere behind messages at 15% opacity
|
||||
• Animation controls in Settings > Appearance
|
||||
Android bridge
|
||||
* Added share-sheet support for files, screenshots, relay media, and attachments.
|
||||
* Added MMS compose handoff with attachments.
|
||||
* SMS now reports clearer sent, blocked, timeout, and failed states.
|
||||
* Return-to-Hermes and bridge route docs now match the actual relay API.
|
||||
|
||||
App
|
||||
• Material You theming (light/dark/auto)
|
||||
• QR code pairing for quick setup
|
||||
• Stats for Nerds — response times, health metrics
|
||||
• Offline detection with reconnect
|
||||
• Developer Options — tap version 7x to unlock experimental features
|
||||
• Configurable limits — attachment size, message length
|
||||
|
||||
Security
|
||||
• API keys in EncryptedSharedPreferences
|
||||
• Network security config for localhost
|
||||
• Feature gating for unfinished features
|
||||
Update note
|
||||
* Same-flavor installs should keep pairing. Re-pair only after uninstalling data,
|
||||
switching app flavor, revoking the device, or clearing the server session store.
|
||||
|
||||
@@ -1,14 +1,51 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.ComposeUiFlags
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
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() {
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@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 {
|
||||
|
||||
@@ -15,9 +15,11 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.hermesandroid.relay.accessibility.MediaProjectionHolder
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
@@ -31,9 +33,15 @@ class MainActivity : ComponentActivity() {
|
||||
// declared as a property (registerForActivityResult is safe to call
|
||||
// from a property initializer on ComponentActivity).
|
||||
//
|
||||
// The result is forwarded to MediaProjectionHolder.onGranted, which
|
||||
// wraps the Intent in a MediaProjection instance and stores it for
|
||||
// ScreenCapture.kt to consume on the next /screenshot bridge command.
|
||||
// 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
|
||||
// result off to BridgeForegroundService, which:
|
||||
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
|
||||
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
|
||||
// 3. Stores the projection in the holder's StateFlow
|
||||
// BridgeViewModel observes that flow and refreshes the UI immediately.
|
||||
//
|
||||
// ScreenCaptureRequester is a process-singleton rendezvous so the
|
||||
// BridgeViewModel (which has no Activity reference) can ask us to
|
||||
@@ -41,10 +49,13 @@ class MainActivity : ComponentActivity() {
|
||||
private val mediaProjectionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val granted = MediaProjectionHolder.onGranted(
|
||||
this, result.resultCode, result.data
|
||||
)
|
||||
Log.i(TAG, "MediaProjection consent result: granted=$granted")
|
||||
val data = result.data
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
Log.i(TAG, "MediaProjection consent granted — handing off to FGS")
|
||||
BridgeForegroundService.grantMediaProjection(this, result.resultCode, data)
|
||||
} else {
|
||||
Log.i(TAG, "MediaProjection consent rejected (resultCode=${result.resultCode})")
|
||||
}
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
|
||||
@@ -99,6 +110,9 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
RelayApp()
|
||||
}
|
||||
window.decorView.post {
|
||||
ComposeArrWorkaround.disableForViewTree(window.decorView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -119,6 +133,25 @@ class MainActivity : ComponentActivity() {
|
||||
NavRouteRequest.tryRequest(route)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// 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.
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
// Re-probe the credential-lock state on resume so the Bridge
|
||||
// tab badge updates immediately if the user just changed their
|
||||
// lock screen in system Settings between app sessions.
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `unattended.supported` is false on the googlePlay flavor — the Play
|
||||
* APK has no wake-lock path — which lets the agent distinguish "user
|
||||
* hasn't opted in" from "this build can't do unattended at all" without
|
||||
* a separate probe.
|
||||
*
|
||||
* 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
|
||||
@@ -226,6 +238,23 @@ class BridgeStatusReporter(
|
||||
put("battery_percent", batteryFinal)
|
||||
put("screen_on", screenOn)
|
||||
put("current_app", currentApp ?: "unknown")
|
||||
// Flavor context so the LLM knows upfront which build
|
||||
// is connected and can avoid attempting sideload-only
|
||||
// tools on a googlePlay phone. Without this the agent
|
||||
// is flavor-blind until it tries android_send_sms and
|
||||
// gets a 403 sideload_only — by which point it's
|
||||
// already wasted a tool call and has to recover.
|
||||
put("flavor", com.hermesandroid.relay.data.BuildFlavor.current)
|
||||
put("application_id", com.hermesandroid.relay.data.BuildFlavor.run {
|
||||
// BuildConfig.APPLICATION_ID is the resolved
|
||||
// applicationId from the active flavor variant.
|
||||
try {
|
||||
@Suppress("KotlinConstantConditions")
|
||||
com.hermesandroid.relay.BuildConfig.APPLICATION_ID
|
||||
} catch (_: Throwable) {
|
||||
"unknown"
|
||||
}
|
||||
})
|
||||
})
|
||||
put("bridge", buildJsonObject {
|
||||
put("master_enabled", masterEnabled)
|
||||
@@ -245,6 +274,25 @@ 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", BuildFlavor.isSideload)
|
||||
put("enabled", UnattendedAccessManager.enabled.value)
|
||||
put(
|
||||
"credential_lock_detected",
|
||||
UnattendedAccessManager.credentialLockDetected.value,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Legacy top-level fields (backwards compat) ────────
|
||||
// Kept so pre-phase3-status consumers still see the
|
||||
// same fields they're already parsing. New consumers
|
||||
|
||||
+85
@@ -10,6 +10,7 @@ import android.view.accessibility.AccessibilityNodeInfo
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.event.EventStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -96,6 +97,7 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
private val screenReader = ScreenReader()
|
||||
private val screenHasher = ScreenHasher()
|
||||
private var _actionExecutor: ActionExecutor? = null
|
||||
|
||||
/**
|
||||
@@ -136,6 +138,12 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
/** Convenience wrapper — the service uses its own [ScreenReader] instance. */
|
||||
val reader: ScreenReader get() = screenReader
|
||||
|
||||
/**
|
||||
* Convenience wrapper for the service's [ScreenHasher] instance.
|
||||
* Used by `BridgeCommandHandler` for `/screen_hash` + `/diff_screen`.
|
||||
*/
|
||||
val hasher: ScreenHasher get() = screenHasher
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
@@ -170,6 +178,16 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
// so we never even receive most events.
|
||||
}
|
||||
}
|
||||
|
||||
// === PHASE3-event-stream: B1 android_events / android_event_stream ===
|
||||
// Feed signal-rich events into the bounded ring buffer when the
|
||||
// agent has explicitly opted in via android_event_stream(true).
|
||||
// EventStore does its own type-filter + throttle + thread-safety
|
||||
// — we just hand it the raw event.
|
||||
if (EventStore.isStreaming) {
|
||||
EventStore.append(event)
|
||||
}
|
||||
// === END PHASE3-event-stream ===
|
||||
}
|
||||
|
||||
override fun onInterrupt() {
|
||||
@@ -220,6 +238,12 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
* On API 34+, [AccessibilityNodeInfo.recycle] is deprecated but still
|
||||
* safe to call — the system just no-ops. We support min SDK 26 so we
|
||||
* keep calling it for the older branch.
|
||||
*
|
||||
* Prefer [snapshotAllWindows] for /screen + tap_text / type_text — it
|
||||
* catches system overlays, popup menus, and the notification shade.
|
||||
* This single-root form is retained for callers that genuinely only
|
||||
* care about the foregrounded app window (e.g. [ActionExecutor.scroll],
|
||||
* which uses the active window's bounds as the scroll gesture's frame).
|
||||
*/
|
||||
fun snapshotRoot(): AccessibilityNodeInfo? = try {
|
||||
rootInActiveWindow
|
||||
@@ -228,6 +252,67 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* P1 — Snapshot the root nodes of **all** live accessibility windows,
|
||||
* top-of-stack first. Catches system overlays, popup menus, the
|
||||
* notification shade when pulled down, permission dialogs, and
|
||||
* multi-window split-screen state — all of which [snapshotRoot] misses.
|
||||
*
|
||||
* Every [android.view.accessibility.AccessibilityWindowInfo.getRoot]
|
||||
* returns a fresh [AccessibilityNodeInfo] that the caller MUST
|
||||
* `recycle()` when done. Recycling is the single biggest landmine in
|
||||
* this area — leaking window roots makes subsequent gesture dispatches
|
||||
* fail silently because the system runs out of node handles.
|
||||
*
|
||||
* # 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.
|
||||
*
|
||||
* Returns an empty list only if the service cannot read any window
|
||||
* root at all (e.g. lock screen, master-off state). Callers should
|
||||
* treat an empty return the same as `snapshotRoot() == null`.
|
||||
*/
|
||||
fun snapshotAllWindows(): List<AccessibilityNodeInfo> {
|
||||
// Try the full multi-window path first. `service.windows` is
|
||||
// available on API 21+ and we target min SDK 26, so no version
|
||||
// guard needed.
|
||||
val windowList: List<android.view.accessibility.AccessibilityWindowInfo> = try {
|
||||
this.windows ?: emptyList()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "service.windows threw: ${t.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
if (windowList.isNotEmpty()) {
|
||||
val roots = ArrayList<AccessibilityNodeInfo>(windowList.size)
|
||||
for (wi in windowList) {
|
||||
val root: AccessibilityNodeInfo? = try {
|
||||
wi.root
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AccessibilityWindowInfo.getRoot threw: ${t.message}")
|
||||
null
|
||||
}
|
||||
if (root != null) roots.add(root)
|
||||
}
|
||||
if (roots.isNotEmpty()) return roots
|
||||
// Else fall through — all window roots were null, try the
|
||||
// single-window fallback in case it can still see the active
|
||||
// window.
|
||||
}
|
||||
|
||||
// googlePlay fallback (or empty-windows edge case): mimic the
|
||||
// pre-P1 single-root behaviour.
|
||||
val active = snapshotRoot() ?: return emptyList()
|
||||
return listOf(active)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort indicator — `true` when the runtime is >= API 26 (always
|
||||
* true on this app, we target 26+). Exposed for completeness so the
|
||||
|
||||
@@ -16,7 +16,7 @@ import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
@@ -26,9 +26,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Phase 3 — accessibility `accessibility-runtime`
|
||||
@@ -109,18 +107,78 @@ class ScreenCapture(
|
||||
/** PNG quality is a no-op for PNG, but Bitmap.compress expects the arg. */
|
||||
private const val PNG_QUALITY = 100
|
||||
|
||||
/** ImageReader buffer count — 2 is enough for our one-shot-at-a-time use. */
|
||||
/**
|
||||
* ImageReader buffer count — we only need the latest frame, but the
|
||||
* reader requires at least 2 slots so the producer (VirtualDisplay)
|
||||
* can keep writing while we acquire the previous one.
|
||||
*/
|
||||
private const val MAX_IMAGES = 2
|
||||
|
||||
/** Capture timeout — if no frame arrives in this window, fail loudly. */
|
||||
private const val CAPTURE_TIMEOUT_MS = 2_500L
|
||||
}
|
||||
|
||||
// === PHASE3-bridge-ui-followup: MediaProjection reuse fix ===
|
||||
//
|
||||
// Starting in Android 14 (API 34), each MediaProjection instance supports
|
||||
// exactly ONE createVirtualDisplay() call per session. Calling it a
|
||||
// second time throws with the error:
|
||||
// "Don't re-use the resultData... Don't take multiple captures by
|
||||
// invoking MediaProjection#createVirtualDisplay multiple times on
|
||||
// the same instance."
|
||||
//
|
||||
// The old implementation built a fresh VirtualDisplay + ImageReader on
|
||||
// EVERY screenshot call and released it after, which worked on Android
|
||||
// 13 and below but breaks the second /screenshot request on 14+.
|
||||
//
|
||||
// Fix: keep the VirtualDisplay + ImageReader + HandlerThread alive
|
||||
// across captures, keyed by the MediaProjection instance. Rebuild only
|
||||
// when the projection reference changes (fresh consent grant) or the
|
||||
// dimensions change (orientation flip). The ImageReader's
|
||||
// setOnImageAvailableListener drains the buffer continuously; each
|
||||
// captureAndUpload() installs a one-shot [pendingCapture] callback
|
||||
// that fires on the next frame.
|
||||
//
|
||||
// Thread model:
|
||||
// - `captureMutex` serializes concurrent captureAndUpload() calls
|
||||
// - `cacheLock` protects the cached-state fields against the listener
|
||||
// thread (which runs on `captureThread.looper`) racing with rebuild
|
||||
// - The listener always acquires the latest frame; the pendingCapture
|
||||
// deferred is completed with the encoded PNG bytes inside the
|
||||
// listener callback on the capture thread.
|
||||
private val captureMutex = kotlinx.coroutines.sync.Mutex()
|
||||
private val cacheLock = Any()
|
||||
private var cachedProjection: MediaProjection? = null
|
||||
private var cachedReader: ImageReader? = null
|
||||
private var cachedDisplay: VirtualDisplay? = null
|
||||
private var cachedThread: HandlerThread? = null
|
||||
private var cachedHandler: Handler? = null
|
||||
private var cachedWidth: Int = 0
|
||||
private var cachedHeight: Int = 0
|
||||
private var cachedDensity: Int = 0
|
||||
|
||||
/**
|
||||
* Pending capture request, populated on [captureAndUpload] entry and
|
||||
* completed by the persistent ImageReader listener on the next frame.
|
||||
* `@Volatile` so the listener thread sees assignments made from the
|
||||
* capture coroutine. AtomicReference-style swap semantics via
|
||||
* [pendingCaptureRef] avoid a stale completion racing a new request.
|
||||
*/
|
||||
private val pendingCaptureRef = java.util.concurrent.atomic.AtomicReference<
|
||||
kotlinx.coroutines.CompletableDeferred<ByteArray>?
|
||||
>(null)
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
|
||||
/**
|
||||
* Build the consent intent that `BridgeScreen` launches via an
|
||||
* `ActivityResultLauncher`. Callers should launch the intent with
|
||||
* `StartActivityForResult` and on success call
|
||||
* [MediaProjectionHolder.onGranted] with the result code + data Intent.
|
||||
* `StartActivityForResult` and on success route the result to
|
||||
* `BridgeForegroundService.grantMediaProjection(...)`, which handles
|
||||
* the Android 14+ FGS-type-upgrade dance and stores the projection
|
||||
* inside the holder. Calling
|
||||
* [MediaProjectionHolder.acceptGrantInsideForegroundService] from
|
||||
* outside a foreground service is a known footgun — see that method's
|
||||
* docstring for the full explanation.
|
||||
*/
|
||||
fun createConsentIntent(): Intent =
|
||||
(context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager)
|
||||
@@ -147,10 +205,16 @@ class ScreenCapture(
|
||||
)
|
||||
)
|
||||
|
||||
// Serialize concurrent capture requests so only one pendingCapture
|
||||
// is in flight at a time. The bridge command handler is the usual
|
||||
// caller and it's single-threaded per /screenshot request, but the
|
||||
// mutex keeps us honest if anything ever parallelizes.
|
||||
val pngBytes = try {
|
||||
captureOnce(projection)
|
||||
captureMutex.withLock {
|
||||
captureFrame(projection)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "captureOnce failed: ${e.message}")
|
||||
Log.w(TAG, "captureFrame failed: ${e.message}")
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
|
||||
@@ -158,72 +222,149 @@ class ScreenCapture(
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously (inside a suspendCancellableCoroutine) capture exactly
|
||||
* one frame from a freshly-built VirtualDisplay + ImageReader, encode
|
||||
* it to PNG, and return the bytes.
|
||||
* Release any cached VirtualDisplay / ImageReader / HandlerThread. Call
|
||||
* this when the MediaProjection is revoked (the holder's `onStop`
|
||||
* callback, or an explicit revoke) so a subsequent grant starts with
|
||||
* a clean slate. Safe to call multiple times.
|
||||
*
|
||||
* NOTE: this does NOT stop the MediaProjection itself — that's the
|
||||
* holder's responsibility. We only own the capture pipeline built on
|
||||
* top of the projection.
|
||||
*/
|
||||
private suspend fun captureOnce(projection: MediaProjection): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val metrics = DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
|
||||
.defaultDisplay.getRealMetrics(metrics)
|
||||
fun releaseCache() {
|
||||
synchronized(cacheLock) {
|
||||
runCatching { cachedDisplay?.release() }
|
||||
runCatching { cachedReader?.close() }
|
||||
runCatching { cachedThread?.quitSafely() }
|
||||
cachedDisplay = null
|
||||
cachedReader = null
|
||||
cachedThread = null
|
||||
cachedHandler = null
|
||||
cachedProjection = null
|
||||
cachedWidth = 0
|
||||
cachedHeight = 0
|
||||
cachedDensity = 0
|
||||
}
|
||||
// Fail any pending capture with a descriptive error so the caller
|
||||
// doesn't hang for the timeout.
|
||||
pendingCaptureRef.getAndSet(null)?.takeIf { it.isActive }?.completeExceptionally(
|
||||
IOException("capture pipeline released before frame arrived")
|
||||
)
|
||||
}
|
||||
|
||||
val width = metrics.widthPixels
|
||||
val height = metrics.heightPixels
|
||||
val densityDpi = metrics.densityDpi
|
||||
/**
|
||||
* Capture one frame from the cached VirtualDisplay + ImageReader,
|
||||
* rebuilding them if the projection reference changed or dimensions
|
||||
* drifted (orientation flip). Returns the PNG-encoded bytes.
|
||||
*
|
||||
* The ImageReader's persistent listener is set up once inside
|
||||
* [ensureCacheFor]. Each call here installs a fresh
|
||||
* [pendingCaptureRef] deferred that the listener completes on the
|
||||
* next frame; the listener drains non-waiting frames so the buffer
|
||||
* doesn't back up while nothing is asking for screenshots.
|
||||
*/
|
||||
private suspend fun captureFrame(projection: MediaProjection): ByteArray {
|
||||
val metrics = DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
|
||||
.defaultDisplay.getRealMetrics(metrics)
|
||||
val width = metrics.widthPixels
|
||||
val height = metrics.heightPixels
|
||||
val densityDpi = metrics.densityDpi
|
||||
|
||||
ensureCacheFor(projection, width, height, densityDpi)
|
||||
|
||||
val deferred = kotlinx.coroutines.CompletableDeferred<ByteArray>()
|
||||
// Replace any stale pending capture (shouldn't exist because of
|
||||
// the mutex, but defensive). If there's a previous one, fail it
|
||||
// so nobody ends up stuck.
|
||||
val previous = pendingCaptureRef.getAndSet(deferred)
|
||||
if (previous != null && previous.isActive) {
|
||||
previous.completeExceptionally(
|
||||
IOException("capture superseded by a newer request")
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
kotlinx.coroutines.withTimeout(CAPTURE_TIMEOUT_MS) { deferred.await() }
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
pendingCaptureRef.compareAndSet(deferred, null)
|
||||
throw IOException("screen capture timed out")
|
||||
} catch (t: Throwable) {
|
||||
pendingCaptureRef.compareAndSet(deferred, null)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build (or reuse) the cached VirtualDisplay + ImageReader + HandlerThread
|
||||
* for this projection. Rebuilds when:
|
||||
*
|
||||
* - The projection reference has changed (new consent grant landed)
|
||||
* - The captured dimensions don't match the current display (orientation
|
||||
* flipped, foldable opened/closed, display switched)
|
||||
*
|
||||
* Must be called while [captureMutex] is held so the cached fields
|
||||
* aren't racing another capture.
|
||||
*/
|
||||
private fun ensureCacheFor(
|
||||
projection: MediaProjection,
|
||||
width: Int,
|
||||
height: Int,
|
||||
densityDpi: Int,
|
||||
) {
|
||||
synchronized(cacheLock) {
|
||||
val projectionChanged = cachedProjection !== projection
|
||||
val dimensionsChanged = width != cachedWidth || height != cachedHeight
|
||||
if (!projectionChanged && !dimensionsChanged && cachedDisplay != null && cachedReader != null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Tear down any stale cache before building fresh.
|
||||
runCatching { cachedDisplay?.release() }
|
||||
runCatching { cachedReader?.close() }
|
||||
runCatching { cachedThread?.quitSafely() }
|
||||
|
||||
val thread = HandlerThread("HermesScreenCapture").apply { start() }
|
||||
val handler = Handler(thread.looper)
|
||||
val reader = ImageReader.newInstance(
|
||||
width, height, PixelFormat.RGBA_8888, MAX_IMAGES
|
||||
)
|
||||
|
||||
val captureThread = HandlerThread("HermesScreenCapture").apply { start() }
|
||||
val captureHandler = Handler(captureThread.looper)
|
||||
|
||||
var virtualDisplay: VirtualDisplay? = null
|
||||
var resolved = false
|
||||
|
||||
fun cleanup() {
|
||||
try { virtualDisplay?.release() } catch (_: Throwable) {}
|
||||
try { reader.close() } catch (_: Throwable) {}
|
||||
try { captureThread.quitSafely() } catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
val postedTimeout = Handler(captureThread.looper)
|
||||
postedTimeout.postDelayed({
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
cleanup()
|
||||
if (cont.isActive) {
|
||||
cont.resumeWith(
|
||||
Result.failure(IOException("screen capture timed out"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}, CAPTURE_TIMEOUT_MS)
|
||||
|
||||
// Persistent listener — fires on every frame the VirtualDisplay
|
||||
// produces. If there's a pending capture request, we encode
|
||||
// the frame and complete it; otherwise we just drain the image
|
||||
// so the ImageReader buffer stays clear.
|
||||
reader.setOnImageAvailableListener({ r ->
|
||||
if (resolved) return@setOnImageAvailableListener
|
||||
val waiter = pendingCaptureRef.get()
|
||||
if (waiter == null || !waiter.isActive) {
|
||||
// Drain-and-drop — nobody's asking for a screenshot
|
||||
// right now but frames are still arriving.
|
||||
runCatching { r.acquireLatestImage() }.getOrNull()?.close()
|
||||
return@setOnImageAvailableListener
|
||||
}
|
||||
var image: Image? = null
|
||||
try {
|
||||
image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
image = r.acquireLatestImage()
|
||||
?: return@setOnImageAvailableListener
|
||||
val png = imageToPngBytes(image, width, height)
|
||||
resolved = true
|
||||
cleanup()
|
||||
if (cont.isActive) cont.resume(png)
|
||||
// Only complete the EXACT deferred we latched onto,
|
||||
// so a stale listener firing after supersession doesn't
|
||||
// resolve a new request.
|
||||
if (pendingCaptureRef.compareAndSet(waiter, null)) {
|
||||
waiter.complete(png)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
resolved = true
|
||||
cleanup()
|
||||
if (cont.isActive) {
|
||||
cont.resumeWith(Result.failure(t))
|
||||
if (pendingCaptureRef.compareAndSet(waiter, null)) {
|
||||
waiter.completeExceptionally(t)
|
||||
}
|
||||
} finally {
|
||||
try { image?.close() } catch (_: Throwable) {}
|
||||
runCatching { image?.close() }
|
||||
}
|
||||
}, captureHandler)
|
||||
}, handler)
|
||||
|
||||
try {
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
val display = try {
|
||||
projection.createVirtualDisplay(
|
||||
"hermes-bridge-capture",
|
||||
width,
|
||||
height,
|
||||
@@ -231,23 +372,42 @@ class ScreenCapture(
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
reader.surface,
|
||||
null,
|
||||
captureHandler
|
||||
handler,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
resolved = true
|
||||
cleanup()
|
||||
if (cont.isActive) {
|
||||
cont.resumeWith(Result.failure(t))
|
||||
}
|
||||
// Build failed — roll back so the next attempt tries fresh.
|
||||
runCatching { reader.close() }
|
||||
runCatching { thread.quitSafely() }
|
||||
throw t
|
||||
}
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
cleanup()
|
||||
}
|
||||
// Register the MediaProjection.Callback so if the system stops
|
||||
// this projection out from under us, we release our cache
|
||||
// instead of holding dead handles. The holder's own callback
|
||||
// is separate — it clears projectionFlow; ours clears the
|
||||
// capture pipeline. Both are safe and complementary.
|
||||
try {
|
||||
projection.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
releaseCache()
|
||||
}
|
||||
}, handler)
|
||||
} catch (_: Throwable) {
|
||||
// Some OEMs log but don't throw if the callback is already
|
||||
// registered by another party (e.g. the holder). Ignore.
|
||||
}
|
||||
|
||||
cachedProjection = projection
|
||||
cachedReader = reader
|
||||
cachedDisplay = display
|
||||
cachedThread = thread
|
||||
cachedHandler = handler
|
||||
cachedWidth = width
|
||||
cachedHeight = height
|
||||
cachedDensity = densityDpi
|
||||
Log.i(TAG, "screen capture pipeline built ${width}x$height dpi=$densityDpi")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an [Image] from `ImageReader` into a PNG byte array. The
|
||||
@@ -379,25 +539,65 @@ class ScreenCapture(
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the per-session [MediaProjection] grant. The Bridge UI (Agent bridge-ui)
|
||||
* calls [onGranted] from its `ActivityResultLauncher` callback; [ScreenCapture]
|
||||
* reads [projection] through the lambda passed to its constructor.
|
||||
* Holds the per-session [MediaProjection] grant.
|
||||
*
|
||||
* # Android 14+ rule
|
||||
*
|
||||
* `MediaProjectionManager.getMediaProjection()` MUST be called only after a
|
||||
* foreground service has called `startForeground()` with type
|
||||
* `FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION`, and that call must happen
|
||||
* AFTER the user has granted the consent dialog. Calling it before — even
|
||||
* if you're inside the launcher result callback — gives you a projection
|
||||
* that the system auto-revokes within a frame, with no error visible to
|
||||
* the app. Symptom: consent dialog appears, user allows, dialog closes,
|
||||
* grant evaporates. Sample-tested on Samsung S24 / Android 14, 2026-04-12.
|
||||
*
|
||||
* Because of that rule, this holder no longer constructs the projection
|
||||
* itself — it can only be populated from inside a foreground service that
|
||||
* has already called `startForeground(type=mediaProjection)`. The phone-side
|
||||
* entry point is `BridgeForegroundService.handleGrantedIntent`, which is
|
||||
* dispatched from `MainActivity.mediaProjectionLauncher`.
|
||||
*
|
||||
* The projection state is exposed as a [StateFlow] so the UI can react to
|
||||
* grants/revocations without polling. [BridgeViewModel] observes this and
|
||||
* calls `refreshPermissionStatus()` on every emission, so the green check
|
||||
* lights up immediately rather than waiting for the next lifecycle resume.
|
||||
*
|
||||
* Cleared on [revoke] (user disabled screenshots) or when the projection's
|
||||
* own `onStop` callback fires (system revoked it).
|
||||
*/
|
||||
object MediaProjectionHolder {
|
||||
@Volatile
|
||||
private var _projection: MediaProjection? = null
|
||||
|
||||
val projection: MediaProjection? get() = _projection
|
||||
private val _projectionFlow = kotlinx.coroutines.flow.MutableStateFlow<MediaProjection?>(null)
|
||||
|
||||
/**
|
||||
* Call from the Bridge UI's `ActivityResultLauncher` callback.
|
||||
* Returns true on success, false on user-rejected consent.
|
||||
* Reactive view of the current projection. Emits a fresh value every
|
||||
* time the holder is populated or cleared; null means "no active grant."
|
||||
*/
|
||||
fun onGranted(context: Context, resultCode: Int, data: Intent?): Boolean {
|
||||
val projectionFlow: kotlinx.coroutines.flow.StateFlow<MediaProjection?> = _projectionFlow
|
||||
|
||||
/**
|
||||
* Synchronous read used by [ScreenCapture] on each capture call. Always
|
||||
* matches the latest [projectionFlow] value.
|
||||
*/
|
||||
val projection: MediaProjection? get() = _projectionFlow.value
|
||||
|
||||
/**
|
||||
* Build a [MediaProjection] from a consent intent result and store it.
|
||||
* **Caller must already be inside a foreground service that has called
|
||||
* `startForeground(type=mediaProjection)`** — otherwise Android 14+ will
|
||||
* silently auto-revoke the projection. The canonical caller is
|
||||
* [com.hermesandroid.relay.bridge.BridgeForegroundService.handleGrantedIntent].
|
||||
*
|
||||
* Returns true on success, false on user-rejected consent or any
|
||||
* downstream API error.
|
||||
*/
|
||||
fun acceptGrantInsideForegroundService(
|
||||
context: Context,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
): Boolean {
|
||||
if (resultCode != android.app.Activity.RESULT_OK || data == null) {
|
||||
Log.i("MediaProjectionHolder", "consent rejected (resultCode=$resultCode)")
|
||||
return false
|
||||
}
|
||||
val manager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
@@ -405,22 +605,27 @@ object MediaProjectionHolder {
|
||||
val newProjection = try {
|
||||
manager.getMediaProjection(resultCode, data)
|
||||
} catch (t: Throwable) {
|
||||
Log.w("MediaProjectionHolder", "getMediaProjection threw: ${t.message}")
|
||||
Log.w(
|
||||
"MediaProjectionHolder",
|
||||
"getMediaProjection threw: ${t.message} — is this called inside a " +
|
||||
"foreground service that already did startForeground(mediaProjection)?"
|
||||
)
|
||||
null
|
||||
} ?: return false
|
||||
|
||||
newProjection.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
_projection = null
|
||||
_projectionFlow.value = null
|
||||
}
|
||||
}, Handler(android.os.Looper.getMainLooper()))
|
||||
|
||||
_projection = newProjection
|
||||
_projectionFlow.value = newProjection
|
||||
Log.i("MediaProjectionHolder", "MediaProjection grant accepted and stored")
|
||||
return true
|
||||
}
|
||||
|
||||
fun revoke() {
|
||||
try { _projection?.stop() } catch (_: Throwable) {}
|
||||
_projection = null
|
||||
try { _projectionFlow.value?.stop() } catch (_: Throwable) {}
|
||||
_projectionFlow.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ object ScreenCaptureRequester {
|
||||
* Activity is alive (caller should fall back to "open the app first").
|
||||
*
|
||||
* The actual grant arrives asynchronously via the launcher's result
|
||||
* callback — see `MainActivity.mediaProjectionLauncher` →
|
||||
* [MediaProjectionHolder.onGranted].
|
||||
* callback — see `MainActivity.mediaProjectionLauncher`, which hands
|
||||
* the result to `BridgeForegroundService.grantMediaProjection` so the
|
||||
* grant lands inside a foreground service that's already running with
|
||||
* `startForeground(type=mediaProjection)` (Android 14+ requirement).
|
||||
*/
|
||||
fun request(): Boolean {
|
||||
val action = launchAction ?: return false
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.hermesandroid.relay.accessibility
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import com.hermesandroid.relay.accessibility.ScreenReader.Companion.MAX_NODES
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Phase 3 — bridge feature expansion, work unit A5.
|
||||
*
|
||||
* Cheap change detection for agent navigation loops. Computes a SHA-256
|
||||
* hash over the full multi-window accessibility tree so the agent can
|
||||
* ask "did anything change since last iteration?" with ~100x less data
|
||||
* than a full [ScreenReader.readScreen] round-trip.
|
||||
*
|
||||
* # Fingerprint field set (hash stability is load-bearing)
|
||||
*
|
||||
* Each interesting node contributes:
|
||||
*
|
||||
* className | text | contentDescription | bounds | viewIdResourceName
|
||||
*
|
||||
* Joined per-node with `|`, and joined between nodes with `\u001e`
|
||||
* (ASCII record separator) so a literal `|` in text can't collide with
|
||||
* field separators. The concatenated string is hashed with SHA-256 and
|
||||
* returned as lowercase hex.
|
||||
*
|
||||
* ## Why these fields
|
||||
* * **className** — structural identity of the widget
|
||||
* * **text** — what the user sees; primary content change signal
|
||||
* * **contentDescription** — screen-reader label, relevant for icon
|
||||
* buttons whose visible text is empty
|
||||
* * **bounds** — layout geometry; catches appearance of new dialogs,
|
||||
* bottom sheets, and popups
|
||||
* * **viewIdResourceName** — stable across recompositions, lets us
|
||||
* distinguish nodes that happen to share text (e.g. two "OK" buttons)
|
||||
*
|
||||
* ## Why NOT these
|
||||
* * **isFocused / accessibility focus** — keyboard navigation toggles
|
||||
* focus without any visible content change; would cause the hash to
|
||||
* churn on arrow-key presses
|
||||
* * **isSelected** — same reasoning as focus; Material chips etc.
|
||||
* flip selected state during the ripple animation
|
||||
* * **isEnabled / isClickable** — these almost always co-vary with
|
||||
* text/bounds and dragging them in adds noise without signal
|
||||
* * **timestamps** — no timestamps in the fingerprint, obviously
|
||||
* * **window index (w<N>:<M> nodeId)** — the window index is stable
|
||||
* across reads within a snapshot, but including it in the fingerprint
|
||||
* is redundant with bounds (windows are geographically disjoint) and
|
||||
* would make the hash brittle to window-list reorderings the agent
|
||||
* doesn't care about
|
||||
*
|
||||
* ## Known limitation
|
||||
* Apps that put a live counter in a text field (e.g. "Downloading…
|
||||
* 3s", scrolling tickers, animated progress %) will churn the hash
|
||||
* every frame. The agent's calling tool documents this and recommends
|
||||
* `android_read_screen` for those edge cases.
|
||||
*
|
||||
* # Traversal
|
||||
*
|
||||
* Walks each provided root with the same child-recycling contract as
|
||||
* [ScreenReader.walk] — children are `.recycle()`'d in `try/finally`,
|
||||
* the input roots are NOT recycled (the caller owns their lifetime,
|
||||
* matching [ScreenReader.readScreen]'s convention).
|
||||
*
|
||||
* Node count is capped at [MAX_NODES] total across all windows so a
|
||||
* pathological grid can't OOM the hasher; if the cap is hit we still
|
||||
* return a hash of what we collected and set [ScreenHashResult.truncated]
|
||||
* = true.
|
||||
*
|
||||
* # Usage pattern
|
||||
*
|
||||
* ```kotlin
|
||||
* // A5 — when P1 multi-window lands, this will be
|
||||
* // service.snapshotAllWindows() -> List<AccessibilityNodeInfo>
|
||||
* // Until then callers pass a singleton list of the active root.
|
||||
* val roots: List<AccessibilityNodeInfo> = listOfNotNull(service.snapshotRoot())
|
||||
* val hash = ScreenHasher().screenHash(roots)
|
||||
* ```
|
||||
*/
|
||||
class ScreenHasher {
|
||||
|
||||
companion object {
|
||||
/** ASCII record separator (0x1E) — unlikely to appear in UI text. */
|
||||
private const val RECORD_SEPARATOR = '\u001E'
|
||||
|
||||
/** Intra-node field separator. */
|
||||
private const val FIELD_SEPARATOR = '|'
|
||||
|
||||
/** SHA-256 digest length in hex chars (64). Used by tests. */
|
||||
const val HASH_HEX_LENGTH = 64
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ScreenHashResult(
|
||||
val hash: String,
|
||||
val nodeCount: Int,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiffScreenResult(
|
||||
val changed: Boolean,
|
||||
val hash: String,
|
||||
val nodeCount: Int,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Walk the multi-window tree under [roots] and return a stable
|
||||
* SHA-256 fingerprint.
|
||||
*
|
||||
* Does NOT recycle [roots] — caller owns them, same contract as
|
||||
* [ScreenReader.readScreen].
|
||||
*/
|
||||
fun screenHash(roots: List<AccessibilityNodeInfo>): ScreenHashResult {
|
||||
val buf = StringBuilder(2048)
|
||||
var count = 0
|
||||
var truncated = false
|
||||
|
||||
for (root in roots) {
|
||||
if (count >= MAX_NODES) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
val hit = walk(root, buf, count)
|
||||
count = hit.count
|
||||
if (hit.truncated) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(buf.toString().toByteArray(Charsets.UTF_8))
|
||||
return ScreenHashResult(
|
||||
hash = digest.toHex(),
|
||||
nodeCount = count,
|
||||
truncated = truncated,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the current hash and compare against [previousHash].
|
||||
* Always returns both the new hash and the node count so the agent
|
||||
* can update its reference without a second round-trip.
|
||||
*/
|
||||
fun diffScreen(
|
||||
roots: List<AccessibilityNodeInfo>,
|
||||
previousHash: String,
|
||||
): DiffScreenResult {
|
||||
val current = screenHash(roots)
|
||||
return DiffScreenResult(
|
||||
changed = current.hash != previousHash,
|
||||
hash = current.hash,
|
||||
nodeCount = current.nodeCount,
|
||||
truncated = current.truncated,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────
|
||||
|
||||
/** Result of a walk — the running node count and a truncation flag. */
|
||||
private data class WalkResult(val count: Int, val truncated: Boolean)
|
||||
|
||||
/**
|
||||
* Append fingerprints for [node] and all descendants into [out].
|
||||
* Mirrors [ScreenReader.walk]'s recycle contract exactly.
|
||||
*/
|
||||
private fun walk(
|
||||
node: AccessibilityNodeInfo?,
|
||||
out: StringBuilder,
|
||||
startCount: Int,
|
||||
): WalkResult {
|
||||
if (node == null) return WalkResult(startCount, false)
|
||||
if (startCount >= MAX_NODES) return WalkResult(startCount, true)
|
||||
|
||||
var count = startCount
|
||||
val rect = Rect()
|
||||
node.getBoundsInScreen(rect)
|
||||
|
||||
val text = node.text?.toString()?.takeIf { it.isNotBlank() }
|
||||
val contentDesc = node.contentDescription?.toString()?.takeIf { it.isNotBlank() }
|
||||
val clickable = node.isClickable
|
||||
val longClickable = node.isLongClickable
|
||||
val scrollable = node.isScrollable
|
||||
|
||||
// Same "interesting" predicate as ScreenReader so the hash
|
||||
// covers exactly the nodes the agent can actually see/interact
|
||||
// with. Otherwise a layout container reshuffle would churn the
|
||||
// hash without any visible change.
|
||||
val interesting = (
|
||||
text != null || contentDesc != null ||
|
||||
clickable || longClickable || scrollable || node.isEditable
|
||||
) && rect.width() > 0 && rect.height() > 0
|
||||
|
||||
if (interesting) {
|
||||
appendFingerprint(
|
||||
out = out,
|
||||
className = node.className?.toString(),
|
||||
text = text,
|
||||
contentDescription = contentDesc,
|
||||
rect = rect,
|
||||
viewId = node.viewIdResourceName,
|
||||
)
|
||||
count += 1
|
||||
}
|
||||
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
if (count >= MAX_NODES) return WalkResult(count, true)
|
||||
val child = node.getChild(i) ?: continue
|
||||
try {
|
||||
val hit = walk(child, out, count)
|
||||
count = hit.count
|
||||
if (hit.truncated) return WalkResult(count, true)
|
||||
} finally {
|
||||
@Suppress("DEPRECATION")
|
||||
try { child.recycle() } catch (_: Throwable) { }
|
||||
}
|
||||
}
|
||||
|
||||
return WalkResult(count, false)
|
||||
}
|
||||
|
||||
private fun appendFingerprint(
|
||||
out: StringBuilder,
|
||||
className: String?,
|
||||
text: String?,
|
||||
contentDescription: String?,
|
||||
rect: Rect,
|
||||
viewId: String?,
|
||||
) {
|
||||
if (out.isNotEmpty()) out.append(RECORD_SEPARATOR)
|
||||
out.append(className.orEmpty()).append(FIELD_SEPARATOR)
|
||||
out.append(text.orEmpty()).append(FIELD_SEPARATOR)
|
||||
out.append(contentDescription.orEmpty()).append(FIELD_SEPARATOR)
|
||||
// Compact bounds form — matches what the agent would see in
|
||||
// ScreenNode.bounds.toString() but cheaper to build.
|
||||
out.append(rect.left).append(',')
|
||||
.append(rect.top).append(',')
|
||||
.append(rect.right).append(',')
|
||||
.append(rect.bottom).append(FIELD_SEPARATOR)
|
||||
out.append(viewId.orEmpty())
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String {
|
||||
val sb = StringBuilder(this.size * 2)
|
||||
for (b in this) {
|
||||
val v = b.toInt() and 0xff
|
||||
sb.append(HEX[v ushr 4])
|
||||
sb.append(HEX[v and 0x0f])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
@@ -1,14 +1,22 @@
|
||||
package com.hermesandroid.relay.accessibility
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Phase 3 — accessibility `accessibility-runtime`
|
||||
*
|
||||
* Walks the active-window `AccessibilityNodeInfo` tree and produces a
|
||||
* structured, serializable snapshot the agent can reason about.
|
||||
* Walks the `AccessibilityNodeInfo` trees of every active accessibility
|
||||
* window and produces a single structured, serializable snapshot the agent
|
||||
* can reason about.
|
||||
*
|
||||
* The output shape is deliberately flat: the agent overwhelmingly cares
|
||||
* about *"what text can I see, and where is it?"* — not the exact widget
|
||||
@@ -17,6 +25,47 @@ import kotlinx.serialization.Serializable
|
||||
* include its screen bounds so [ActionExecutor.tapText] can hand them to
|
||||
* `dispatchGesture` without re-walking the tree.
|
||||
*
|
||||
* # Multi-window walk (P1)
|
||||
*
|
||||
* As of P1 (`feature/P1-all-windows`) the reader walks *every* live
|
||||
* accessibility window, not just `rootInActiveWindow`. This catches system
|
||||
* overlays, popup menus, the notification shade when pulled down,
|
||||
* permission dialogs, and multi-window split-screen state — all of which
|
||||
* were previously invisible to the agent.
|
||||
*
|
||||
* ## Tree-merging strategy
|
||||
*
|
||||
* We flatten all windows into **one** `ScreenContent` rather than returning
|
||||
* per-window trees. This is deliberate: the existing public contract
|
||||
* (consumed by `BridgeCommandHandler` and the Python-side LLM prompt) is
|
||||
* "one JSON object with a `nodes[]` array". Preserving that contract means
|
||||
* no wire-format change and no consumer refactor.
|
||||
*
|
||||
* To disambiguate nodes that belong to different windows we prefix every
|
||||
* `nodeId` with `w<windowIndex>:<sequentialIndex>`. `windowIndex` is the
|
||||
* position in `service.windows` (top-of-stack first, per Android docs),
|
||||
* `sequentialIndex` is the 0-based collection order within the combined
|
||||
* walk. The root bounds emitted on `ScreenContent.rootBounds` are the
|
||||
* **union** of every window's root bounds, so geometric callers still see
|
||||
* a single enclosing rectangle.
|
||||
*
|
||||
* ## MAX_NODES cap
|
||||
*
|
||||
* The 512-node cap applies to the *combined* tree, not per-window. If the
|
||||
* first window alone exceeds the cap we short-circuit the whole walk and
|
||||
* set `truncated = true` without descending into later windows — the agent
|
||||
* already has more than it can reasonably use.
|
||||
*
|
||||
* ## Node recycling landmine
|
||||
*
|
||||
* Every `AccessibilityWindowInfo.getRoot()` returns a **fresh**
|
||||
* `AccessibilityNodeInfo` that must be recycled by the caller. Every
|
||||
* `info.getChild(i)` also returns a fresh ref. The public methods here
|
||||
* do NOT recycle their input roots — the caller
|
||||
* ([HermesAccessibilityService.snapshotAllWindows] in practice) owns the
|
||||
* lifetime of the roots it produced. Child nodes fetched during the walk
|
||||
* are recycled per-iteration in `try/finally`.
|
||||
*
|
||||
* The tree is bounded by [MAX_NODES] to prevent pathological apps (grids
|
||||
* with thousands of cells) from producing multi-megabyte screen dumps.
|
||||
* When the cap is hit we short-circuit traversal and set
|
||||
@@ -29,7 +78,8 @@ class ScreenReader {
|
||||
/**
|
||||
* Hard cap on node count. 512 is comfortable for a typical app
|
||||
* screen (most have 30–150 interesting nodes) while keeping the
|
||||
* wire payload under ~40 KB in the worst case.
|
||||
* wire payload under ~40 KB in the worst case. With P1 the cap
|
||||
* now spans the *combined* tree across all live windows.
|
||||
*/
|
||||
const val MAX_NODES = 512
|
||||
|
||||
@@ -40,7 +90,8 @@ class ScreenReader {
|
||||
/**
|
||||
* Structured representation of a screen, ready for JSON serialization.
|
||||
* The [rootBounds] are in absolute screen pixels (what `dispatchGesture`
|
||||
* uses).
|
||||
* uses). When P1 merges multiple windows, [rootBounds] is the union of
|
||||
* every window's root bounds.
|
||||
*/
|
||||
@Serializable
|
||||
data class ScreenContent(
|
||||
@@ -53,6 +104,22 @@ class ScreenReader {
|
||||
|
||||
@Serializable
|
||||
data class ScreenNode(
|
||||
/**
|
||||
* Stable, walk-scoped identifier for this node. Format:
|
||||
* `w<windowIndex>:<sequentialIndex>` — e.g. `w0:42` for the 43rd
|
||||
* emitted node from the top-of-stack window, `w1:7` for the 8th
|
||||
* emitted node of the next window down. Always present when the
|
||||
* node was produced by [readAllWindows]; may be null for callers
|
||||
* that bypass the multi-window entry point (legacy tests).
|
||||
*
|
||||
* Also re-used by A3 `searchNodes` so filtered results feed back
|
||||
* into `tap nodeId` and other node-ID-addressable commands.
|
||||
*
|
||||
* The Python `android_tool` layer has long advertised a `nodeId`
|
||||
* field in its doc-comments; P1 is the first version that actually
|
||||
* emits it on the wire.
|
||||
*/
|
||||
val nodeId: String? = null,
|
||||
val text: String? = null,
|
||||
val contentDescription: String? = null,
|
||||
val className: String? = null,
|
||||
@@ -82,11 +149,12 @@ class ScreenReader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree rooted at [rootNode] and return a [ScreenContent]
|
||||
* snapshot.
|
||||
* Back-compat single-root entry point. Delegates to [readAllWindows]
|
||||
* with a single-element list so the walk semantics (cap, recycling,
|
||||
* node-id prefix) stay identical regardless of which public method
|
||||
* the caller picked.
|
||||
*
|
||||
* This method does NOT recycle [rootNode] — the caller owns it (the
|
||||
* caller also owns the lifetime contract with `rootInActiveWindow`).
|
||||
* This method does NOT recycle [rootNode] — the caller owns it.
|
||||
*
|
||||
* @param includeBounds when false, we still collect bounds for
|
||||
* traversal decisions but zero them in the output to shrink the
|
||||
@@ -96,15 +164,78 @@ class ScreenReader {
|
||||
fun readScreen(
|
||||
rootNode: AccessibilityNodeInfo,
|
||||
includeBounds: Boolean = true,
|
||||
): ScreenContent = readAllWindows(listOf(rootNode), includeBounds)
|
||||
|
||||
/**
|
||||
* P1 multi-window entry point. Walks every root in [windowRoots] in
|
||||
* order (top-of-stack first) and emits a single flat [ScreenContent]
|
||||
* with node IDs prefixed by window index.
|
||||
*
|
||||
* The [windowRoots] list is NOT recycled by this method — callers
|
||||
* ([HermesAccessibilityService.snapshotAllWindows] + finally blocks
|
||||
* in `BridgeCommandHandler`) own the roots they produced. Child nodes
|
||||
* fetched during traversal ARE recycled per-iteration.
|
||||
*
|
||||
* @param includeBounds see [readScreen].
|
||||
*/
|
||||
fun readAllWindows(
|
||||
windowRoots: List<AccessibilityNodeInfo>,
|
||||
includeBounds: Boolean = true,
|
||||
): ScreenContent {
|
||||
val collected = ArrayList<ScreenNode>(128)
|
||||
val rootRect = Rect().also { rootNode.getBoundsInScreen(it) }
|
||||
val rootBounds = rootRect.toBoundsOrZero()
|
||||
var truncated = false
|
||||
|
||||
val truncated = walk(rootNode, collected, includeBounds)
|
||||
// Union of every window's root bounds. Starts empty and absorbs
|
||||
// each window's root rect via Rect.union(), so one-window callers
|
||||
// see identical output to the pre-P1 code path.
|
||||
val unionRect = Rect()
|
||||
var unionInitialized = false
|
||||
var packageName: String? = null
|
||||
|
||||
for ((windowIndex, rootNode) in windowRoots.withIndex()) {
|
||||
if (collected.size >= MAX_NODES) {
|
||||
// Cap already exhausted by an earlier window — do not
|
||||
// descend. This is intentional: the agent has more than
|
||||
// it can reason about and we'd rather be honest about it
|
||||
// than silently clip the later-window content.
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
|
||||
// Latch the first window's package name as the "primary" one.
|
||||
// Multi-window state with different packages (e.g. a popup from
|
||||
// a different process) is rare, and the agent can still see
|
||||
// the full package-name string on each node via className if
|
||||
// it needs finer-grained attribution.
|
||||
if (packageName == null) {
|
||||
packageName = rootNode.packageName?.toString()
|
||||
}
|
||||
|
||||
val rootRect = Rect()
|
||||
rootNode.getBoundsInScreen(rootRect)
|
||||
if (!unionInitialized) {
|
||||
unionRect.set(rootRect)
|
||||
unionInitialized = true
|
||||
} else {
|
||||
unionRect.union(rootRect)
|
||||
}
|
||||
|
||||
val hit = walk(
|
||||
node = rootNode,
|
||||
windowIndex = windowIndex,
|
||||
out = collected,
|
||||
includeBounds = includeBounds,
|
||||
)
|
||||
if (hit) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val rootBounds = if (unionInitialized) unionRect.toBoundsOrZero() else ZERO_BOUNDS
|
||||
|
||||
return ScreenContent(
|
||||
packageName = rootNode.packageName?.toString(),
|
||||
packageName = packageName,
|
||||
rootBounds = rootBounds,
|
||||
nodes = collected,
|
||||
truncated = truncated,
|
||||
@@ -114,9 +245,13 @@ class ScreenReader {
|
||||
/**
|
||||
* Recursive walker. Returns `true` if the cap was hit (signaling
|
||||
* caller to mark output as truncated).
|
||||
*
|
||||
* [windowIndex] is the position in the parent walk's `windowRoots`
|
||||
* list and becomes the `w<n>:` prefix on every emitted node ID.
|
||||
*/
|
||||
private fun walk(
|
||||
node: AccessibilityNodeInfo?,
|
||||
windowIndex: Int,
|
||||
out: MutableList<ScreenNode>,
|
||||
includeBounds: Boolean,
|
||||
): Boolean {
|
||||
@@ -146,8 +281,16 @@ class ScreenReader {
|
||||
!bounds.isEmpty
|
||||
|
||||
if (interesting) {
|
||||
// Node ID is `w<windowIndex>:<sequentialIndex>`. Using the
|
||||
// sequential emission index (not a hash of the node) keeps
|
||||
// IDs stable within a single snapshot and compact on the
|
||||
// wire, at the cost of being snapshot-scoped (not durable
|
||||
// across successive /screen calls). That's the right tradeoff
|
||||
// — the agent re-reads the screen before each action anyway.
|
||||
val nodeId = "w$windowIndex:${out.size}"
|
||||
out.add(
|
||||
ScreenNode(
|
||||
nodeId = nodeId,
|
||||
text = text,
|
||||
contentDescription = contentDesc,
|
||||
className = node.className?.toString(),
|
||||
@@ -169,7 +312,7 @@ class ScreenReader {
|
||||
if (out.size >= MAX_NODES) return true
|
||||
val child = node.getChild(i) ?: continue
|
||||
try {
|
||||
val hit = walk(child, out, includeBounds)
|
||||
val hit = walk(child, windowIndex, out, includeBounds)
|
||||
if (hit) return true
|
||||
} finally {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -181,38 +324,246 @@ class ScreenReader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first descendant node whose text or content-description
|
||||
* contains [needle] (case-insensitive). Used by
|
||||
* [ActionExecutor.tapText]. Returns the node's center bounds, or null
|
||||
* if no match.
|
||||
* Filtered search across all provided window roots. Used by
|
||||
* `android_find_nodes` — returns up to [limit] matches without
|
||||
* dumping the whole accessibility tree.
|
||||
*
|
||||
* Filter semantics (all optional, all ANDed):
|
||||
* - [text]: case-insensitive substring match against each node's
|
||||
* `text` OR `contentDescription`.
|
||||
* - [className]: exact match against `node.className.toString()`.
|
||||
* - [clickable]: when non-null, filters on `node.isClickable`.
|
||||
*
|
||||
* The underlying traversal honors [MAX_NODES] as a safety rail even
|
||||
* when [limit] is higher — we stop walking after visiting 512 nodes
|
||||
* regardless of how many matched. Node recycling follows the same
|
||||
* per-child `try/finally` pattern as [walk], so this function is
|
||||
* leak-free w.r.t. the accessibility node pool.
|
||||
*
|
||||
* Returns a list of [ScreenNode] in the same shape that
|
||||
* [readAllWindows] / [readScreen] emits, including the P1 `nodeId`
|
||||
* field (`"w<windowIndex>:<sequentialIndex>"`) so callers can feed
|
||||
* results back into `tap nodeId`.
|
||||
*/
|
||||
fun searchNodes(
|
||||
roots: List<AccessibilityNodeInfo>,
|
||||
text: String? = null,
|
||||
className: String? = null,
|
||||
clickable: Boolean? = null,
|
||||
limit: Int = 20,
|
||||
): List<ScreenNode> {
|
||||
if (limit <= 0) return emptyList()
|
||||
val loweredText = text?.takeIf { it.isNotBlank() }?.lowercase()
|
||||
val effectiveLimit = limit.coerceAtLeast(0)
|
||||
|
||||
val out = ArrayList<ScreenNode>(effectiveLimit.coerceAtMost(64))
|
||||
// Shared visit counter across all windows — the MAX_NODES cap is
|
||||
// global, matching how `readAllWindows` (P1) bounds traversal.
|
||||
val visited = intArrayOf(0)
|
||||
// H2 fix: hoist the emission-id counter OUTSIDE the per-window loop
|
||||
// so it mirrors `walk`/`findNodeById`'s GLOBAL "interesting" counter
|
||||
// exactly. Previously this was scoped to each window, producing IDs
|
||||
// like `w1:0` when the canonical scheme is `w1:N` (N = global). Round
|
||||
// trips through `find_nodes → tap nodeId` then resolved to the wrong
|
||||
// node on any screen with >1 window.
|
||||
val nextIndex = intArrayOf(0)
|
||||
|
||||
for ((windowIndex, root) in roots.withIndex()) {
|
||||
if (out.size >= effectiveLimit) break
|
||||
if (visited[0] >= MAX_NODES) break
|
||||
searchWalk(
|
||||
node = root,
|
||||
windowIndex = windowIndex,
|
||||
nextIndex = nextIndex,
|
||||
visited = visited,
|
||||
loweredText = loweredText,
|
||||
className = className,
|
||||
clickable = clickable,
|
||||
limit = effectiveLimit,
|
||||
out = out,
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive walker for [searchNodes]. Returns nothing; accumulates
|
||||
* matches into [out] and respects both [MAX_NODES] (via [visited])
|
||||
* and [limit] (via `out.size`).
|
||||
*
|
||||
* [nextIndex] is the per-window sequential counter used to build the
|
||||
* `w<windowIndex>:<N>` node ID — mirrors the scheme P1 introduced in
|
||||
* [readAllWindows] so the two surfaces produce stable, comparable IDs.
|
||||
*/
|
||||
private fun searchWalk(
|
||||
node: AccessibilityNodeInfo?,
|
||||
windowIndex: Int,
|
||||
nextIndex: IntArray,
|
||||
visited: IntArray,
|
||||
loweredText: String?,
|
||||
className: String?,
|
||||
clickable: Boolean?,
|
||||
limit: Int,
|
||||
out: MutableList<ScreenNode>,
|
||||
) {
|
||||
if (node == null) return
|
||||
if (out.size >= limit) return
|
||||
if (visited[0] >= MAX_NODES) return
|
||||
|
||||
visited[0] += 1
|
||||
|
||||
val nodeText = node.text?.toString()?.takeIf { it.isNotBlank() }?.take(MAX_TEXT_LEN)
|
||||
val contentDesc = node.contentDescription?.toString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.take(MAX_TEXT_LEN)
|
||||
val nodeClassName = node.className?.toString()
|
||||
val nodeClickable = node.isClickable
|
||||
val nodeLongClickable = node.isLongClickable
|
||||
val nodeScrollable = node.isScrollable
|
||||
val nodeEditable = node.isEditable
|
||||
|
||||
// H2 fix: nextIndex must mirror `walk`/`findNodeById`'s emission
|
||||
// counter exactly — increment ONLY for nodes that pass the canonical
|
||||
// "interesting" predicate (text || contentDesc || clickable ||
|
||||
// longClickable || scrollable || editable, with non-empty bounds),
|
||||
// not for every visited node. Otherwise the IDs handed back from
|
||||
// find_nodes drift relative to readAllWindows + findNodeById and
|
||||
// round-trip lookups silently resolve to the wrong node.
|
||||
val rect = Rect()
|
||||
node.getBoundsInScreen(rect)
|
||||
val bounds = rect.toBoundsOrZero()
|
||||
val canonicalInteresting = (nodeText != null || contentDesc != null ||
|
||||
nodeClickable || nodeLongClickable || nodeScrollable || nodeEditable) &&
|
||||
!bounds.isEmpty
|
||||
|
||||
val thisNodeIndex: Int
|
||||
if (canonicalInteresting) {
|
||||
thisNodeIndex = nextIndex[0]
|
||||
nextIndex[0] += 1
|
||||
} else {
|
||||
thisNodeIndex = -1
|
||||
}
|
||||
|
||||
val matchesText = loweredText == null ||
|
||||
(nodeText?.lowercase()?.contains(loweredText) == true) ||
|
||||
(contentDesc?.lowercase()?.contains(loweredText) == true)
|
||||
val matchesClass = className == null || nodeClassName == className
|
||||
val matchesClickable = clickable == null || nodeClickable == clickable
|
||||
|
||||
// Only emit nodes that BOTH match the search filter AND are
|
||||
// canonically-interesting (i.e. would also be emitted by `walk`).
|
||||
// Restricting emission to canonical nodes is what makes the round
|
||||
// trip find_nodes → tap nodeId reliable.
|
||||
if (canonicalInteresting && matchesText && matchesClass && matchesClickable) {
|
||||
if (!bounds.isEmpty || loweredText != null || className != null) {
|
||||
out.add(
|
||||
ScreenNode(
|
||||
nodeId = "w$windowIndex:$thisNodeIndex",
|
||||
text = nodeText,
|
||||
contentDescription = contentDesc,
|
||||
className = nodeClassName,
|
||||
viewId = node.viewIdResourceName,
|
||||
bounds = bounds,
|
||||
clickable = nodeClickable,
|
||||
longClickable = node.isLongClickable,
|
||||
scrollable = node.isScrollable,
|
||||
editable = node.isEditable,
|
||||
focused = node.isFocused,
|
||||
selected = node.isSelected,
|
||||
enabled = node.isEnabled,
|
||||
)
|
||||
)
|
||||
if (out.size >= limit) return
|
||||
}
|
||||
}
|
||||
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
if (out.size >= limit) return
|
||||
if (visited[0] >= MAX_NODES) return
|
||||
val child = node.getChild(i) ?: continue
|
||||
try {
|
||||
searchWalk(
|
||||
node = child,
|
||||
windowIndex = windowIndex,
|
||||
nextIndex = nextIndex,
|
||||
visited = visited,
|
||||
loweredText = loweredText,
|
||||
className = className,
|
||||
clickable = clickable,
|
||||
limit = limit,
|
||||
out = out,
|
||||
)
|
||||
} finally {
|
||||
@Suppress("DEPRECATION")
|
||||
try { child.recycle() } catch (_: Throwable) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first node across [windowRoots] whose text or
|
||||
* content-description contains [needle] (case-insensitive). Used by
|
||||
* [ActionExecutor.tapText]. Returns the node's bounds, or null if no
|
||||
* match anywhere.
|
||||
*
|
||||
* We walk fresh (not against a cached [ScreenContent]) so the result
|
||||
* is always current — tapping into stale bounds is the single most
|
||||
* common "bridge tapped the wrong thing" bug.
|
||||
*/
|
||||
fun findNodeBoundsByText(rootNode: AccessibilityNodeInfo, needle: String): Bounds? {
|
||||
fun findNodeBoundsByText(
|
||||
windowRoots: List<AccessibilityNodeInfo>,
|
||||
needle: String,
|
||||
): Bounds? {
|
||||
if (needle.isBlank()) return null
|
||||
val lowered = needle.lowercase()
|
||||
return findFirst(rootNode) { node ->
|
||||
val text = node.text?.toString()?.lowercase()
|
||||
val desc = node.contentDescription?.toString()?.lowercase()
|
||||
(text?.contains(lowered) == true) || (desc?.contains(lowered) == true)
|
||||
}?.let { found ->
|
||||
val r = Rect()
|
||||
found.getBoundsInScreen(r)
|
||||
r.toBoundsOrZero()
|
||||
for (root in windowRoots) {
|
||||
val hit = findFirst(root) { node ->
|
||||
val text = node.text?.toString()?.lowercase()
|
||||
val desc = node.contentDescription?.toString()?.lowercase()
|
||||
(text?.contains(lowered) == true) || (desc?.contains(lowered) == true)
|
||||
}
|
||||
if (hit != null) {
|
||||
val r = Rect()
|
||||
hit.getBoundsInScreen(r)
|
||||
return r.toBoundsOrZero()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the currently-focused input node (for [ActionExecutor.typeText]).
|
||||
* Prefers `FOCUS_INPUT` focus, falls back to the first editable node.
|
||||
* Back-compat single-root overload. Delegates to the multi-window
|
||||
* form so tests and any legacy call site still compile unchanged.
|
||||
*/
|
||||
fun findFocusedInput(rootNode: AccessibilityNodeInfo): AccessibilityNodeInfo? {
|
||||
rootNode.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)?.let { return it }
|
||||
return findFirst(rootNode) { it.isEditable }
|
||||
fun findNodeBoundsByText(rootNode: AccessibilityNodeInfo, needle: String): Bounds? =
|
||||
findNodeBoundsByText(listOf(rootNode), needle)
|
||||
|
||||
/**
|
||||
* Find the currently-focused input node across [windowRoots] (for
|
||||
* [ActionExecutor.typeText]). Prefers `FOCUS_INPUT` focus on each
|
||||
* window in order, falls back to the first editable node found in any
|
||||
* window.
|
||||
*
|
||||
* Returned node is caller-owned — must be `recycle()`d on API < 33.
|
||||
*/
|
||||
fun findFocusedInput(windowRoots: List<AccessibilityNodeInfo>): AccessibilityNodeInfo? {
|
||||
for (root in windowRoots) {
|
||||
root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)?.let { return it }
|
||||
}
|
||||
for (root in windowRoots) {
|
||||
findFirst(root) { it.isEditable }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat single-root overload. Delegates to the multi-window
|
||||
* form.
|
||||
*/
|
||||
fun findFocusedInput(rootNode: AccessibilityNodeInfo): AccessibilityNodeInfo? =
|
||||
findFocusedInput(listOf(rootNode))
|
||||
|
||||
private fun findFirst(
|
||||
node: AccessibilityNodeInfo?,
|
||||
predicate: (AccessibilityNodeInfo) -> Boolean,
|
||||
@@ -230,6 +581,225 @@ class ScreenReader {
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── A4: describe_node + stable nodeId lookup ────────────────────────────
|
||||
//
|
||||
// `findNodeById` re-walks the window tree every call. We deliberately do
|
||||
// NOT cache IDs between calls — the UI changes, nodes come and go, and a
|
||||
// cached lookup table would be stale the moment the user scrolled. The
|
||||
// walker assigns the same `w<windowIndex>:<sequentialIndex>` IDs that the
|
||||
// P1 multi-window [walk] emits, so a nodeId from `read_screen` round-trips
|
||||
// cleanly into `describe_node`, `/tap`, and `/scroll` during the same
|
||||
// screen dwell.
|
||||
//
|
||||
// IMPORTANT: the counter MUST mirror P1 semantics exactly:
|
||||
// 1. Only "interesting" nodes (text/contentDesc/clickable/longClickable/
|
||||
// scrollable/editable AND non-empty bounds) get an ID.
|
||||
// 2. The sequential index is a GLOBAL pre-order emission counter
|
||||
// shared across all windows (not per-window). Window 0 emits a
|
||||
// handful of nodes at 0..N-1, then window 1's first emission is
|
||||
// N, not 0. This matches how readAllWindows feeds a single
|
||||
// `collected` list into multiple `walk()` calls.
|
||||
//
|
||||
// IMPORTANT: the caller takes ownership of the returned node and is
|
||||
// responsible for `node.recycle()`. We stop recycling at the match frontier
|
||||
// and let it bubble up. `describeNode` below handles this contract.
|
||||
|
||||
/**
|
||||
* Walk every window root in [roots] and return the first node whose
|
||||
* assigned stable ID matches [nodeId]. Returns `null` if no match.
|
||||
*
|
||||
* ID format is `w<windowIndex>:<sequentialIndex>` — the same scheme the
|
||||
* P1 multi-window [walk] emits on [ScreenNode.nodeId]. The sequential
|
||||
* index is a 0-based GLOBAL emission counter (shared across windows)
|
||||
* that increments only for "interesting" nodes — matching the P1 filter
|
||||
* in [walk] (`interesting = has text/desc/clickable/longClickable/
|
||||
* scrollable/editable AND non-empty bounds`).
|
||||
*
|
||||
* The caller takes ownership of the returned [AccessibilityNodeInfo] and
|
||||
* MUST recycle it (on API <= 33) when done. We stop recycling at the
|
||||
* match frontier so the node survives the return trip.
|
||||
*/
|
||||
fun findNodeById(
|
||||
roots: List<AccessibilityNodeInfo>,
|
||||
nodeId: String,
|
||||
): AccessibilityNodeInfo? {
|
||||
if (nodeId.isBlank()) return null
|
||||
// Parse `w<windowIdx>:<seqIdx>`. Reject malformed IDs up front so we
|
||||
// don't spend O(tree) walking when the input can't possibly match.
|
||||
val colonIdx = nodeId.indexOf(':')
|
||||
if (colonIdx <= 1 || nodeId[0] != 'w') return null
|
||||
val wantedWindow = nodeId.substring(1, colonIdx).toIntOrNull() ?: return null
|
||||
val wantedSeq = nodeId.substring(colonIdx + 1).toIntOrNull() ?: return null
|
||||
if (wantedWindow < 0 || wantedWindow >= roots.size || wantedSeq < 0) return null
|
||||
|
||||
// GLOBAL counter, shared across every window's walk to match
|
||||
// readAllWindows' shared `collected` list ordering.
|
||||
val counter = IntArray(1)
|
||||
for ((windowIndex, root) in roots.withIndex()) {
|
||||
// Cheap pre-filter: the wantedSeq is global, so we still have to
|
||||
// walk earlier windows to drain their interesting-node emission
|
||||
// counts, BUT once we're at or past the wanted window we can
|
||||
// look for the match. We only RETURN a match when we're on the
|
||||
// correct windowIndex AND the global counter reaches wantedSeq.
|
||||
val hit = walkForId(root, wantedSeq, counter, windowIndex, wantedWindow)
|
||||
if (hit != null) return hit
|
||||
if (counter[0] > wantedSeq) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive node-id walker. Increments [counter] only for "interesting"
|
||||
* nodes (matching P1's [walk] semantics). Returns a non-null match
|
||||
* (caller-owned and responsible for recycling) OR null if this subtree
|
||||
* doesn't contain it.
|
||||
*
|
||||
* [currentWindow] is the index of the window currently being walked;
|
||||
* [wantedWindow] is the window portion of the parsed nodeId. We only
|
||||
* return a hit when they match — earlier and later windows still
|
||||
* contribute to the global counter but can't claim the match.
|
||||
*
|
||||
* Recycling rules:
|
||||
* - Children that don't contain the match are recycled in-place.
|
||||
* - The matched node bubbles up un-recycled — the outermost caller owns it.
|
||||
* - The root node itself is never recycled here; the caller of
|
||||
* `findNodeById` owns window roots (same contract as `snapshotAllWindows`).
|
||||
*/
|
||||
private fun walkForId(
|
||||
node: AccessibilityNodeInfo?,
|
||||
wantedSeq: Int,
|
||||
counter: IntArray,
|
||||
currentWindow: Int,
|
||||
wantedWindow: Int,
|
||||
): AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
if (counter[0] > wantedSeq) return null
|
||||
|
||||
// Determine whether this node would be emitted by P1's [walk].
|
||||
// Must mirror the `interesting` predicate there exactly or the
|
||||
// counter drifts and the round-trip breaks.
|
||||
val rect = Rect()
|
||||
node.getBoundsInScreen(rect)
|
||||
val bounds = rect.toBoundsOrZero()
|
||||
val nodeText = node.text?.toString()?.takeIf { it.isNotBlank() }
|
||||
val contentDesc = node.contentDescription?.toString()?.takeIf { it.isNotBlank() }
|
||||
val clickable = node.isClickable
|
||||
val longClickable = node.isLongClickable
|
||||
val scrollable = node.isScrollable
|
||||
val interesting = (nodeText != null || contentDesc != null ||
|
||||
clickable || longClickable || scrollable || node.isEditable) &&
|
||||
!bounds.isEmpty
|
||||
|
||||
if (interesting) {
|
||||
val mySeq = counter[0]
|
||||
counter[0] = mySeq + 1
|
||||
if (currentWindow == wantedWindow && mySeq == wantedSeq) {
|
||||
// Match. Bubble up without recycling.
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
if (counter[0] > wantedSeq) return null
|
||||
val child = node.getChild(i) ?: continue
|
||||
val hit = walkForId(child, wantedSeq, counter, currentWindow, wantedWindow)
|
||||
if (hit != null) {
|
||||
// Match in this subtree — don't recycle the hit.
|
||||
return hit
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
try { child.recycle() } catch (_: Throwable) { }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A4: result of a `describe_node` lookup. Serialized directly into the
|
||||
* `bridge.response` result payload by [BridgeCommandHandler].
|
||||
*
|
||||
* When [found] is false, [properties] is null and [error] explains why.
|
||||
*/
|
||||
data class DescribeNodeResult(
|
||||
val found: Boolean,
|
||||
val properties: JsonObject? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A4: return the full property bag for [nodeId] on the current window
|
||||
* set. Props: `nodeId`, `bounds`, `className`, `text`, `contentDescription`,
|
||||
* `hintText` (API 26+), `viewIdResourceName`, `childCount`, plus a dozen
|
||||
* state flags. `checked` is null when the node isn't checkable so callers
|
||||
* can distinguish "not a toggle" from "unchecked toggle".
|
||||
*
|
||||
* Walks the tree via [findNodeById], builds the JSON, and recycles the
|
||||
* resolved node before returning.
|
||||
*/
|
||||
fun describeNode(
|
||||
roots: List<AccessibilityNodeInfo>,
|
||||
nodeId: String,
|
||||
): DescribeNodeResult {
|
||||
val node = findNodeById(roots, nodeId)
|
||||
?: return DescribeNodeResult(found = false, error = "node not found: $nodeId")
|
||||
|
||||
try {
|
||||
val rect = Rect().also { node.getBoundsInScreen(it) }
|
||||
val bounds = rect.toBoundsOrZero()
|
||||
|
||||
// hintText is API 26+. minSdk on this project is 26, so in practice
|
||||
// it's always available — but we guard anyway to keep the property
|
||||
// out of the payload on devices where the API call would throw.
|
||||
val hintText: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
node.hintText?.toString()
|
||||
} else null
|
||||
|
||||
// Local helper to collapse blank/null strings to JsonNull. A
|
||||
// local lambda rather than an extension `put` overload so we
|
||||
// don't shadow `JsonObjectBuilder.put(String, String?)`.
|
||||
fun strOrNull(raw: String?): JsonElement =
|
||||
if (raw.isNullOrBlank()) JsonNull else JsonPrimitive(raw)
|
||||
|
||||
val props: JsonObject = buildJsonObject {
|
||||
put("nodeId", nodeId)
|
||||
put("bounds", buildJsonObject {
|
||||
put("left", bounds.left)
|
||||
put("top", bounds.top)
|
||||
put("right", bounds.right)
|
||||
put("bottom", bounds.bottom)
|
||||
put("centerX", bounds.centerX)
|
||||
put("centerY", bounds.centerY)
|
||||
put("width", bounds.width)
|
||||
put("height", bounds.height)
|
||||
})
|
||||
put("className", strOrNull(node.className?.toString()))
|
||||
put("text", strOrNull(node.text?.toString()))
|
||||
put("contentDescription", strOrNull(node.contentDescription?.toString()))
|
||||
put("hintText", strOrNull(hintText))
|
||||
put("viewIdResourceName", strOrNull(node.viewIdResourceName))
|
||||
put("childCount", node.childCount)
|
||||
put("clickable", node.isClickable)
|
||||
put("longClickable", node.isLongClickable)
|
||||
put("focusable", node.isFocusable)
|
||||
put("focused", node.isFocused)
|
||||
put("editable", node.isEditable)
|
||||
put("scrollable", node.isScrollable)
|
||||
put("checkable", node.isCheckable)
|
||||
// Null vs false is load-bearing: null = "not a toggle",
|
||||
// false = "unchecked toggle".
|
||||
put("checked", if (node.isCheckable) JsonPrimitive(node.isChecked) else JsonNull)
|
||||
put("enabled", node.isEnabled)
|
||||
put("selected", node.isSelected)
|
||||
put("password", node.isPassword)
|
||||
}
|
||||
|
||||
return DescribeNodeResult(found = true, properties = props)
|
||||
} finally {
|
||||
@Suppress("DEPRECATION")
|
||||
try { node.recycle() } catch (_: Throwable) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Rect.toBoundsOrZero(): Bounds =
|
||||
Bounds(left = left, top = top, right = right, bottom = bottom)
|
||||
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
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.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 {
|
||||
audioSource.start()
|
||||
maybeAttachEffects()
|
||||
|
||||
while (isActive) {
|
||||
val read = audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
|
||||
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
|
||||
}
|
||||
|
||||
val result = vadEngine.analyze(frameBuffer)
|
||||
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() {
|
||||
readerJob?.cancel()
|
||||
readerJob = null
|
||||
}
|
||||
|
||||
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
|
||||
} 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,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,58 @@
|
||||
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 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 +62,220 @@ 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
|
||||
|
||||
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
|
||||
|
||||
init {
|
||||
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 && !visualizerAttached) {
|
||||
attachVisualizer(exoPlayer.audioSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Exposed read-only. Internally the same id drives the Visualizer
|
||||
* attach logic in [attachVisualizer]; B4 reads it via a provider
|
||||
* lambda so the listener can re-check across the 1 s poll window
|
||||
* without holding a stale reference.
|
||||
*/
|
||||
val audioSessionId: Int
|
||||
get() = exoPlayer.audioSessionId
|
||||
|
||||
/**
|
||||
* 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 +303,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 +341,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()
|
||||
|
||||
@@ -1,24 +1,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.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
|
||||
@@ -56,16 +64,117 @@ 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 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"
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
Profile(
|
||||
name = name,
|
||||
model = model,
|
||||
description = description,
|
||||
systemMessage = systemMessage,
|
||||
gatewayRunning = gatewayRunning,
|
||||
hasSoul = hasSoul,
|
||||
skillCount = skillCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -90,8 +199,19 @@ class AuthManager(
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Multi-connection: pick the EncryptedSharedPreferences
|
||||
// filename based on the bound connection. The legacy sentinel
|
||||
// keeps the pre-multi-connection install on its original file
|
||||
// so the existing paired device keeps working with no
|
||||
// migration.
|
||||
val prefsName = tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context) ?: LegacyEncryptedPrefsTokenStore(context)
|
||||
KeystoreTokenStore.tryCreate(context, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(context, prefsName)
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
@@ -107,13 +227,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
|
||||
@@ -205,8 +336,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
|
||||
@@ -219,6 +379,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 {
|
||||
@@ -227,6 +393,13 @@ class AuthManager(
|
||||
if (existingToken != null) {
|
||||
_authState.value = AuthState.Paired(existingToken)
|
||||
_currentPairedSession.value = loadStoredMetadata(existingToken)
|
||||
Log.i(
|
||||
TAG,
|
||||
"init: hydrated existing session_token=${existingToken.take(8)}… " +
|
||||
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
|
||||
}
|
||||
_apiKeyPresent.value = !s.getString(KEY_API_KEY).isNullOrBlank()
|
||||
}
|
||||
@@ -313,6 +486,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]
|
||||
@@ -332,6 +508,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.
|
||||
*
|
||||
@@ -354,8 +545,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)}…, " +
|
||||
"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)
|
||||
}
|
||||
@@ -363,6 +563,12 @@ class AuthManager(
|
||||
else -> {
|
||||
_authState.value = AuthState.Pairing
|
||||
val codeToSend = serverIssuedCode ?: _pairingCode.value
|
||||
val serverSource = if (serverIssuedCode != null) "QR" else "local-fallback"
|
||||
Log.i(
|
||||
TAG,
|
||||
"authenticate: sending pairing_code=$codeToSend source=$serverSource " +
|
||||
"ttl=$pendingTtlSeconds grants=${pendingGrants?.keys}"
|
||||
)
|
||||
buildJsonObject {
|
||||
put("pairing_code", codeToSend)
|
||||
put("device_id", deviceId)
|
||||
@@ -416,14 +622,24 @@ class AuthManager(
|
||||
*/
|
||||
fun applyServerIssuedCodeAndReset(code: String, relayUrl: String? = null) {
|
||||
val normalized = code.trim().uppercase()
|
||||
if (normalized.isEmpty()) return
|
||||
if (normalized.isEmpty()) {
|
||||
Log.w(TAG, "applyServerIssuedCodeAndReset: empty code, returning early — authState NOT reset")
|
||||
return
|
||||
}
|
||||
val prevState = _authState.value
|
||||
serverIssuedCode = normalized
|
||||
_pairingCode.value = normalized
|
||||
_authState.value = AuthState.Unpaired
|
||||
_currentPairedSession.value = null
|
||||
Log.i(
|
||||
TAG,
|
||||
"applyServerIssuedCodeAndReset: code=$normalized relayUrl=$relayUrl " +
|
||||
"prevState=${prevState::class.simpleName} → Unpaired"
|
||||
)
|
||||
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)
|
||||
@@ -432,12 +648,62 @@ class AuthManager(
|
||||
}
|
||||
|
||||
override fun onMessage(envelope: Envelope) {
|
||||
Log.i(TAG, "onMessage channel=${envelope.channel} type=${envelope.type}")
|
||||
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()
|
||||
}
|
||||
@@ -446,6 +712,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
|
||||
@@ -483,10 +750,27 @@ class AuthManager(
|
||||
val payload = envelope.payload
|
||||
val token = payload["session_token"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
if (token == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleAuthOk: payload missing session_token — authState NOT transitioned to Paired. " +
|
||||
"Payload keys: ${payload.keys}"
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
// upgrade to a long-lived session token has landed.
|
||||
serverIssuedCode = null
|
||||
@@ -527,32 +811,122 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAuthFail(envelope: Envelope) {
|
||||
try {
|
||||
val reason = envelope.payload["reason"]?.jsonPrimitive?.contentOrNull ?: "Unknown error"
|
||||
_authState.value = AuthState.Failed(reason)
|
||||
val rawReason = envelope.payload["reason"]?.jsonPrimitive?.contentOrNull
|
||||
?: "Unknown error"
|
||||
val humanized = humanizeAuthFailReason(rawReason)
|
||||
Log.w(TAG, "handleAuthFail: raw=$rawReason humanized=$humanized")
|
||||
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
|
||||
* what the user reads when a pair fails.
|
||||
*
|
||||
* Pass-through for anything we don't recognize so server-side debug
|
||||
* output isn't clobbered.
|
||||
*/
|
||||
private fun humanizeAuthFailReason(raw: String): String {
|
||||
val lower = raw.lowercase()
|
||||
return when {
|
||||
// "pairing code not recognized", "invalid pairing code",
|
||||
// "unknown pairing code", etc. — the code is no longer on the
|
||||
// relay, which almost always means the QR was already used.
|
||||
"pairing" in lower && ("not recogniz" in lower ||
|
||||
"invalid" in lower ||
|
||||
"unknown" in lower ||
|
||||
"consumed" in lower ||
|
||||
"already used" in lower) ->
|
||||
"That pairing code was already used. Generate a fresh QR " +
|
||||
"from `hermes-pair` and scan again."
|
||||
"rate" in lower && "limit" in lower ->
|
||||
"Too many pair attempts — the relay temporarily blocked your " +
|
||||
"IP. Wait ~5 minutes and try again."
|
||||
"expired" in lower ->
|
||||
"The pairing code expired. Generate a fresh QR and scan again."
|
||||
"session" in lower && "expired" in lower ->
|
||||
"Your session expired. Re-pair to get a new one."
|
||||
"session_token" in lower || "token" in lower ->
|
||||
"The server rejected your saved session. Re-pair to get a new one."
|
||||
else -> raw
|
||||
}
|
||||
}
|
||||
|
||||
private fun generatePairingCode(): String {
|
||||
return (1..PAIRING_CODE_LENGTH)
|
||||
.map { PAIRING_CODE_CHARS.random() }
|
||||
|
||||
@@ -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,7 +67,8 @@ 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
|
||||
@@ -89,7 +90,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 +115,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 +140,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 +165,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 +243,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"
|
||||
@@ -243,7 +265,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 +277,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()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
|
||||
import com.hermesandroid.relay.accessibility.MediaProjectionHolder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -70,6 +71,27 @@ class BridgeForegroundService : Service() {
|
||||
const val ACTION_DISABLE = "com.hermesandroid.relay.bridge.DISABLE"
|
||||
const val ACTION_OPEN_SETTINGS = "com.hermesandroid.relay.bridge.OPEN_SETTINGS"
|
||||
|
||||
// === PHASE3-bridge-ui-followup: MediaProjection upgrade action ===
|
||||
// Fired by MainActivity.mediaProjectionLauncher after the user has
|
||||
// granted the system consent dialog. Carries the resultCode + data
|
||||
// Intent the launcher received. The service handles this by:
|
||||
// 1. Calling startForeground AGAIN with the dual SPECIAL_USE |
|
||||
// MEDIA_PROJECTION type bitmask (legal NOW because consent
|
||||
// has been granted).
|
||||
// 2. Calling MediaProjectionHolder.acceptGrantInsideForegroundService
|
||||
// to construct and store the projection.
|
||||
// Splitting the FGS type slot out of the initial start-up is
|
||||
// critical: Android 14+ silently auto-revokes any projection created
|
||||
// by a service that called startForeground(type=mediaProjection)
|
||||
// BEFORE the consent dialog was granted. The previous code had
|
||||
// mediaProjection in the initial type bitmask the moment the master
|
||||
// toggle flipped on, which is exactly that violation. Symptom:
|
||||
// "I tap Allow but the row never turns green" — Bailey, 2026-04-13.
|
||||
const val ACTION_GRANT_PROJECTION = "com.hermesandroid.relay.bridge.GRANT_PROJECTION"
|
||||
const val EXTRA_RESULT_CODE = "com.hermesandroid.relay.bridge.RESULT_CODE"
|
||||
const val EXTRA_RESULT_DATA = "com.hermesandroid.relay.bridge.RESULT_DATA"
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context.applicationContext, BridgeForegroundService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
@@ -81,20 +103,80 @@ class BridgeForegroundService : Service() {
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
// Use stopService() rather than startService(ACTION_STOP) — on
|
||||
// Android 15+ (target SDK 35), the system routes ANY intent to
|
||||
// a service with foregroundServiceType through the foreground
|
||||
// watchdog and demands a startForeground call within 5s, even
|
||||
// if the intent is an internal "please shut down" message. By
|
||||
// going through stopService() we bypass onStartCommand entirely
|
||||
// and call onDestroy directly — clean shutdown, no watchdog.
|
||||
val intent = Intent(context.applicationContext, BridgeForegroundService::class.java)
|
||||
.setAction(ACTION_STOP)
|
||||
context.applicationContext.startService(intent)
|
||||
context.applicationContext.stopService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: hand the consent result off to the foreground
|
||||
* service so it can upgrade its FGS type to include MEDIA_PROJECTION
|
||||
* and construct the projection. Called from
|
||||
* `MainActivity.mediaProjectionLauncher` immediately after the user
|
||||
* grants the system consent dialog.
|
||||
*
|
||||
* The service must already be running (master toggle on) — that is
|
||||
* guaranteed by `BridgeViewModel.requestScreenCapture()` which gates
|
||||
* the consent flow on the master toggle being on.
|
||||
*/
|
||||
fun grantMediaProjection(context: Context, resultCode: Int, data: Intent) {
|
||||
val intent = Intent(context.applicationContext, BridgeForegroundService::class.java)
|
||||
.setAction(ACTION_GRANT_PROJECTION)
|
||||
.putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||
.putExtra(EXTRA_RESULT_DATA, data)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// True after we've successfully called startForeground with the
|
||||
// mediaProjection type slot (i.e. after consent + grant). Drives the
|
||||
// type bitmask passed to subsequent startForeground calls so we don't
|
||||
// accidentally drop the slot on a re-start.
|
||||
private var hasMediaProjectionType: Boolean = false
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "onStartCommand action=${intent?.action} flags=$flags startId=$startId")
|
||||
// === PHASE3-bridge-ui-followup: always startForeground first ===
|
||||
// CRITICAL: on Android 15+ (target SDK 35), ANY intent delivered to
|
||||
// a service that declares foregroundServiceType in the manifest —
|
||||
// including intents dispatched via Context.startService() — gets
|
||||
// tracked by the system's foreground-service watchdog. The service
|
||||
// has 5 seconds to call startForeground() or the system throws
|
||||
// ForegroundServiceDidNotStartInTimeException and crashes the app.
|
||||
//
|
||||
// Symptom we hit on 2026-04-13: opening the Bridge tab → BridgeViewModel
|
||||
// collector fires masterToggle (initial value false) → calls
|
||||
// BridgeForegroundService.stop(ctx) → startService(ACTION_STOP) →
|
||||
// service onStartCommand handles ACTION_STOP, calls stopForeground
|
||||
// and stopSelf without ever calling startForeground → 5s later the
|
||||
// system kills the process.
|
||||
//
|
||||
// The fix: ALWAYS call startForeground at the top of onStartCommand,
|
||||
// before any action branching. The brief notification flash for
|
||||
// stop-only paths is acceptable; the alternative (using a bound
|
||||
// service or broadcast receiver for control commands) is a much
|
||||
// bigger refactor for the same outcome.
|
||||
startForegroundNotification()
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
Log.i(TAG, "ACTION_STOP → stopping foreground service")
|
||||
hasMediaProjectionType = false
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
@@ -127,13 +209,93 @@ class BridgeForegroundService : Service() {
|
||||
runCatching { startActivity(launch) }
|
||||
return START_STICKY
|
||||
}
|
||||
ACTION_GRANT_PROJECTION -> {
|
||||
// === PHASE3-bridge-ui-followup: post-consent FGS type upgrade ===
|
||||
// The launcher result has just landed in MainActivity. The
|
||||
// top-of-onStartCommand call already brought us into the
|
||||
// foreground (with SPECIAL_USE only). Now flip the type
|
||||
// flag and call startForeground AGAIN to upgrade to
|
||||
// SPECIAL_USE | MEDIA_PROJECTION — legal NOW because the
|
||||
// consent has been granted — then construct the projection
|
||||
// from inside the foreground state. Two startForeground
|
||||
// calls on the same service is well-supported; the second
|
||||
// just changes the type bitmask.
|
||||
Log.i(TAG, "ACTION_GRANT_PROJECTION → upgrading FGS type and accepting grant")
|
||||
hasMediaProjectionType = true
|
||||
startForegroundNotification()
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
val data: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||
}
|
||||
val accepted = MediaProjectionHolder.acceptGrantInsideForegroundService(
|
||||
this, resultCode, data
|
||||
)
|
||||
if (!accepted) {
|
||||
// Rolling back the type slot keeps us honest: if the
|
||||
// user actually denied (or the API failed), we shouldn't
|
||||
// claim a grant we don't have. Re-run startForeground
|
||||
// with SPECIAL_USE only so the FGS type matches reality.
|
||||
Log.w(TAG, "grant not accepted — reverting FGS to SPECIAL_USE only")
|
||||
hasMediaProjectionType = false
|
||||
startForegroundNotification()
|
||||
}
|
||||
return START_STICKY
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
}
|
||||
}
|
||||
|
||||
startForegroundNotification()
|
||||
// Fall-through for ACTION_START / null intent — startForegroundNotification
|
||||
// was already called at the top of this method.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground services legitimately survive task removal — the service
|
||||
* instance stays alive even after the user swipes the app from recents,
|
||||
* which is the behavior we want for the bridge itself (agent-driven
|
||||
* phone control via WSS should keep working when the user "closes" the
|
||||
* app). BUT the MediaProjection that powers screen capture should NOT
|
||||
* outlive task removal: the system-level screen-cast indicator icon in
|
||||
* Android's status bar stays visible for the duration of the projection,
|
||||
* and a user who swiped the app away would understandably read that
|
||||
* icon as "the app I just closed is still recording my screen." That's
|
||||
* the exact class of trust failure we can't afford for an
|
||||
* accessibility-controlling app.
|
||||
*
|
||||
* Fix: on task removal, revoke only the projection (the bridge stays
|
||||
* alive) and downgrade the FGS type back to SPECIAL_USE-only so
|
||||
* startForeground reflects reality and the MEDIA_PROJECTION system
|
||||
* indicator disappears. Next time the user reopens the app and
|
||||
* re-enables screenshots, a fresh consent dialog appears — which is
|
||||
* the correct UX for a privacy-sensitive capability.
|
||||
*
|
||||
* Caught 2026-04-15 by Bailey: the screen-cast icon persisted in the
|
||||
* status bar even after force-stopping the app from recents.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.i(
|
||||
TAG,
|
||||
"onTaskRemoved: user swiped app — revoking MediaProjection, " +
|
||||
"keeping bridge alive for agent control",
|
||||
)
|
||||
runCatching { MediaProjectionHolder.revoke() }
|
||||
if (hasMediaProjectionType) {
|
||||
hasMediaProjectionType = false
|
||||
runCatching { startForegroundNotification() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Reset state so a fresh service instance starts in the
|
||||
// SPECIAL_USE-only configuration. Also drop any held MediaProjection
|
||||
// — a projection without an active bridge is meaningless and the
|
||||
// next bridge enable should always prompt for fresh consent.
|
||||
hasMediaProjectionType = false
|
||||
runCatching { MediaProjectionHolder.revoke() }
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -143,21 +305,30 @@ class BridgeForegroundService : Service() {
|
||||
val notification = buildNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// === PHASE3-bridge-ui-followup: Android 14+ MediaProjection FGS ===
|
||||
// OR both type slots:
|
||||
// - SPECIAL_USE for the persistent "bridge active" indicator
|
||||
// - MEDIA_PROJECTION so MediaProjectionManager.getMediaProjection()
|
||||
// can actually return a usable projection. Without this slot
|
||||
// declared at startForeground time, Android 14+ silently
|
||||
// auto-revokes the projection within frames of the consent
|
||||
// dialog closing. Symptom: "I tapped Allow but the grant
|
||||
// never sticks" — exactly what tripped us up on 2026-04-12.
|
||||
// === PHASE3-bridge-ui-followup: gated MediaProjection type slot ===
|
||||
// CRITICAL: on Android 14+, startForeground(type=mediaProjection)
|
||||
// is only legal AFTER the user has granted the system consent
|
||||
// dialog. Calling it before — even if you intend to "wait
|
||||
// until consent arrives" — makes the eventual projection
|
||||
// get auto-revoked by the system within a frame, with no
|
||||
// app-visible error.
|
||||
//
|
||||
// So we start with SPECIAL_USE only when the bridge first
|
||||
// comes up (master toggle on, no projection yet), and the
|
||||
// ACTION_GRANT_PROJECTION handler upgrades us to
|
||||
// SPECIAL_USE | MEDIA_PROJECTION right after consent and
|
||||
// before getMediaProjection. That's why this method reads
|
||||
// [hasMediaProjectionType] instead of always OR-ing both.
|
||||
//
|
||||
// Both subtypes share this single notification + this single
|
||||
// service. Manifest must list both in `foregroundServiceType`.
|
||||
val combinedType =
|
||||
// service. Manifest lists both in `foregroundServiceType`.
|
||||
val typeMask = if (hasMediaProjectionType) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
startForeground(NOTIFICATION_ID, notification, combinedType)
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification, typeMask)
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Q..U: the type arg is required on Q+ too, but
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@@ -109,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
|
||||
@@ -144,6 +161,12 @@ class BridgeSafetyManager(
|
||||
settingsHydrated = true
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
prefsRepo.trustedDestructiveVerbs.collect { latest ->
|
||||
_trustedDestructiveVerbs.value = latest
|
||||
trustedHydrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blocklist ────────────────────────────────────────────────────────
|
||||
@@ -163,7 +186,20 @@ class BridgeSafetyManager(
|
||||
|
||||
suspend fun requiresConfirmation(method: String, text: String?): Boolean {
|
||||
if (text.isNullOrBlank()) return false
|
||||
if (method != "/tap_text" && method != "/type") return false
|
||||
// Pre-0.4.0 only /tap_text and /type were gated — node-id taps
|
||||
// slipped past because the gate never looked up the tapped
|
||||
// node's text. Bailey 2026-04-15 hit this: after denying an
|
||||
// SMS, the agent fell back to android_open_app + android_tap
|
||||
// (by nodeId) on the Messages app's "Send" button, bypassing
|
||||
// the verb modal. BridgeCommandHandler.extractDestructiveVerbText
|
||||
// now resolves the node's text for /tap + /long_press too, and
|
||||
// this method gates on any of the four paths as long as the
|
||||
// caller supplies a text argument.
|
||||
if (method != "/tap_text" &&
|
||||
method != "/type" &&
|
||||
method != "/tap" &&
|
||||
method != "/long_press"
|
||||
) return false
|
||||
val verbs = currentSettings().destructiveVerbs
|
||||
if (verbs.isEmpty()) return false
|
||||
return containsDestructiveVerb(text, verbs)
|
||||
@@ -183,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
|
||||
@@ -200,13 +256,28 @@ class BridgeSafetyManager(
|
||||
return false
|
||||
}
|
||||
|
||||
// ComposeView creation + setContent inside BridgeStatusOverlay.showConfirmation
|
||||
// must run on the Main thread. This awaitConfirmation call can originate
|
||||
// from either the WSS-incoming BridgeCommandHandler path (already on Main
|
||||
// via ChannelMultiplexer's dispatcher) OR from the in-process voice-intent
|
||||
// local-dispatch path (Dispatchers.Default, via RealVoiceBridgeIntentHandler's
|
||||
// own scope). Using Dispatchers.Main.immediate makes the first case a no-op
|
||||
// and only schedules on Main for the second — one line handles both callers.
|
||||
//
|
||||
// Before this fix the off-thread call threw from ComposeView.setContent,
|
||||
// the outer runCatching swallowed it, and the log message mislabelled the
|
||||
// cause as "likely overlay permission missing" which sent debugging up a
|
||||
// wrong tree (2026-04-15 on-device test with overlay permission granted
|
||||
// but voice SMS still never showed the modal).
|
||||
val shown = runCatching {
|
||||
host.showConfirmation(pending) { resolution ->
|
||||
resolveConfirmation(requestId, resolution)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
host.showConfirmation(pending) { resolution ->
|
||||
resolveConfirmation(requestId, resolution)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shown.isFailure) {
|
||||
Log.w(TAG, "awaitConfirmation: overlay host refused to show modal (likely overlay permission missing)")
|
||||
Log.w(TAG, "awaitConfirmation: host.showConfirmation threw — denying", shown.exceptionOrNull())
|
||||
pendingConfirmations.remove(requestId)
|
||||
return false
|
||||
}
|
||||
@@ -273,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
|
||||
|
||||
@@ -19,8 +19,13 @@ import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.hermesandroid.relay.ui.components.BridgeStatusOverlayChip
|
||||
import com.hermesandroid.relay.ui.components.DestructiveVerbConfirmDialog
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -39,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
|
||||
@@ -73,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 ──────────────────────────────────────────────────────
|
||||
@@ -81,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
|
||||
@@ -100,7 +133,7 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
setContent {
|
||||
MaterialTheme { BridgeStatusOverlayChip() }
|
||||
MaterialTheme { BridgeStatusOverlayChip(unattended = unattended) }
|
||||
}
|
||||
}
|
||||
attachLifecycle(compose)
|
||||
@@ -125,7 +158,9 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
Log.w(TAG, "addView(chip) failed", it)
|
||||
return
|
||||
}
|
||||
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
|
||||
chipView = compose
|
||||
chipUnattended = unattended
|
||||
}
|
||||
|
||||
// ── Confirmation modal ───────────────────────────────────────────────
|
||||
@@ -149,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)
|
||||
},
|
||||
@@ -181,6 +227,7 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
onResult(false)
|
||||
return
|
||||
}
|
||||
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
|
||||
activeConfirmations[request.id] = compose
|
||||
}
|
||||
|
||||
@@ -204,21 +251,62 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
val owner = OverlayLifecycleOwner().also { it.start() }
|
||||
view.setViewTreeLifecycleOwner(owner)
|
||||
view.setViewTreeViewModelStoreOwner(owner)
|
||||
// REQUIRED even when the overlay content uses only plain `remember`:
|
||||
// `AndroidComposeView.onAttachedToWindow` hard-fails with
|
||||
// `IllegalStateException: Composed into the View which doesn't
|
||||
// propagateViewTreeSavedStateRegistryOwner` if this tree owner is
|
||||
// missing, regardless of whether the composable actually reads
|
||||
// saved state. Confirmed empirically on Samsung S24 / Android 14
|
||||
// / Compose BOM 2024.12 when enabling the persistent status chip
|
||||
// from the Bridge Safety screen (Phase 3 safety-rails).
|
||||
view.setViewTreeSavedStateRegistryOwner(owner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal always-RESUMED lifecycle owner for ComposeViews we attach to
|
||||
* a WindowManager. Compose's `Recomposer` refuses to run inside a view
|
||||
* that has no `ViewTreeLifecycleOwner`; we also need a ViewModelStore
|
||||
* so `viewModel()` calls inside the overlay can resolve.
|
||||
* a `WindowManager`. Compose's `Recomposer` refuses to run inside a view
|
||||
* that has no `ViewTreeLifecycleOwner`; `viewModel()` calls inside the
|
||||
* overlay need a `ViewModelStore`; and — as of recent Compose versions —
|
||||
* `AndroidComposeView.onAttachedToWindow` hard-requires a
|
||||
* `ViewTreeSavedStateRegistryOwner` even for composables that never read
|
||||
* saved state. So this class implements all three.
|
||||
*
|
||||
* We deliberately skip `SavedStateRegistryOwner` — [DestructiveVerbConfirmDialog]
|
||||
* uses plain `remember`, not `rememberSaveable`, and the chip is stateless.
|
||||
* Adding SavedState here would tie the class to the androidx.savedstate
|
||||
* artifact version which isn't currently pinned in the version catalog.
|
||||
* Earlier versions of this file deliberately skipped
|
||||
* `SavedStateRegistryOwner` on the assumption that "no `rememberSaveable`
|
||||
* → no saved state needed". That assumption was wrong: the onAttach gate
|
||||
* in `AndroidComposeView` doesn't inspect the composable body, it just
|
||||
* checks for the tree owner and throws. The crash was
|
||||
* [IllegalStateException] at `AndroidComposeView.onAttachedToWindow:2234`
|
||||
* on every overlay attach.
|
||||
*
|
||||
* ## Init sequence — DO NOT REORDER
|
||||
*
|
||||
* Current androidx.savedstate requires:
|
||||
*
|
||||
* 1. `savedStateController.performRestore(null)` — while the owner is
|
||||
* still in [Lifecycle.State.INITIALIZED]. Internally this calls
|
||||
* `performAttach()` which hard-asserts `currentState == INITIALIZED`
|
||||
* and throws `IllegalStateException: Restarter must be created only
|
||||
* during owner's initialization stage` if you've already advanced
|
||||
* past it.
|
||||
* 2. `registry.currentState = CREATED`
|
||||
* 3. `registry.currentState = RESUMED`
|
||||
*
|
||||
* An older androidx.savedstate release required the OPPOSITE order
|
||||
* (CREATED → performRestore → RESUMED) and this file shipped with that
|
||||
* code, matching the KDoc. The 2026-04-15 Compose BOM bump flipped the
|
||||
* contract and the overlay started throwing on every destructive-verb
|
||||
* confirmation attempt. Caught by Bailey's on-device voice→SMS test
|
||||
* that same day — see the `BridgeSafetyMgr` stack trace in the session
|
||||
* log. The chip path didn't trigger it because it was never exercised
|
||||
* in the same build + flavor combo; only the confirmation modal path
|
||||
* hit the assertion.
|
||||
*/
|
||||
private class OverlayLifecycleOwner : LifecycleOwner, ViewModelStoreOwner {
|
||||
private class OverlayLifecycleOwner :
|
||||
LifecycleOwner,
|
||||
ViewModelStoreOwner,
|
||||
SavedStateRegistryOwner {
|
||||
|
||||
private val registry = LifecycleRegistry(this)
|
||||
override val lifecycle: Lifecycle get() = registry
|
||||
@@ -226,7 +314,16 @@ private class OverlayLifecycleOwner : LifecycleOwner, ViewModelStoreOwner {
|
||||
private val store = ViewModelStore()
|
||||
override val viewModelStore: ViewModelStore get() = store
|
||||
|
||||
private val savedStateController = SavedStateRegistryController.create(this)
|
||||
override val savedStateRegistry: SavedStateRegistry
|
||||
get() = savedStateController.savedStateRegistry
|
||||
|
||||
fun start() {
|
||||
// Restore saved state FIRST — must run while currentState is still
|
||||
// INITIALIZED or performAttach() throws. See KDoc above for the
|
||||
// assertion story.
|
||||
savedStateController.performRestore(null)
|
||||
registry.currentState = Lifecycle.State.CREATED
|
||||
registry.currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
|
||||
|
||||
@@ -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,114 @@
|
||||
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.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,
|
||||
)
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -40,7 +40,93 @@ data class ChatMessage(
|
||||
// Agent/personality name for display on assistant messages
|
||||
val agentName: String? = null,
|
||||
// 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
|
||||
)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* 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 what Hermes's server config means by it (agent profiles —
|
||||
* name + model + description defined under `agent.profiles` in config.yaml).
|
||||
* A follow-up pass will introduce the new `Profile` concept on top.
|
||||
*/
|
||||
@Serializable
|
||||
data class Connection(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val apiServerUrl: String,
|
||||
val relayUrl: String,
|
||||
val tokenStoreKey: String,
|
||||
/** 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,
|
||||
) {
|
||||
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"
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
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()
|
||||
|
||||
/**
|
||||
* 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 next = current.filterNot { it.id == connection.id } + connection
|
||||
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 next = current.map { if (it.id == connection.id) connection 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 { 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,
|
||||
)
|
||||
} 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,
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,17 @@ import java.io.File
|
||||
* Backup format is a JSON file containing settings and connection info.
|
||||
* Tokens are NOT included in backups for security.
|
||||
*/
|
||||
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"
|
||||
@@ -41,39 +53,69 @@ class DataManager(private val context: Context) {
|
||||
/**
|
||||
* Backup data model -- only non-sensitive settings.
|
||||
* Tokens and device IDs are never included.
|
||||
*
|
||||
* **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.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 2,
|
||||
val version: Int = 4,
|
||||
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 connections: List<Connection> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Export app settings to a JSON string.
|
||||
* Does NOT include session tokens or device IDs (security).
|
||||
*
|
||||
* 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
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"exportSettings: no ConnectionStore wired — writing empty connections list " +
|
||||
"(caller constructed DataManager without the multi-connection ctor arg)",
|
||||
)
|
||||
}
|
||||
val backup = AppBackup(
|
||||
version = 2,
|
||||
version = 4,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
profiles = profiles,
|
||||
connections = connectionsSnapshot ?: emptyList(),
|
||||
exportedAt = System.currentTimeMillis()
|
||||
)
|
||||
return json.encodeToString(backup)
|
||||
@@ -82,10 +124,51 @@ class DataManager(private val context: Context) {
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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,6 +59,17 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +97,21 @@ object BuildFlavor {
|
||||
const val SIDELOAD = "sideload"
|
||||
val current: String get() = BuildConfig.FLAVOR
|
||||
|
||||
/**
|
||||
* True when the current build is the sideload track. Kept as a property
|
||||
* getter (not a compile-time `val`) so the call site reads cleanly —
|
||||
* `BuildFlavor.isSideload` is easier to eyeball in `BridgeCommandHandler`
|
||||
* than `BuildFlavor.current == BuildFlavor.SIDELOAD`. R8 folds the
|
||||
* comparison away in release builds because `current` resolves to a
|
||||
* compile-time `BuildConfig.FLAVOR` string constant.
|
||||
*
|
||||
* Used by Tier C (C1-C4) tool gates — `android_location`,
|
||||
* `android_search_contacts`, `android_call`, `android_send_sms` — to
|
||||
* return `"sideload-only"` 403 responses on googlePlay builds instead
|
||||
* of crashing on a missing permission declaration.
|
||||
*/
|
||||
val isSideload: Boolean get() = current == SIDELOAD
|
||||
|
||||
val bridgeTier1: Boolean = true // baseline — both tracks
|
||||
val bridgeTier2: Boolean = true // notifications, calendar — both tracks
|
||||
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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.
|
||||
*/
|
||||
val id: String? = 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"
|
||||
}
|
||||
|
||||
object Accents {
|
||||
const val INFO = "info"
|
||||
const val SUCCESS = "success"
|
||||
const val WARNING = "warning"
|
||||
const val DANGER = "danger"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,56 @@
|
||||
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 a NAMED AGENT CONFIG within a Connection. Switching profile
|
||||
* changes:
|
||||
* - which model the phone asks the server to use on the next chat
|
||||
* request (via [model]);
|
||||
* - which system message the phone sends for that request (via
|
||||
* [systemMessage], when non-blank).
|
||||
*
|
||||
* It does not change the server, sessions, or memory.
|
||||
*
|
||||
* 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`.
|
||||
*/
|
||||
@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,
|
||||
)
|
||||
@@ -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,98 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,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 }
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.hermesandroid.relay.event
|
||||
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
|
||||
/**
|
||||
* Phase 3 — `B1 event-stream`
|
||||
*
|
||||
* Process-wide bounded ring buffer of recent [AccessibilityEvent]s,
|
||||
* exposed via the `android_events(limit, since)` polling tool on the
|
||||
* host. Off by default — capture only runs when
|
||||
* [isStreaming] is true, which the agent toggles explicitly via
|
||||
* `android_event_stream(true)`.
|
||||
*
|
||||
* # Why a ring buffer
|
||||
*
|
||||
* `onAccessibilityEvent` can fire hundreds of times per second during a
|
||||
* scroll or text-change burst. Even with per-`(type, package)` throttling
|
||||
* we can easily accumulate thousands of entries an hour, so we cap the
|
||||
* store at [MAX_ENTRIES] and evict the oldest in FIFO order. The `since`
|
||||
* parameter on the poll tool lets the agent efficiently ask "what's new
|
||||
* since the last time I checked" without re-downloading history.
|
||||
*
|
||||
* # Why we filter event types
|
||||
*
|
||||
* Android emits a deep taxonomy of events (focus, selection, hover,
|
||||
* gesture-detection, touch-exploration, view-scrolled-sub-tree, etc.).
|
||||
* The overwhelming majority are low-signal for a reactive-agent use
|
||||
* case — we keep only the five that answer "what is the user DOING":
|
||||
*
|
||||
* * click — the user pressed something
|
||||
* * text changed — the user typed / pasted
|
||||
* * window state changed — the foreground activity switched
|
||||
* * window content changed — something on the current screen updated
|
||||
* * scroll — the user scrolled a list
|
||||
*
|
||||
* Everything else is dropped at [append] so the buffer stays dense.
|
||||
*
|
||||
* # Throttling semantics
|
||||
*
|
||||
* We keep one event per `(eventTypeInt, packageName)` per
|
||||
* [THROTTLE_WINDOW_MS]. This collapses scroll bursts and text-change
|
||||
* storms into a single entry per app per 100ms, which is the natural
|
||||
* human-observable granularity anyway. The throttle map is garbage-
|
||||
* collected every [THROTTLE_GC_INTERVAL_MS] to keep its size bounded
|
||||
* even if the user flips through many packages.
|
||||
*
|
||||
* # Thread safety
|
||||
*
|
||||
* Reads and writes both take the single [lock]. Android's accessibility
|
||||
* service pumps events on its own binder thread; the poll tool flows in
|
||||
* over the WSS multiplexer thread. A simple `synchronized` block is
|
||||
* plenty — we're not in a hot rendering path.
|
||||
*
|
||||
* # Privacy
|
||||
*
|
||||
* Events can contain search queries, typed messages, password form
|
||||
* values, and anything else that touches an EditText. The buffer is
|
||||
* cleared automatically on any [setStreaming] transition (both
|
||||
* enable→disable and disable→enable) so the agent cannot observe
|
||||
* history from a previous "on" interval without the user's explicit
|
||||
* second opt-in.
|
||||
*/
|
||||
object EventStore {
|
||||
|
||||
/** Maximum number of entries retained in the ring buffer. */
|
||||
const val MAX_ENTRIES: Int = 500
|
||||
|
||||
/** Throttle window per `(eventTypeInt, packageName)` pair. */
|
||||
const val THROTTLE_WINDOW_MS: Long = 100L
|
||||
|
||||
/** Sweep interval for the throttle map (keeps memory bounded). */
|
||||
const val THROTTLE_GC_INTERVAL_MS: Long = 5_000L
|
||||
|
||||
/**
|
||||
* One recorded accessibility event. Snake-case fields on the wire
|
||||
* so the Python tool client sees a consistent envelope across every
|
||||
* `android_*` surface.
|
||||
*/
|
||||
data class Entry(
|
||||
val timestamp: Long,
|
||||
val eventType: String,
|
||||
val packageName: String?,
|
||||
val className: String?,
|
||||
val text: String?,
|
||||
val contentDescription: String?,
|
||||
val source: String,
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val buffer: ArrayDeque<Entry> = ArrayDeque(MAX_ENTRIES)
|
||||
|
||||
/**
|
||||
* Per-`(eventTypeInt, packageName)` last-appended timestamp for the
|
||||
* [THROTTLE_WINDOW_MS] gate. Swept by [maybeGcThrottleMap] whenever
|
||||
* [append] runs and the clock has advanced past the last sweep by
|
||||
* [THROTTLE_GC_INTERVAL_MS].
|
||||
*/
|
||||
private val throttleMap: MutableMap<Pair<Int, String>, Long> = HashMap()
|
||||
private var lastThrottleGcAt: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var isStreaming: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Enable or disable event capture. Always clears the buffer on a
|
||||
* state change (both directions) to avoid two kinds of leak:
|
||||
*
|
||||
* * on disable → the buffer is wiped so a subsequent re-enable
|
||||
* cannot replay stale "off"-interval events.
|
||||
* * on enable → we start with a fresh slate so no pre-existing
|
||||
* state from a prior session is mixed with the new stream.
|
||||
*
|
||||
* A no-op call (same value) leaves the buffer alone.
|
||||
*/
|
||||
fun setStreaming(enabled: Boolean) {
|
||||
synchronized(lock) {
|
||||
if (isStreaming == enabled) return
|
||||
isStreaming = enabled
|
||||
buffer.clear()
|
||||
throttleMap.clear()
|
||||
lastThrottleGcAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an AccessibilityEvent to the buffer if it survives the
|
||||
* type filter and the `(type, package)` throttle. Silently drops
|
||||
* events when [isStreaming] is false — the caller in
|
||||
* `HermesAccessibilityService.onAccessibilityEvent` can still call
|
||||
* us unconditionally.
|
||||
*/
|
||||
fun append(event: AccessibilityEvent) {
|
||||
// M2 fix: previously the `isStreaming` check happened OUTSIDE the
|
||||
// lock, creating a TOCTOU window. A binder-thread append could read
|
||||
// `isStreaming == true` and then enter the lock AFTER `setStreaming
|
||||
// (false)` had already cleared the buffer, adding a stale entry.
|
||||
// Move the check INSIDE the synchronized block so the streaming gate
|
||||
// and the buffer mutation are atomic. The @Volatile on `isStreaming`
|
||||
// stays as a cheap fast-path hint outside the hot lock.
|
||||
if (!isStreaming) return
|
||||
|
||||
val eventTypeInt = event.eventType
|
||||
val humanType = humanEventType(eventTypeInt) ?: return
|
||||
val pkg = event.packageName?.toString()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
synchronized(lock) {
|
||||
// Re-check under the lock so a concurrent setStreaming(false)
|
||||
// can't race a stale entry past the volatile read above.
|
||||
if (!isStreaming) return
|
||||
// Throttle: drop if we saw a same-kind event in the last window.
|
||||
val key = Pair(eventTypeInt, pkg ?: "")
|
||||
val last = throttleMap[key]
|
||||
if (last != null && (now - last) < THROTTLE_WINDOW_MS) {
|
||||
return
|
||||
}
|
||||
throttleMap[key] = now
|
||||
maybeGcThrottleMap(now)
|
||||
|
||||
val entry = Entry(
|
||||
timestamp = now,
|
||||
eventType = humanType,
|
||||
packageName = pkg,
|
||||
className = event.className?.toString(),
|
||||
text = concatEventText(event),
|
||||
contentDescription = event.contentDescription?.toString(),
|
||||
source = sourceForEventType(eventTypeInt),
|
||||
)
|
||||
|
||||
if (buffer.size >= MAX_ENTRIES) {
|
||||
buffer.removeFirst()
|
||||
}
|
||||
buffer.addLast(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return up to [limit] most-recent entries (oldest-first inside
|
||||
* the returned list, matching poll semantics of "play these back in
|
||||
* chronological order"). If [since] is non-zero, only entries with
|
||||
* `timestamp > since` are returned.
|
||||
*/
|
||||
fun recent(limit: Int = 50, since: Long = 0L): List<Entry> {
|
||||
if (limit <= 0) return emptyList()
|
||||
synchronized(lock) {
|
||||
if (buffer.isEmpty()) return emptyList()
|
||||
|
||||
// Collect chronologically (buffer is already oldest → newest)
|
||||
// filtered by since, then tail-trimmed to limit.
|
||||
val filtered = if (since > 0L) {
|
||||
buffer.filter { it.timestamp > since }
|
||||
} else {
|
||||
buffer.toList()
|
||||
}
|
||||
return if (filtered.size <= limit) {
|
||||
filtered
|
||||
} else {
|
||||
filtered.subList(filtered.size - limit, filtered.size).toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear both the buffer and the throttle map. */
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
buffer.clear()
|
||||
throttleMap.clear()
|
||||
lastThrottleGcAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Test / diag helper — current number of buffered entries. */
|
||||
fun size(): Int = synchronized(lock) { buffer.size }
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map an Android event-type int to the short human string the
|
||||
* polling tool returns. Returning `null` is the drop signal — the
|
||||
* five accepted types are the signal-rich ones per the Phase 3
|
||||
* event-stream plan; every other type is silently ignored.
|
||||
*/
|
||||
private fun humanEventType(eventType: Int): String? = when (eventType) {
|
||||
AccessibilityEvent.TYPE_VIEW_CLICKED -> "click"
|
||||
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> "text_changed"
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> "window_content_changed"
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> "window_state_changed"
|
||||
AccessibilityEvent.TYPE_VIEW_SCROLLED -> "scroll"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable `TYPE_*` name for the `source` field — mirrors the Android
|
||||
* constant name so downstream consumers can correlate with logcat.
|
||||
*/
|
||||
private fun sourceForEventType(eventType: Int): String = when (eventType) {
|
||||
AccessibilityEvent.TYPE_VIEW_CLICKED -> "TYPE_VIEW_CLICKED"
|
||||
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> "TYPE_VIEW_TEXT_CHANGED"
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> "TYPE_WINDOW_CONTENT_CHANGED"
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> "TYPE_WINDOW_STATE_CHANGED"
|
||||
AccessibilityEvent.TYPE_VIEW_SCROLLED -> "TYPE_VIEW_SCROLLED"
|
||||
else -> "TYPE_UNKNOWN_$eventType"
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate [AccessibilityEvent.getText] into one display string,
|
||||
* truncated to 200 chars so a runaway EditText doesn't blow the
|
||||
* buffer. Returns null when the event had no text at all (we'd
|
||||
* rather emit `null` than empty string so the JSON stays tight).
|
||||
*/
|
||||
private fun concatEventText(event: AccessibilityEvent): String? {
|
||||
val parts = event.text ?: return null
|
||||
if (parts.isEmpty()) return null
|
||||
val joined = parts.joinToString(separator = " ") { it?.toString().orEmpty() }.trim()
|
||||
if (joined.isEmpty()) return null
|
||||
return if (joined.length > 200) joined.substring(0, 200) else joined
|
||||
}
|
||||
|
||||
/**
|
||||
* Amortised GC of the throttle map. Called from [append] under the
|
||||
* lock. Walks the map only every [THROTTLE_GC_INTERVAL_MS] to avoid
|
||||
* O(n) work per event.
|
||||
*/
|
||||
private fun maybeGcThrottleMap(now: Long) {
|
||||
if (lastThrottleGcAt == 0L) {
|
||||
lastThrottleGcAt = now
|
||||
return
|
||||
}
|
||||
if ((now - lastThrottleGcAt) < THROTTLE_GC_INTERVAL_MS) return
|
||||
lastThrottleGcAt = now
|
||||
val cutoff = now - THROTTLE_WINDOW_MS
|
||||
val it = throttleMap.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val e = it.next()
|
||||
if (e.value < cutoff) it.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
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.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,7 +17,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 +68,29 @@ 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
|
||||
* 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,
|
||||
/**
|
||||
* 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)
|
||||
@@ -94,6 +125,12 @@ class ConnectionManager(
|
||||
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,10 +143,39 @@ 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.
|
||||
*
|
||||
* Cleared on [disconnect] per ADR 24's "clears on disconnect" semantics
|
||||
* from the UI card.
|
||||
*/
|
||||
@Volatile
|
||||
private var manualRoleOverride: String? = null
|
||||
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_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) {
|
||||
@@ -120,6 +186,38 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
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)")
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
Log.d(TAG, "connect: no resolver winner — using supplied 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) {
|
||||
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.")
|
||||
@@ -147,6 +245,215 @@ class ConnectionManager(
|
||||
doConnect(normalized)
|
||||
}
|
||||
|
||||
// ----- 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 devicePull = deviceIdProvider ?: return null
|
||||
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val endpoints: List<EndpointCandidate> = try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
|
||||
if (endpoints.isEmpty()) return null
|
||||
|
||||
// Manual override: if the user pinned a role in the Endpoints card,
|
||||
// try that one first; fall through to the strict-priority algorithm
|
||||
// if it isn't reachable.
|
||||
manualRoleOverride?.let { preferredRole ->
|
||||
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.
|
||||
*/
|
||||
fun probeAndReconnect() {
|
||||
endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return@launch
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
webSocket?.close(1000, "Endpoint re-probe")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
suspend fun refreshActiveEndpoint(): EndpointCandidate? {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
_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 = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${manualRoleOverride ?: "(cleared)"}")
|
||||
}
|
||||
|
||||
fun getManualRoleOverride(): String? = manualRoleOverride
|
||||
|
||||
private fun markActiveEndpointUnreachable(reason: String) {
|
||||
val active = _activeEndpoint.value ?: return
|
||||
endpointResolver?.markUnreachable(active)
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
private fun resolveAndSwitchIfNeeded(closeReason: String) {
|
||||
if (endpointResolver == null) return
|
||||
val current = serverUrl ?: return
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null) {
|
||||
_activeEndpoint.value = null
|
||||
return@launch
|
||||
}
|
||||
val newUrl = resolved.relay.url
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "endpoint fallback: swapping $current → $normalizedNew")
|
||||
webSocket?.close(1000, closeReason)
|
||||
connectToUrlOnMainPath(newUrl)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "endpoint fallback: 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")
|
||||
if (endpointResolver == null) return
|
||||
val url = serverUrl ?: return
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val newUrl = resolved?.relay?.url
|
||||
if (newUrl == null) {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Only swap if the winner actually differs from the
|
||||
// currently-connected URL. Avoids dropping a healthy
|
||||
// socket on a no-op network flap (Wi-Fi scan, cell
|
||||
// handover that ends up on the same route, etc.).
|
||||
if (normalizedNew != url) {
|
||||
Log.i(TAG, "network change: swapping $url → $normalizedNew")
|
||||
webSocket?.close(1000, "Network change — switching endpoint")
|
||||
connectToUrlOnMainPath(newUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
|
||||
markActiveEndpointUnreachable("network lost")
|
||||
resolveAndSwitchIfNeeded("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() {
|
||||
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 {
|
||||
// Strip scheme to reason about the path portion cheaply.
|
||||
val schemeEnd = url.indexOf("://")
|
||||
@@ -169,10 +476,16 @@ class ConnectionManager(
|
||||
webSocket = null
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_isInsecureConnection.value = false
|
||||
// ADR 24: clear manual override on explicit disconnect — the
|
||||
// Routes card's "Prefer this route" menu contract is that it lasts
|
||||
// until the user disconnects, then resets to resolver-picked.
|
||||
manualRoleOverride = null
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
disconnect()
|
||||
unregisterNetworkCallback()
|
||||
supervisorJob.cancel()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
@@ -204,10 +517,13 @@ class ConnectionManager(
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
Log.i(TAG, "doConnect: opening WSS to $url")
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
reconnectAttempt = 0
|
||||
lastUpgradeResponseCode = null
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
|
||||
// TOFU: record the peer cert fingerprint if we don't have one
|
||||
// yet. OkHttp populates response.handshake when the connection
|
||||
@@ -239,17 +555,24 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "onClosing: code=$code reason=$reason")
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "onClosed: code=$code reason=$reason")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
val code = response?.code
|
||||
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=$code)")
|
||||
lastUpgradeResponseCode = code
|
||||
if (response == null) {
|
||||
markActiveEndpointUnreachable("socket failure")
|
||||
}
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
t.printStackTrace()
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
@@ -272,8 +595,17 @@ 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")
|
||||
RATE_LIMIT_BACKOFF_MS
|
||||
} else {
|
||||
(BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
delay(backoffMs)
|
||||
@@ -281,7 +613,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
|
||||
|
||||
@@ -34,6 +34,7 @@ class ConnectivityObserver(private val context: Context) {
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 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. The cache lives 60 seconds per `(role|host:port)`
|
||||
* key so repeated `connect()` calls don't hammer the network.
|
||||
* * **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>()
|
||||
|
||||
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
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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")
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "resolve: no reachable candidate across ${candidates.size} record(s)")
|
||||
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)
|
||||
probeCache[key] = CacheEntry(
|
||||
expiresAt = now + CACHE_TTL_MS,
|
||||
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 url = "${candidate.api.url}/health".toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
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 ->
|
||||
resp.isSuccessful
|
||||
}
|
||||
} ?: false
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
* skips the dead endpoint without waiting for its probe to time out.
|
||||
*
|
||||
* The entry is still TTL'd — after 30 seconds it expires and the next
|
||||
* resolve() will re-probe. That matches "ADR 24 — cached for 30 seconds"
|
||||
* and stops a permanently-cached stale result.
|
||||
*/
|
||||
fun markUnreachable(candidate: EndpointCandidate) {
|
||||
val key = cacheKey(candidate)
|
||||
probeCache[key] = CacheEntry(
|
||||
expiresAt = clock() + CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
}
|
||||
|
||||
/** Test-only: wipe the probe cache so a fresh run starts clean. */
|
||||
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 }
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -192,43 +193,95 @@ 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 = 50): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
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.items ?: parsed.sessions ?: emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(title: String? = null): Result<SessionItem> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(CreateSessionRequest(title = title))
|
||||
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): SessionItem? =
|
||||
createSessionResult(title).getOrNull()
|
||||
|
||||
suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId")
|
||||
@@ -354,11 +407,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,13 +452,22 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
// Agent-profile model override — top-level `model` mirrors
|
||||
// the OpenAI Chat Completions shape and is what the
|
||||
// upstream sessions handler looks at when deciding which
|
||||
// model to route the turn through.
|
||||
put("model", modelOverride)
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
@@ -386,6 +478,13 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
// Nest the synthesized OpenAI-format pairs under `messages`.
|
||||
// Stays additive — the upstream sessions handler reads
|
||||
// `message` for the live turn and treats `messages` as
|
||||
// history context to seed the LLM with.
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
@@ -584,11 +683,27 @@ class HermesApiClient(
|
||||
|
||||
// --- 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,
|
||||
@@ -599,10 +714,22 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null
|
||||
): EventSource {
|
||||
// Resolution: modelOverride (profile pick) > model (caller default) > "default".
|
||||
// Blank strings are treated as null so callers can't accidentally
|
||||
// ship `"model": ""` over the wire.
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendRunStream: modelOverride=$modelOverride (profile pick, was model=$model)")
|
||||
}
|
||||
val requestPayload = buildJsonObject {
|
||||
put("model", model ?: "default")
|
||||
put("model", resolvedModel)
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
@@ -618,6 +745,13 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
// /v1/runs is OpenAI Responses-shaped — accepts an
|
||||
// additional `messages` field for context-priming the
|
||||
// run. Mirror the chat-stream branch so both endpoints
|
||||
// ingest the synthetic voice-intent history identically.
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
@@ -911,4 +1045,14 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,18 @@ import java.io.IOException
|
||||
* POST /voice/synthesize — JSON `{"text": "..."}`, returns audio/mpeg bytes
|
||||
*
|
||||
* Auth, URL conversion (`ws` ↔ `http`), and error shape mirror
|
||||
* [RelayHttpClient] — same bearer token, same `{relayUrl, sessionToken}`
|
||||
* providers. We take the providers as constructor args instead of sharing
|
||||
* a [RelayHttpClient] instance so the classes stay decoupled.
|
||||
* [RelayHttpClient]. Android prefers the saved Hermes API key for voice when
|
||||
* present, because chat+voice-only installs do not need the full Relay pairing
|
||||
* flow. Paired Relay sessions remain the fallback for installs without an API
|
||||
* key. We take the providers as constructor args instead of sharing a
|
||||
* [RelayHttpClient] instance so the classes stay decoupled.
|
||||
*/
|
||||
class RelayVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val relayUrlProvider: () -> String?,
|
||||
private val sessionTokenProvider: suspend () -> String?,
|
||||
private val apiBearerTokenProvider: suspend () -> String? = { null },
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -50,10 +53,10 @@ class RelayVoiceClient(
|
||||
suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val httpBase = resolveHttpBase()
|
||||
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
val token = sessionTokenProvider()
|
||||
val token = resolveBearerToken()
|
||||
if (token.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
missingAuthError()
|
||||
)
|
||||
}
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
@@ -122,10 +125,10 @@ class RelayVoiceClient(
|
||||
suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val httpBase = resolveHttpBase()
|
||||
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
val token = sessionTokenProvider()
|
||||
val token = resolveBearerToken()
|
||||
if (token.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
missingAuthError()
|
||||
)
|
||||
}
|
||||
if (text.isBlank()) {
|
||||
@@ -187,10 +190,10 @@ class RelayVoiceClient(
|
||||
suspend fun getVoiceConfig(): Result<VoiceConfig> = withContext(Dispatchers.IO) {
|
||||
val httpBase = resolveHttpBase()
|
||||
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
val token = sessionTokenProvider()
|
||||
val token = resolveBearerToken()
|
||||
if (token.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
missingAuthError()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,8 +237,19 @@ class RelayVoiceClient(
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
private suspend fun resolveBearerToken(): String? {
|
||||
val apiBearer = apiBearerTokenProvider()?.trim()
|
||||
if (!apiBearer.isNullOrBlank()) return apiBearer
|
||||
val sessionToken = sessionTokenProvider()?.trim()
|
||||
return sessionToken?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun missingAuthError(): IllegalStateException =
|
||||
IllegalStateException("Voice auth missing — pair with the relay or save a Hermes API key")
|
||||
|
||||
private fun describeHttpError(code: Int, message: String): String = when (code) {
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
401 -> "Voice auth failed — check the Relay session or Hermes API key"
|
||||
403 -> "Voice access expired — extend or re-pair with voice grants"
|
||||
404 -> "Voice endpoint not available on this relay"
|
||||
413 -> "Audio too large for relay"
|
||||
503 -> "Voice provider unavailable (check relay config)"
|
||||
|
||||
+2146
-34
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,25 @@ package com.hermesandroid.relay.network.handlers
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
import com.hermesandroid.relay.network.models.SessionItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Manages chat message state, session list, and streaming events.
|
||||
@@ -64,6 +71,22 @@ class ChatHandler {
|
||||
// placeholder instead of attempting a fetch.
|
||||
private val mediaRelayRegex = Regex("""MEDIA:hermes-relay://([A-Za-z0-9_-]+)""")
|
||||
private val mediaBarePathRegex = Regex("""^\s*MEDIA:(/\S+)\s*$""")
|
||||
// Rich card marker — single line, full JSON object payload.
|
||||
//
|
||||
// Agents emit:
|
||||
// CARD:{"type":"approval_request","title":"...","actions":[...]}
|
||||
//
|
||||
// Constraints (mirrors the `MEDIA:` marker contract in
|
||||
// hermes-agent's prompt_builder.py): the marker MUST live on its
|
||||
// own line, and the JSON must be single-line (escape newlines in
|
||||
// string fields as `\n`). This keeps the line-buffer parser
|
||||
// trivial — same strategy as MEDIA.
|
||||
//
|
||||
// The regex is intentionally greedy on the JSON body so nested
|
||||
// braces in fields/actions are captured correctly. Invalid JSON is
|
||||
// logged and the line is left in content untouched so the user
|
||||
// still sees _something_ rather than a silent drop.
|
||||
private val cardMarkerRegex = Regex("""^\s*CARD:(\{.*\})\s*$""")
|
||||
// Known completion/failure emojis — if these appear in backtick format, it's a completion
|
||||
private val completionEmojis = setOf("✅", "✓", "☑")
|
||||
private val failureEmojis = setOf("❌", "✗", "⚠")
|
||||
@@ -125,6 +148,27 @@ class ChatHandler {
|
||||
private var mediaLineBuffer = StringBuilder()
|
||||
private val dispatchedMediaMarkers = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Separate line buffer + dedupe set for rich-card markers, mirroring
|
||||
* the media-marker pipeline above. Cards are a first-class feature
|
||||
* (not gated behind any flag), so they need their own buffer for the
|
||||
* same reason `MEDIA:` does — tool-annotation parsing can be toggled
|
||||
* off without silently dropping partial card lines.
|
||||
*/
|
||||
private var cardLineBuffer = StringBuilder()
|
||||
private val dispatchedCardMarkers = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Lenient JSON for card payloads. `ignoreUnknownKeys` means future
|
||||
* schema additions (new card types, new field shapes) won't crash
|
||||
* older phone builds — the renderer's unknown-type fallback handles
|
||||
* display.
|
||||
*/
|
||||
private val cardJson = kotlinx.serialization.json.Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which tool names currently have an active (in-progress) annotation-based
|
||||
* ToolCall, keyed by "messageId:toolName" → toolCallId. This lets us match a
|
||||
@@ -155,6 +199,337 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a local-only voice-intent trace to the chat scroll. Used by
|
||||
* the sideload voice intent flow (`RealVoiceBridgeIntentHandler`) so
|
||||
* phone-control utterances like "open Chrome" or "text Sam" leave a
|
||||
* visible record in chat history rather than vanishing into a side-
|
||||
* channel the user can't see.
|
||||
*
|
||||
* Adds two messages back-to-back:
|
||||
* - a user message with the raw transcribed text
|
||||
* - an assistant message with the action description
|
||||
*
|
||||
* The two bubbles are injected straight into [_messages] for
|
||||
* visual continuity. Server-side session sync — so the gateway LLM
|
||||
* sees prior voice actions in its memory when the user follows up
|
||||
* via text or voice — is handled by a separate path (v0.4.1):
|
||||
* the post-dispatch result bubble (emitted from
|
||||
* [com.hermesandroid.relay.viewmodel.VoiceViewModel]'s
|
||||
* `onDispatchResult` callback into [appendLocalVoiceIntentResult])
|
||||
* carries a structured [com.hermesandroid.relay.data.VoiceIntentTrace],
|
||||
* which [com.hermesandroid.relay.voice.VoiceIntentSyncBuilder]
|
||||
* reads on the next chat send to synthesize an OpenAI
|
||||
* `assistant` (with `tool_calls`) + `tool` message pair that
|
||||
* rides under the request body's `messages` field. This pre-
|
||||
* dispatch bubble carries no trace — it's purely a UI marker.
|
||||
*
|
||||
* @param userText The raw transcribed voice utterance.
|
||||
* @param actionDescription Human-readable description of what the
|
||||
* bridge layer did (or is about to do). Shown verbatim in the
|
||||
* assistant message bubble.
|
||||
*/
|
||||
fun appendLocalVoiceIntentTrace(
|
||||
userText: String,
|
||||
actionDescription: String,
|
||||
voiceIntent: VoiceIntentTrace? = null,
|
||||
) {
|
||||
val ts = System.currentTimeMillis()
|
||||
val userMsg = ChatMessage(
|
||||
id = "voice-intent-user-$ts",
|
||||
role = MessageRole.USER,
|
||||
content = userText,
|
||||
timestamp = ts,
|
||||
)
|
||||
val assistantMsg = ChatMessage(
|
||||
id = "voice-intent-action-$ts",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = actionDescription,
|
||||
timestamp = ts + 1,
|
||||
// Mark the personality so the bubble doesn't render under the
|
||||
// current personality's avatar (which would imply the LLM said
|
||||
// it). The chat UI's existing agentName plumbing handles the
|
||||
// alternate label automatically.
|
||||
agentName = "Voice action",
|
||||
// Structured trace — read by VoiceIntentSyncBuilder on the next
|
||||
// chat send to materialize OpenAI-format tool_call + tool
|
||||
// messages so the server-side LLM sees the action in its
|
||||
// session memory. Null for the pre-dispatch user bubble (the
|
||||
// raw transcribed utterance carries no structure on its own).
|
||||
voiceIntent = voiceIntent,
|
||||
)
|
||||
_messages.update { list ->
|
||||
(list + userMsg + assistantMsg).let {
|
||||
if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append ONLY an assistant-role bubble showing the post-dispatch
|
||||
* outcome of a voice intent action (e.g. "SMS sent", "user denied",
|
||||
* "permission missing"). Called by [ChatViewModel.recordVoiceIntentResult]
|
||||
* after the phone-side executor returns, so the user sees the actual
|
||||
* result of the destructive-verb flow instead of just the pre-dispatch
|
||||
* preview. ID prefix `voice-intent-result-` makes this survive
|
||||
* [loadMessageHistory] reloads the same way the pre-dispatch trace
|
||||
* does, and also lets [CompactTranscriptRow] in voice mode render it
|
||||
* via MarkdownContent.
|
||||
*
|
||||
* [agentName] defaults to "Voice action" for the voice-mode origin
|
||||
* (classifier → sideload handler path) but chat mode tool-call
|
||||
* parity passes "Phone action" so the label reflects that the bubble
|
||||
* is a structured trace of an LLM-initiated android_* tool call
|
||||
* rather than a user utterance classified and dispatched locally.
|
||||
*/
|
||||
fun appendLocalVoiceIntentResult(
|
||||
description: String,
|
||||
agentName: String = "Voice action",
|
||||
voiceIntent: VoiceIntentTrace? = null,
|
||||
) {
|
||||
val ts = System.currentTimeMillis()
|
||||
val resultMsg = ChatMessage(
|
||||
id = "voice-intent-result-$ts",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = description,
|
||||
timestamp = ts,
|
||||
agentName = agentName,
|
||||
// When the dispatch result lands AFTER the pre-dispatch trace
|
||||
// (destructive intents wait on the 5-second countdown), the
|
||||
// post-dispatch bubble carries the authoritative success /
|
||||
// failure data — this is the bubble VoiceIntentSyncBuilder
|
||||
// should read for the synthetic tool-response, not the
|
||||
// pre-dispatch placeholder. The VoiceViewModel post-dispatch
|
||||
// path passes a fully-populated VoiceIntentTrace here; safe
|
||||
// intents (where the dispatch already happened before the
|
||||
// pre-dispatch trace was appended) leave this null and rely
|
||||
// on the pre-dispatch trace's voiceIntent field.
|
||||
voiceIntent = voiceIntent,
|
||||
)
|
||||
_messages.update { list ->
|
||||
(list + resultMsg).let {
|
||||
if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip [VoiceIntentTrace.syncedToServer] to true on every voice-intent
|
||||
* message currently in the message list, so they're not re-emitted in
|
||||
* the next chat payload's synthetic-messages array. Called from
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel.startStream]
|
||||
* immediately after the API client takes ownership of the request — at
|
||||
* that point the server-side session has the synthetic context, and
|
||||
* future sends should not duplicate it.
|
||||
*
|
||||
* Only mutates messages where the embedded
|
||||
* [com.hermesandroid.relay.data.VoiceIntentTrace.syncedToServer] is
|
||||
* currently false, so this is safe to call repeatedly without
|
||||
* triggering redundant StateFlow emissions.
|
||||
*/
|
||||
fun markVoiceIntentsSynced() {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
val trace = msg.voiceIntent
|
||||
if (trace != null && !trace.syncedToServer) {
|
||||
changed = true
|
||||
msg.copy(voiceIntent = trace.copy(syncedToServer = true))
|
||||
} else msg
|
||||
}
|
||||
if (changed) mapped else messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Twin of [markVoiceIntentsSynced] for rich-card action dispatches.
|
||||
* Flips [com.hermesandroid.relay.data.HermesCardDispatch.syncedToServer]
|
||||
* to true on every dispatch whose flag is currently false, so the
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] doesn't
|
||||
* re-emit them on the next chat send. Called from
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel.startStream] at the
|
||||
* same point — right after the API client accepts the request — so
|
||||
* the voice-intent and card-dispatch sync paths have identical
|
||||
* commit-timing semantics and a thrown request-building exception
|
||||
* falsely marks neither stream as synced.
|
||||
*/
|
||||
fun markCardDispatchesSynced() {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
if (msg.cardDispatches.isEmpty()) return@map msg
|
||||
val anyUnsynced = msg.cardDispatches.any { !it.syncedToServer }
|
||||
if (!anyUnsynced) return@map msg
|
||||
changed = true
|
||||
msg.copy(
|
||||
cardDispatches = msg.cardDispatches.map {
|
||||
if (it.syncedToServer) it else it.copy(syncedToServer = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (changed) mapped else messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable lenient JSON parser for tool-result previews. [Json { ... }]
|
||||
* is cheap to construct but we share one instance so per-tool-completion
|
||||
* parsing doesn't churn allocations.
|
||||
*/
|
||||
private val phoneActionResultJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a just-completed android_* tool call and, if it's an ACTION
|
||||
* tool (not a read-only probe or UI micro-action), synthesize a
|
||||
* [LocalDispatchResult]-shaped outcome from [resultPreview] and emit a
|
||||
* structured follow-up bubble via [appendLocalVoiceIntentResult]. This
|
||||
* gives chat-mode tool calls the same post-dispatch feedback the voice
|
||||
* flow got in 0.4.1, so the user always sees a visible success/failure
|
||||
* trace even when the LLM's narration is lossy or quiet.
|
||||
*
|
||||
* [isFailure] true means we're being called from [onToolCallFailed] —
|
||||
* in that case [resultPreview] is the error string, not a JSON blob,
|
||||
* and we skip the parse.
|
||||
*/
|
||||
private fun maybeEmitPhoneActionBubble(
|
||||
toolName: String,
|
||||
resultPreview: String?,
|
||||
isFailure: Boolean,
|
||||
) {
|
||||
val label = labelForAndroidTool(toolName) ?: return
|
||||
|
||||
val synthetic = if (isFailure) {
|
||||
LocalDispatchResult(
|
||||
status = 500,
|
||||
errorMessage = resultPreview?.ifBlank { null },
|
||||
errorCode = null,
|
||||
resultJson = null,
|
||||
)
|
||||
} else {
|
||||
parseAndroidToolResult(resultPreview)
|
||||
}
|
||||
|
||||
val description = formatPhoneActionResult(label, synthetic)
|
||||
appendLocalVoiceIntentResult(description, agentName = "Phone action")
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an `android_*` tool name to a short human-readable action label,
|
||||
* or null if the tool is read-only / UI-micro and should NOT emit a
|
||||
* result bubble.
|
||||
*
|
||||
* Philosophy: only meaningful action-completion states earn a bubble.
|
||||
* Read probes (`android_read_screen`, `android_get_apps`) and UI
|
||||
* micro-actions (`android_tap`, `android_swipe`) already render as a
|
||||
* [com.hermesandroid.relay.data.ToolCall] card on the assistant
|
||||
* message, so emitting a second bubble for each would just spam the
|
||||
* scrollback.
|
||||
*/
|
||||
private fun labelForAndroidTool(toolName: String): String? {
|
||||
if (!toolName.startsWith("android_")) return null
|
||||
return when (toolName) {
|
||||
"android_send_sms" -> "Send SMS"
|
||||
"android_call" -> "Call"
|
||||
"android_search_contacts" -> "Search Contacts"
|
||||
"android_open_app" -> "Open App"
|
||||
"android_return_to_hermes" -> "Return to Hermes"
|
||||
"android_screenshot" -> "Screenshot"
|
||||
"android_press_key" -> "Key Press"
|
||||
"android_setup" -> "Bridge Setup"
|
||||
// Read-only / UI micro-actions — intentionally skipped.
|
||||
// The ToolProgressCard on the assistant bubble already
|
||||
// surfaces these inline; an extra result bubble would be
|
||||
// noise, not signal.
|
||||
"android_read_screen",
|
||||
"android_find_nodes",
|
||||
"android_tap",
|
||||
"android_tap_text",
|
||||
"android_long_press",
|
||||
"android_type",
|
||||
"android_swipe",
|
||||
"android_scroll",
|
||||
"android_drag",
|
||||
"android_wait",
|
||||
"android_get_apps",
|
||||
"android_current_app",
|
||||
"android_describe_node",
|
||||
"android_ping",
|
||||
"android_clipboard_read",
|
||||
"android_clipboard_write",
|
||||
"android_media",
|
||||
"android_screen_hash",
|
||||
"android_diff_screen",
|
||||
"android_events",
|
||||
"android_event_stream",
|
||||
"android_location",
|
||||
"android_macro",
|
||||
"android_send_intent",
|
||||
"android_broadcast" -> null
|
||||
// Unknown android_* tools still get a generic label so new
|
||||
// additions don't vanish silently — the catchall in
|
||||
// [formatPhoneActionResult] handles them.
|
||||
else -> toolName
|
||||
.removePrefix("android_")
|
||||
.replace('_', ' ')
|
||||
.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an `android_*` tool result JSON preview into a
|
||||
* [LocalDispatchResult]-shaped struct so [formatPhoneActionResult]
|
||||
* can reuse the voice-mode formatter verbatim. Plugin handlers in
|
||||
* `plugin/tools/android_tool.py` return `json.dumps(data)` with
|
||||
* `ok: bool` / `error: str` fields, so we just look those up.
|
||||
*
|
||||
* Fails open: if the preview is missing, blank, truncated, or
|
||||
* otherwise unparseable we return a success result with no error
|
||||
* message. A streaming tool completion should never crash chat just
|
||||
* because the result preview was malformed.
|
||||
*/
|
||||
private fun parseAndroidToolResult(resultPreview: String?): LocalDispatchResult {
|
||||
val raw = resultPreview?.trim().orEmpty()
|
||||
if (raw.isEmpty() || !raw.startsWith("{")) {
|
||||
return LocalDispatchResult(
|
||||
status = 200,
|
||||
errorMessage = null,
|
||||
errorCode = null,
|
||||
resultJson = null,
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val obj = phoneActionResultJson.parseToJsonElement(raw).jsonObject
|
||||
val ok = obj["ok"]?.jsonPrimitive?.booleanOrNull
|
||||
val errorMsg = obj["error"]?.jsonPrimitive?.contentOrNull
|
||||
?: obj["message"]?.jsonPrimitive?.contentOrNull
|
||||
val errorCode = obj["error_code"]?.jsonPrimitive?.contentOrNull
|
||||
?: obj["code"]?.jsonPrimitive?.contentOrNull
|
||||
val status = when {
|
||||
ok == true -> 200
|
||||
ok == false -> 400
|
||||
errorMsg != null -> 400
|
||||
else -> 200
|
||||
}
|
||||
LocalDispatchResult(
|
||||
status = status,
|
||||
errorMessage = errorMsg,
|
||||
errorCode = errorCode,
|
||||
resultJson = obj,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "parseAndroidToolResult: unparseable preview (${e.message})")
|
||||
LocalDispatchResult(
|
||||
status = 200,
|
||||
errorMessage = null,
|
||||
errorCode = null,
|
||||
resultJson = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a placeholder assistant message immediately after the user sends,
|
||||
* showing streaming dots before the first SSE delta arrives.
|
||||
@@ -175,6 +550,8 @@ class ChatHandler {
|
||||
dispatchedMediaMarkers.clear()
|
||||
annotationLineBuffer.clear()
|
||||
activeAnnotationTools.clear()
|
||||
cardLineBuffer.clear()
|
||||
dispatchedCardMarkers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,19 +641,32 @@ class ChatHandler {
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
val cleanedContent = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
||||
} else {
|
||||
rawContent
|
||||
}
|
||||
|
||||
// Cards are synchronous (no async fetch) so we attach them
|
||||
// straight onto the reconstructed ChatMessage and strip their
|
||||
// lines from the displayed content in the same pass. No
|
||||
// post-assignment dispatch needed.
|
||||
val (cleanedContent, extractedCards) = if (
|
||||
role == MessageRole.ASSISTANT && afterMedia.isNotEmpty()
|
||||
) {
|
||||
extractCardsFromContent(afterMedia)
|
||||
} else {
|
||||
afterMedia to emptyList()
|
||||
}
|
||||
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
role = role,
|
||||
content = cleanedContent,
|
||||
timestamp = timestampMs,
|
||||
isStreaming = false,
|
||||
toolCalls = toolCalls
|
||||
toolCalls = toolCalls,
|
||||
cards = extractedCards,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -285,7 +675,34 @@ class ChatHandler {
|
||||
// just collected against the reloaded IDs are guaranteed to fire.
|
||||
dispatchedMediaMarkers.clear()
|
||||
|
||||
_messages.value = if (loaded.size > MAX_MESSAGES) loaded.takeLast(MAX_MESSAGES) else loaded
|
||||
// === PHASE3-voice-intents-chathistory ===
|
||||
// Preserve local-only voice-intent trace messages across a reload.
|
||||
// These messages are injected by [appendLocalVoiceIntentTrace] with
|
||||
// IDs prefixed "voice-intent-" and never reach the server-side
|
||||
// session, so a wholesale `_messages.value = loaded` assignment
|
||||
// would wipe them. Bailey hit this 2026-04-15: voice fall-through
|
||||
// ("proceed" → not a recognized intent → chat.sendMessage) triggered
|
||||
// a history reload on stream complete and the previous voice trace
|
||||
// vanished, making it look like "the chat cleared". Server-side
|
||||
// sync (so these traces reach the LLM's session memory too) is
|
||||
// still a v0.4.1 follow-up, but preserving them client-side is
|
||||
// enough to fix the disappearing-scrollback bug today.
|
||||
val preservedVoiceTraces = _messages.value.filter {
|
||||
it.id.startsWith("voice-intent-")
|
||||
}
|
||||
val merged = if (preservedVoiceTraces.isEmpty()) {
|
||||
loaded
|
||||
} else {
|
||||
// Merge by timestamp so voice traces interleave with the
|
||||
// reloaded server messages in chronological order. The voice
|
||||
// trace IDs carry `System.currentTimeMillis()` in their suffix
|
||||
// (see appendLocalVoiceIntentTrace), so ChatMessage.timestamp
|
||||
// is the source of truth here.
|
||||
(loaded + preservedVoiceTraces).sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
_messages.value = if (merged.size > MAX_MESSAGES) merged.takeLast(MAX_MESSAGES) else merged
|
||||
// === END PHASE3-voice-intents-chathistory ===
|
||||
|
||||
// Now that the reloaded messages are in state, fire callbacks so the
|
||||
// ViewModel can insert LOADING/FAILED attachments via mutateMessage.
|
||||
@@ -357,6 +774,38 @@ class ChatHandler {
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan loaded content for `CARD:{json}` lines, parse each to a
|
||||
* [HermesCard], return the cleaned content + the extracted cards. Pure
|
||||
* function — does not mutate [_messages] or mark anything dispatched.
|
||||
* Called from [loadMessageHistory]. Unparseable card lines are left in
|
||||
* the content (same policy as [tryDispatchCardMarker]) so the user
|
||||
* sees a visible artifact instead of a silent drop.
|
||||
*/
|
||||
private fun extractCardsFromContent(content: String): Pair<String, List<HermesCard>> {
|
||||
var cleaned = content
|
||||
val cards = mutableListOf<HermesCard>()
|
||||
for (rawLine in content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
val match = cardMarkerRegex.find(trimmed) ?: continue
|
||||
val payload = match.groupValues[1]
|
||||
val card = try {
|
||||
cardJson.decodeFromString(HermesCard.serializer(), payload)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Card reload parse failed: ${e.message}")
|
||||
continue
|
||||
}
|
||||
cards += card
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
.replace("$rawLine\n", "")
|
||||
.replace(rawLine, "")
|
||||
}
|
||||
return cleaned.trim() to cards
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the tool_calls JSON from an assistant message into ToolCall objects.
|
||||
* Format: array of objects with {id, type:"function", function: {name, arguments}}
|
||||
@@ -512,6 +961,12 @@ class ChatHandler {
|
||||
// Always scan for media markers — inbound attachments are a first-class
|
||||
// feature and shouldn't be gated behind the tool-annotation flag.
|
||||
scanForMediaMarkers(messageId, processedDelta)
|
||||
|
||||
// Rich cards ride the same "always on" treatment as media — the
|
||||
// agent can emit `CARD:{json}` at any point in any endpoint and
|
||||
// the renderer should pick it up without the user having to
|
||||
// opt in to any parsing mode.
|
||||
scanForCardMarkers(messageId, processedDelta)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -650,6 +1105,116 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-marker line scanner — twin of [scanForMediaMarkers] for
|
||||
* `CARD:{json}` rows. Matched cards are appended to the message's
|
||||
* [ChatMessage.cards] list and the raw line is stripped from
|
||||
* [ChatMessage.content] so the user only sees the rendered
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble], never the
|
||||
* literal JSON.
|
||||
*/
|
||||
private fun scanForCardMarkers(messageId: String, delta: String) {
|
||||
cardLineBuffer.append(delta)
|
||||
|
||||
while (true) {
|
||||
val newlineIndex = cardLineBuffer.indexOf('\n')
|
||||
if (newlineIndex == -1) break
|
||||
|
||||
val line = cardLineBuffer.substring(0, newlineIndex)
|
||||
cardLineBuffer.delete(0, newlineIndex + 1)
|
||||
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
|
||||
if (tryDispatchCardMarker(messageId, trimmed)) {
|
||||
stripLineFromContent(messageId, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single `CARD:{json}` line. On success, appends the card to
|
||||
* [ChatMessage.cards] for [messageId]. Dedupes by "messageId:cardKey"
|
||||
* where cardKey is the parsed [HermesCard.id] or a SHA-style hash
|
||||
* (content-based) fallback, so the same card re-appearing during
|
||||
* streaming + finalize reconciliation doesn't render twice.
|
||||
*
|
||||
* Invalid JSON is logged and returns false — the caller leaves the
|
||||
* line in the content, which at least gives the user a visible hint
|
||||
* that the agent tried to emit a card the phone couldn't parse.
|
||||
*/
|
||||
private fun tryDispatchCardMarker(messageId: String, line: String): Boolean {
|
||||
val match = cardMarkerRegex.find(line) ?: return false
|
||||
val payload = match.groupValues[1]
|
||||
|
||||
val card = try {
|
||||
cardJson.decodeFromString(HermesCard.serializer(), payload)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Card marker parse failed: ${e.message} | payload=${payload.take(200)}")
|
||||
return false
|
||||
}
|
||||
|
||||
// Content-based fallback key when the agent didn't supply an id —
|
||||
// good enough for dedupe of the exact same card within a single
|
||||
// message turn.
|
||||
val cardKey = card.id ?: "anon:${payload.hashCode()}"
|
||||
val dedupeKey = "$messageId:$cardKey"
|
||||
if (!dispatchedCardMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Card marker duplicate, skipping: $cardKey")
|
||||
return true // Still strip the line — it's a valid card, just a repeat.
|
||||
}
|
||||
|
||||
Log.d(TAG, "Card marker: type=${card.type} key=$cardKey")
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(cards = msg.cards + card)
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the card line buffer + post-stream reconcile, twin of
|
||||
* [finalizeMediaMarkers]. Guards against the race where a CARD: line
|
||||
* arrived as the last delta with no trailing newline, and also
|
||||
* re-sweeps the finalized content for any card lines that survived
|
||||
* real-time stripping (update-ordering race against [stripLineFromContent]).
|
||||
*/
|
||||
private fun finalizeCardMarkers(messageId: String) {
|
||||
if (cardLineBuffer.isNotEmpty()) {
|
||||
val remaining = cardLineBuffer.toString().trim()
|
||||
cardLineBuffer.clear()
|
||||
if (remaining.isNotEmpty() && tryDispatchCardMarker(messageId, remaining)) {
|
||||
stripLineFromContent(messageId, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(trimmed)) {
|
||||
tryDispatchCardMarker(messageId, trimmed)
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
.replace("$rawLine\n", "")
|
||||
.replace(rawLine, "")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) msg.copy(content = cleaned.trim()) else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect [line] for a media marker and dispatch the appropriate callback.
|
||||
*
|
||||
@@ -1001,6 +1566,14 @@ class ChatHandler {
|
||||
}
|
||||
|
||||
fun onToolCallComplete(messageId: String, toolCallId: String, resultPreview: String? = null) {
|
||||
// Snapshot the matching tool call's name BEFORE mutating — we need it
|
||||
// to decide whether to emit a phone-action result bubble below.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
@@ -1022,9 +1595,28 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat parity: emit a structured follow-up bubble for android_*
|
||||
// action tools the LLM called, mirroring the voice-mode post-
|
||||
// dispatch feedback path. Only fires for ACTION tools — read-only
|
||||
// and UI micro-actions are skipped (see [labelForAndroidTool]) so
|
||||
// chat parity doesn't spam the scrollback with `android_read_screen`
|
||||
// or `android_tap` bubbles that the existing ToolProgressCard
|
||||
// already surfaces inline on the assistant message.
|
||||
if (toolName != null) {
|
||||
maybeEmitPhoneActionBubble(toolName, resultPreview, isFailure = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallFailed(messageId: String, toolCallId: String, error: String?) {
|
||||
// Snapshot tool name before the update so the phone-action bubble
|
||||
// can label the failure correctly.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
@@ -1046,6 +1638,14 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName != null) {
|
||||
maybeEmitPhoneActionBubble(
|
||||
toolName = toolName,
|
||||
resultPreview = error,
|
||||
isFailure = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1077,6 +1677,9 @@ class ChatHandler {
|
||||
|
||||
// Finalize media markers unconditionally (not gated by parseToolAnnotations)
|
||||
finalizeMediaMarkers(messageId)
|
||||
// Rich cards get the same unconditional treatment — a partial
|
||||
// card line without a trailing newline should still render.
|
||||
finalizeCardMarkers(messageId)
|
||||
// Note: do NOT set _isStreaming to false — the run is still active
|
||||
}
|
||||
|
||||
@@ -1111,6 +1714,7 @@ class ChatHandler {
|
||||
|
||||
// Finalize media markers unconditionally
|
||||
finalizeMediaMarkers(messageId)
|
||||
finalizeCardMarkers(messageId)
|
||||
}
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
@@ -1169,6 +1773,37 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Card action dispatch ---
|
||||
|
||||
/**
|
||||
* Record that the user tapped an action on a rich card. Appends a
|
||||
* [com.hermesandroid.relay.data.HermesCardDispatch] to the owning
|
||||
* message's dispatch list, which
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble] reads to
|
||||
* collapse the action row into a "you chose X" confirmation.
|
||||
*
|
||||
* Idempotent — taps on the same (cardKey, actionValue) pair are
|
||||
* silently coalesced, so tapping twice during a slow send doesn't
|
||||
* double-stamp the history.
|
||||
*/
|
||||
fun recordCardDispatch(messageId: String, cardKey: String, actionValue: String) {
|
||||
val stamp = com.hermesandroid.relay.data.HermesCardDispatch(
|
||||
cardKey = cardKey,
|
||||
actionValue = actionValue,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
val alreadyDispatched = msg.cardDispatches.any {
|
||||
it.cardKey == cardKey && it.actionValue == actionValue
|
||||
}
|
||||
if (alreadyDispatched) msg
|
||||
else msg.copy(cardDispatches = msg.cardDispatches + stamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Retry support ---
|
||||
|
||||
private val _lastSentMessage = MutableStateFlow<String?>(null)
|
||||
@@ -1178,3 +1813,60 @@ class ChatHandler {
|
||||
_lastSentMessage.value = text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-formatted description of a [LocalDispatchResult] for rendering
|
||||
* in a chat bubble. Shared between the voice post-dispatch feedback path
|
||||
* ([com.hermesandroid.relay.viewmodel.ChatViewModel.recordVoiceIntentResult])
|
||||
* and the chat-mode android_* tool-completion path ([ChatHandler.onToolCallComplete])
|
||||
* so both origins render identical-looking bubbles for identical outcomes.
|
||||
*
|
||||
* The label parameter is the short human-readable action name
|
||||
* ("Send SMS", "Open App", "Call", etc). Error-code branches mirror the
|
||||
* `error_code` strings [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
|
||||
* emits on destructive-verb rejections.
|
||||
*/
|
||||
internal fun formatPhoneActionResult(
|
||||
label: String,
|
||||
result: LocalDispatchResult,
|
||||
): String = when {
|
||||
result.isSuccess -> when (label) {
|
||||
"Send SMS" -> "**$label — sent** ✓"
|
||||
"Open App" -> "**$label — opened** ✓"
|
||||
"Tap" -> "**$label — done** ✓"
|
||||
"Navigate back" -> "**$label — done** ✓"
|
||||
"Home" -> "**$label — done** ✓"
|
||||
else -> "**$label — complete** ✓"
|
||||
}
|
||||
result.errorCode == "user_denied" -> buildString {
|
||||
append("**$label — cancelled by you**")
|
||||
}
|
||||
result.errorCode == "bridge_disabled" -> buildString {
|
||||
append("**$label — agent control is off**")
|
||||
append('\n')
|
||||
append("Enable Agent Control in the Hermes Bridge tab to retry.")
|
||||
}
|
||||
result.errorCode == "permission_denied" -> buildString {
|
||||
append("**$label — permission needed**")
|
||||
append('\n')
|
||||
append(result.errorMessage ?: "The phone is missing a required runtime permission.")
|
||||
}
|
||||
result.errorCode == "service_unavailable" -> buildString {
|
||||
append("**$label — bridge offline**")
|
||||
append('\n')
|
||||
append("The accessibility service isn't connected. Enable Hermes accessibility in Settings.")
|
||||
}
|
||||
result.errorCode == "cancelled" -> buildString {
|
||||
append("**$label — cancelled before dispatch**")
|
||||
}
|
||||
result.errorMessage != null -> buildString {
|
||||
append("**$label — failed**")
|
||||
append('\n')
|
||||
append(result.errorMessage)
|
||||
}
|
||||
else -> buildString {
|
||||
append("**$label — failed**")
|
||||
append('\n')
|
||||
append("Status ${result.status}.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
package com.hermesandroid.relay.network.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Standard relay envelope. Most channel traffic nests data in [payload]
|
||||
* so a single decoder handles every message shape.
|
||||
*
|
||||
* **`profiles` escape hatch.** The `pairing`-channel `profiles.updated`
|
||||
* push envelope (see `AuthManager.handleProfilesUpdated`) hoists its
|
||||
* profiles array to the top level of the JSON rather than nesting it
|
||||
* inside payload. Rather than duplicating the kotlinx.serialization
|
||||
* pipeline for one message type, we widen [Envelope] with an optional
|
||||
* top-level [profiles] array. Every other envelope type leaves it null
|
||||
* — kotlinx.serialization tolerates an absent field because of the
|
||||
* default.
|
||||
*/
|
||||
@Serializable
|
||||
data class Envelope(
|
||||
val channel: String,
|
||||
val type: String,
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val payload: JsonObject = buildJsonObject {}
|
||||
val payload: JsonObject = buildJsonObject {},
|
||||
val profiles: JsonArray? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.hermesandroid.relay.power
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* A8 — Wake-scope wrapper for bridge gesture dispatch.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* When the phone is idle (screen dimmed or the CPU has drifted into a
|
||||
* low-power state), `AccessibilityService.dispatchGesture` and
|
||||
* `ACTION_SET_TEXT` sometimes silently fail or land after a multi-second
|
||||
* lag — the gesture's `GestureResultCallback` never fires because the
|
||||
* looper stalls. The symptom on-device is a bridge command that "worked"
|
||||
* over the wire but produced no visible effect.
|
||||
*
|
||||
* Wrapping the gesture-dispatching entry points of [com.hermesandroid.relay.accessibility.ActionExecutor]
|
||||
* in [wakeForAction] holds a wake lock just long enough for the gesture
|
||||
* to be issued, dispatched, and its completion callback delivered, then
|
||||
* releases. Ref-counted so nested calls (e.g. `tapText` → `tap`) don't
|
||||
* release each other's lock prematurely.
|
||||
*
|
||||
* ## Wake-lock flag choice
|
||||
*
|
||||
* We use [PowerManager.PARTIAL_WAKE_LOCK]. The classic "screen stays on"
|
||||
* flags (`SCREEN_BRIGHT_WAKE_LOCK`, `FULL_WAKE_LOCK`) have been deprecated
|
||||
* since API 17 — Google's guidance is that anything needing the screen
|
||||
* awake should use `Window.FLAG_KEEP_SCREEN_ON` or
|
||||
* `Activity.setTurnScreenOn(true)` / `KeyguardManager.requestDismissKeyguard`
|
||||
* from a visible surface. We are in a background service with no window,
|
||||
* so those APIs don't apply — but we also don't need the screen bright;
|
||||
* we just need the CPU to stay scheduled long enough for the gesture to
|
||||
* land and its callback to fire. `PARTIAL_WAKE_LOCK` does exactly that
|
||||
* and is the only non-deprecated wake-lock flag remaining under our
|
||||
* minSdk = 26 / targetSdk = 35 constraints.
|
||||
*
|
||||
* Note: this does **not** wake a fully-off screen. If the device is
|
||||
* genuinely locked, the gesture will still no-op against the lock screen
|
||||
* UI — that's a `KeyguardManager` / foreground-activity problem, not a
|
||||
* wake-lock problem, and is intentionally out of scope for this unit.
|
||||
*
|
||||
* ## Safety rails
|
||||
*
|
||||
* - **Ref-counted** under a `synchronized` block so nested calls share
|
||||
* one physical wake lock.
|
||||
* - **10-second hard timeout** on the underlying [PowerManager.WakeLock]
|
||||
* so a crashed or long-stalled gesture can never pin the CPU awake.
|
||||
* Even a bridge command that somehow hangs for minutes will see the
|
||||
* lock self-release after 10s.
|
||||
* - **`try { block() } finally { release }`** so throwing gesture code
|
||||
* still releases the lock.
|
||||
* - **Non-reference-counted underlying [PowerManager.WakeLock]** — we
|
||||
* manage the count ourselves in [lockCount] rather than letting
|
||||
* `WakeLock.acquire/release` do it, because PowerManager's ref-count
|
||||
* is a process-global footgun that can outlive our suspend frames on
|
||||
* coroutine cancellation.
|
||||
*/
|
||||
object WakeLockManager {
|
||||
|
||||
private const val TAG = "WakeLockManager"
|
||||
private const val WAKE_LOCK_TAG = "HermesRelay::BridgeAction"
|
||||
|
||||
/**
|
||||
* Hard cap — the lock will self-release after this many ms even if
|
||||
* the block is still running. Long-held wake locks are a classic
|
||||
* battery-drain bug; gesture dispatch should never take this long.
|
||||
*/
|
||||
private const val TIMEOUT_MS: Long = 10_000L
|
||||
|
||||
@Volatile
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
private val countLock = Any()
|
||||
private var lockCount: Int = 0
|
||||
|
||||
/**
|
||||
* One-shot initializer. Call from `Application.onCreate` with the
|
||||
* application context so we can build the underlying wake lock
|
||||
* without leaking an Activity. Idempotent — subsequent calls are
|
||||
* no-ops.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
// L3 fix: previously the `if (wakeLock != null) return` check and
|
||||
// the subsequent assignment were not synchronized, so two concurrent
|
||||
// initialize() calls could each create their own WakeLock and the
|
||||
// second would overwrite the first. Benign in practice (only called
|
||||
// from Application.onCreate which is main-thread) but trivial to
|
||||
// harden by reusing the existing countLock.
|
||||
synchronized(countLock) {
|
||||
if (wakeLock != null) return
|
||||
val pm = context.applicationContext.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
if (pm == null) {
|
||||
Log.w(TAG, "PowerManager unavailable — WakeLockManager will no-op")
|
||||
return
|
||||
}
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||
// We manage ref-counting ourselves; setReferenceCounted(false)
|
||||
// means release() always fully releases, regardless of how many
|
||||
// acquire() calls preceded it.
|
||||
setReferenceCounted(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [block] with a partial wake lock held. Ref-counted so nested
|
||||
* calls (e.g. `tapText` inside `tap`) share one physical lock. The
|
||||
* lock is released in a `finally` so exceptions still clean up.
|
||||
*
|
||||
* If [initialize] was never called (e.g. the manager was never
|
||||
* wired up in `Application.onCreate`), this falls through to simply
|
||||
* running [block] with no wake lock held — the safer failure mode
|
||||
* is "bridge works but may glitch when idle", not "bridge crashes".
|
||||
*/
|
||||
suspend fun <T> wakeForAction(block: suspend () -> T): T {
|
||||
val acquired = tryAcquire()
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
if (acquired) {
|
||||
tryRelease()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the wake lock if this is the outermost call. Returns true
|
||||
* if the caller is responsible for releasing (i.e. they bumped the
|
||||
* ref count — this matters for the finally block in [wakeForAction]).
|
||||
*/
|
||||
private fun tryAcquire(): Boolean {
|
||||
val lock = wakeLock ?: return false
|
||||
synchronized(countLock) {
|
||||
if (lockCount == 0) {
|
||||
try {
|
||||
lock.acquire(TIMEOUT_MS)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "wakeLock.acquire threw: ${t.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
lockCount += 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the ref count and release the underlying lock when the
|
||||
* last waiter drops off. Tolerant of already-timed-out locks —
|
||||
* `isHeld` check avoids an `IllegalStateException` on release of a
|
||||
* lock that the 10-second timeout already reaped.
|
||||
*/
|
||||
private fun tryRelease() {
|
||||
val lock = wakeLock ?: return
|
||||
synchronized(countLock) {
|
||||
if (lockCount <= 0) {
|
||||
// Shouldn't happen, but if it does, don't let an
|
||||
// underflow wedge the counter.
|
||||
lockCount = 0
|
||||
return
|
||||
}
|
||||
lockCount -= 1
|
||||
if (lockCount == 0) {
|
||||
try {
|
||||
if (lock.isHeld) {
|
||||
lock.release()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "wakeLock.release threw: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,13 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -41,6 +44,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -52,15 +56,28 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
|
||||
import com.hermesandroid.relay.ui.components.UnattendedGlobalBanner
|
||||
import com.hermesandroid.relay.ui.components.UpdateBanner
|
||||
import com.hermesandroid.relay.update.UpdateCheckResult
|
||||
import com.hermesandroid.relay.viewmodel.UpdateViewModel
|
||||
import com.hermesandroid.relay.ui.components.WhatsNewDialog
|
||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
|
||||
@@ -73,17 +90,20 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
||||
// === END PHASE3-safety-rails ===
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ConnectionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
|
||||
import com.hermesandroid.relay.ui.screens.ConnectionsSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
|
||||
import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.VoiceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.network.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import com.hermesandroid.relay.audio.VoicePlayer
|
||||
@@ -115,14 +135,74 @@ sealed class Screen(
|
||||
val icon: ImageVector
|
||||
) {
|
||||
data object Onboarding : Screen("onboarding", "Onboarding", Icons.Filled.Settings)
|
||||
data object Chat : Screen("chat", "Chat", Icons.AutoMirrored.Filled.Chat)
|
||||
// `openAgentSheet` — optional flag that tells ChatScreen to auto-open
|
||||
// its consolidated AgentInfoSheet on first composition. Added so the
|
||||
// Settings → Active Agent card can bounce the user straight to Chat
|
||||
// with the sheet pre-opened (Connection / Profile / Personality
|
||||
// editing lives inside the sheet). The arg is consumed once and then
|
||||
// cleared from the back-stack entry's arguments so returning to the
|
||||
// Chat tab later via bottom nav does NOT re-open the sheet.
|
||||
//
|
||||
// `route` stays the NavHost route template — matching the pattern used
|
||||
// by Screen.Pair — while [route] (the function) builds concrete URIs.
|
||||
// The bottom-nav selected-state hierarchy check keys off [route]
|
||||
// (the template), so the template must match what's registered in the
|
||||
// NavHost, and the NavigationBarItem click must navigate via [route]()
|
||||
// so no unresolved `{openAgentSheet}` leaks into the destination.
|
||||
data object Chat : Screen(
|
||||
"chat?openAgentSheet={openAgentSheet}",
|
||||
"Chat",
|
||||
Icons.AutoMirrored.Filled.Chat,
|
||||
) {
|
||||
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
|
||||
fun route(openAgentSheet: Boolean = false): String =
|
||||
if (openAgentSheet) "chat?openAgentSheet=true" else "chat"
|
||||
}
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
|
||||
// Non-bottom-nav destinations — reached by explicit navigation, not the
|
||||
// NavigationBar. Paired Devices is opened from Settings → Connection.
|
||||
data object PairedDevices : Screen("paired_devices", "Paired Devices", Icons.Filled.Settings)
|
||||
// NavigationBar. "Relay sessions" is the user-facing label; the Kotlin
|
||||
// object name keeps `PairedDevices` to avoid churning navigation identifiers
|
||||
// and deep-link routes. Opened from Settings → Relay sessions and from the
|
||||
// active connection card's Security section.
|
||||
data object PairedDevices : Screen("paired_devices", "Relay sessions", Icons.Filled.Settings)
|
||||
// Full-screen pair wizard route. Replaces the old in-Settings Dialog
|
||||
// launch so the chooser + Confirm + Verify steps + the camera viewport
|
||||
// get a real fullscreen surface (the Dialog wasn't actually filling the
|
||||
// window — Settings cards were leaking through behind it).
|
||||
//
|
||||
// Multi-connection: accepts an optional `connectionId` query arg —
|
||||
// the ConnectionsSettings "Re-pair" button targets a specific
|
||||
// connection. The "Add connection" path pre-creates a placeholder
|
||||
// via `ConnectionViewModel.beginAddConnection()` and routes here
|
||||
// with that id, so the wizard's applyPairingPayload lands in the
|
||||
// new connection's auth store instead of the outgoing one's.
|
||||
data object Pair : Screen(
|
||||
"pair?connectionId={connectionId}&autoStart={autoStart}",
|
||||
"Pair",
|
||||
Icons.Filled.Settings,
|
||||
) {
|
||||
const val ARG_CONNECTION_ID: String = "connectionId"
|
||||
/**
|
||||
* Optional "skip the chooser, jump into this pair method" hint.
|
||||
* Currently only `"scan"` is recognised — the "Add connection" FAB
|
||||
* on the Connections screen passes it so the camera opens
|
||||
* immediately instead of forcing the user through the Method step.
|
||||
* Re-pair flows intentionally leave this null so the full chooser
|
||||
* (Scan / Enter code / Show code) remains available.
|
||||
*/
|
||||
const val ARG_AUTO_START: String = "autoStart"
|
||||
fun route(connectionId: String? = null, autoStart: String? = null): String {
|
||||
val params = buildList {
|
||||
if (connectionId != null) add("connectionId=$connectionId")
|
||||
if (autoStart != null) add("autoStart=$autoStart")
|
||||
}
|
||||
return if (params.isEmpty()) "pair" else "pair?${params.joinToString("&")}"
|
||||
}
|
||||
}
|
||||
data object ConnectionsSettings : Screen("settings/connections", "Connections", Icons.Filled.Settings)
|
||||
data object VoiceSettings : Screen("voice_settings", "Voice", Icons.Filled.Settings)
|
||||
// === PHASE3-notif-listener-followup ===
|
||||
data object NotificationCompanionSettings :
|
||||
@@ -134,13 +214,61 @@ sealed class Screen(
|
||||
// === END PHASE3-safety-rails ===
|
||||
// Per-category settings sub-screens — split out of the mega SettingsScreen
|
||||
// following the VoiceSettingsScreen pattern (see DEVLOG 2026-04-11).
|
||||
data object ConnectionSettings : Screen("settings/connection", "Connection", Icons.Filled.Settings)
|
||||
// (The singular `ConnectionSettings` object was removed on 2026-04-21
|
||||
// when its underlying screen was collapsed into the active card of
|
||||
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
|
||||
// above for the surviving route.)
|
||||
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
|
||||
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
|
||||
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
|
||||
data object Analytics : Screen("settings/analytics", "Analytics", Icons.Filled.Settings)
|
||||
data object DeveloperSettings : Screen("settings/developer", "Developer", Icons.Filled.Settings)
|
||||
data object About : Screen("settings/about", "About", Icons.Filled.Settings)
|
||||
|
||||
// Profile Inspector — full-screen read-only viewer with 4 tabs
|
||||
// (Config / SOUL / Memory / Skills) for a single profile. The
|
||||
// `profileName` path segment survives process death via Android's
|
||||
// SavedStateHandle arg propagation; the route template is registered
|
||||
// in the NavHost with a typed `StringType` arg, and the concrete
|
||||
// URI is built by `route(profileName)`.
|
||||
data object ProfileInspector : Screen(
|
||||
"settings/profile_inspector/{profileName}?section={section}",
|
||||
"Profile Inspector",
|
||||
Icons.Filled.Settings,
|
||||
) {
|
||||
const val ARG_PROFILE_NAME: String = "profileName"
|
||||
const val ARG_SECTION: String = "section"
|
||||
|
||||
/** Tab sections accepted by the `section` query arg. */
|
||||
const val SECTION_CONFIG: String = "config"
|
||||
const val SECTION_SOUL: String = "soul"
|
||||
const val SECTION_MEMORY: String = "memory"
|
||||
const val SECTION_SKILLS: String = "skills"
|
||||
|
||||
/**
|
||||
* Build a concrete nav URI for the Profile Inspector.
|
||||
*
|
||||
* @param profileName the profile to inspect (required).
|
||||
* @param section which tab to land on. One of
|
||||
* [SECTION_CONFIG] / [SECTION_SOUL] /
|
||||
* [SECTION_MEMORY] / [SECTION_SKILLS].
|
||||
* Defaults to [SECTION_CONFIG] so callers
|
||||
* that don't care about the tab — the
|
||||
* common "Inspect" card entry — land on
|
||||
* Config, matching the pre-deep-link
|
||||
* behaviour.
|
||||
*
|
||||
* Backwards compat: the route template still accepts a
|
||||
* call without `?section=` because the query arg has a
|
||||
* default value in the navArgument declaration. Old
|
||||
* deep-links without the arg resolve to Config.
|
||||
*/
|
||||
fun route(profileName: String, section: String = SECTION_CONFIG): String {
|
||||
val encoded = java.net.URLEncoder.encode(profileName, "UTF-8")
|
||||
.replace("+", "%20")
|
||||
return "settings/profile_inspector/$encoded?section=$section"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val bottomNavScreens = listOf(
|
||||
@@ -156,19 +284,53 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = viewModel()
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = viewModel()
|
||||
val updateViewModel: UpdateViewModel = viewModel()
|
||||
|
||||
// Composition-scoped coroutine scope for firing connection-store suspend
|
||||
// writes off of UI click handlers (rename/revoke/remove) —
|
||||
// ConnectionStore's mutations are all suspend fns and we don't want to
|
||||
// block the main dispatcher from inside the composable body.
|
||||
val connectionSwitchScope = rememberCoroutineScope()
|
||||
|
||||
// One-time init: the terminal channel ViewModel registers with the shared
|
||||
// multiplexer and observes the relay connection state so it can attach/
|
||||
// reattach automatically on network changes.
|
||||
val terminalAppContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
|
||||
LaunchedEffect(Unit) {
|
||||
terminalViewModel.initialize(
|
||||
multiplexer = connectionViewModel.multiplexer,
|
||||
connectionState = connectionViewModel.relayConnectionState,
|
||||
authState = connectionViewModel.authState,
|
||||
authManager = connectionViewModel.authManager
|
||||
authManager = connectionViewModel.authManager,
|
||||
tabNameStore = com.hermesandroid.relay.data.TerminalTabNameStore(terminalAppContext),
|
||||
)
|
||||
}
|
||||
|
||||
// Cold-start relay kick.
|
||||
//
|
||||
// The ON_RESUME observer installed below in DisposableEffect misses the
|
||||
// Activity's very first ON_RESUME because DisposableEffect attaches the
|
||||
// observer AFTER the Activity has already resumed — LifecycleEventObserver
|
||||
// does not fire state transitions retroactively, it only sees *future*
|
||||
// events. That meant cold-start users had a disconnected relay UI until
|
||||
// they navigated to Settings (whose own `LaunchedEffect(Unit)` fires
|
||||
// `reconnectIfStale()` on entry) or backgrounded + foregrounded the app
|
||||
// to trigger a fresh ON_RESUME.
|
||||
//
|
||||
// Watching authState here handles both branches: on cold start the
|
||||
// persisted session rehydrates asynchronously from AuthManager and flips
|
||||
// authState → Paired after DataStore + crypto init. That transition fires
|
||||
// this LaunchedEffect, which kicks the WSS handshake regardless of which
|
||||
// tab the user is looking at. reconnectIfStale() is cheap and
|
||||
// self-guarding (paired && disconnected && hasUrl) so the second firing
|
||||
// on any later Paired-refresh is a no-op.
|
||||
val coldStartAuthState by connectionViewModel.authState.collectAsState()
|
||||
LaunchedEffect(coldStartAuthState) {
|
||||
if (coldStartAuthState is AuthState.Paired) {
|
||||
connectionViewModel.reconnectIfStale()
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle-aware revalidation. ON_RESUME (every time the app comes
|
||||
// to the foreground) flips both health badges to Probing and fires a
|
||||
// fresh API + relay /health probe. Without this hook, badges showed
|
||||
@@ -206,7 +368,27 @@ fun RelayApp() {
|
||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
relayUrlProvider = { connectionViewModel.relayUrl.value },
|
||||
relayUrlProvider = { connectionViewModel.effectiveRelayUrl.value },
|
||||
sessionTokenProvider = {
|
||||
(connectionViewModel.authState.value as? AuthState.Paired)?.token
|
||||
},
|
||||
apiBearerTokenProvider = {
|
||||
connectionViewModel.getApiKey()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Profile Inspector client. Shares the same lazy relay URL + bearer
|
||||
// token providers as the voice client so any rotation/re-pair is
|
||||
// automatically picked up on the next fetch. Process-stable via
|
||||
// remember {} so the OkHttpClient isn't rebuilt on recomposition.
|
||||
val profileInspectorClient = remember {
|
||||
RelayProfileInspectorClient(
|
||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
relayUrlProvider = { connectionViewModel.effectiveRelayUrl.value },
|
||||
sessionTokenProvider = {
|
||||
(connectionViewModel.authState.value as? AuthState.Paired)?.token
|
||||
},
|
||||
@@ -218,16 +400,69 @@ fun RelayApp() {
|
||||
val voiceSfxPlayer = remember { VoiceSfxPlayer(mediaContext) }
|
||||
LaunchedEffect(Unit) {
|
||||
val recorder = VoiceRecorder(mediaContext, voiceViewModel.viewModelScope)
|
||||
val player = VoicePlayer()
|
||||
val player = VoicePlayer(mediaContext)
|
||||
voiceViewModel.initialize(
|
||||
voiceClient = voiceClient,
|
||||
chatViewModel = chatViewModel,
|
||||
recorder = recorder,
|
||||
player = player,
|
||||
sfxPlayer = voiceSfxPlayer,
|
||||
// === PHASE3-voice-intents-localdispatch ===
|
||||
// Wire the local in-process dispatcher so voice intents go
|
||||
// through `BridgeCommandHandler.handleLocalCommand` (same
|
||||
// dispatch + Tier 5 safety pipeline as WSS-incoming commands)
|
||||
// instead of round-tripping through the relay. The relay
|
||||
// correctly rejects phone-originated bridge.command envelopes
|
||||
// as "unexpected from phone" — the wire protocol is server→
|
||||
// phone for commands, and voice intents are phone-local.
|
||||
//
|
||||
// The multiplexer is still passed for non-bridge envelope use
|
||||
// cases and as a debug fallback (with a WARN log) if the
|
||||
// local dispatcher is somehow null at runtime.
|
||||
//
|
||||
// Discovered + fixed 2026-04-14 — see ROADMAP.md v0.4.1
|
||||
// "voice intent local dispatch loop" entry and the multiplexer
|
||||
// wiring fix in commit a568366 that unblocked the dispatch
|
||||
// path enough to surface this protocol mismatch.
|
||||
bridgeMultiplexer = connectionViewModel.multiplexer,
|
||||
localBridgeDispatcher = connectionViewModel.bridgeCommandHandler::handleLocalCommand,
|
||||
// === END PHASE3-voice-intents-localdispatch ===
|
||||
// 2026-04-17: persist the interaction-mode preference across
|
||||
// app restarts. VoicePreferencesRepository is the same repo
|
||||
// VoiceSettingsScreen reads/writes.
|
||||
voicePreferences = com.hermesandroid.relay.data.VoicePreferencesRepository(mediaContext),
|
||||
)
|
||||
}
|
||||
|
||||
// Multi-connection: wire ChatViewModel / VoiceViewModel into the
|
||||
// connection switch coordinator. Registered once per composition — the
|
||||
// coordinator stores these as callbacks and fires them in order when
|
||||
// switchConnection() is invoked so in-flight streams don't keep
|
||||
// scribbling into the outgoing connection's state after the swap.
|
||||
//
|
||||
// Voice callback is gated on sideload: googlePlay still builds the
|
||||
// VoiceViewModel (it's wired unconditionally above) but voice mode is a
|
||||
// sideload-only feature, so there's no live turn to stop on stock
|
||||
// googlePlay builds. The stop-callback is harmless either way; the gate
|
||||
// mirrors the flavor check on the global unattended banner below.
|
||||
LaunchedEffect(Unit) {
|
||||
connectionViewModel.registerStreamCancelCallback {
|
||||
chatViewModel.cancelStream()
|
||||
}
|
||||
if (BuildFlavor.isSideload) {
|
||||
// VoiceViewModel.stop() isn't defined — use exitVoiceMode() which
|
||||
// is the closest semantic match (tears down the active turn and
|
||||
// returns the UI to text mode). Worker B2 confirmed no stop()
|
||||
// method exists as of the connection-switch pass, so this rename
|
||||
// is intentional and kept as-is. If a proper stop() is added
|
||||
// later, swap this for vvm.stop().
|
||||
connectionViewModel.registerVoiceStopCallback {
|
||||
voiceViewModel.exitVoiceMode()
|
||||
}
|
||||
}
|
||||
chatViewModel.observeConnectionSwitches(connectionViewModel.connectionSwitchEvents)
|
||||
}
|
||||
|
||||
LaunchedEffect(apiClient) {
|
||||
apiClient?.let { client ->
|
||||
chatViewModel.initialize(client, connectionViewModel.chatHandler)
|
||||
@@ -242,6 +477,15 @@ fun RelayApp() {
|
||||
mediaCacheWriter = connectionViewModel.mediaCacheWriter
|
||||
)
|
||||
|
||||
// Agent-profile pick provider (Pass 2). Lambda reads the latest
|
||||
// StateFlow value on every send, so ChatViewModel never needs a
|
||||
// direct reference to ConnectionViewModel. Safe to rewire on
|
||||
// every API-client swap — the lambda captures the long-lived
|
||||
// VM, not the (per-connection) apiClient.
|
||||
chatViewModel.setSelectedProfileProvider {
|
||||
connectionViewModel.selectedProfile.value
|
||||
}
|
||||
|
||||
// Wire session persistence callback
|
||||
chatViewModel.onSessionChanged = { sessionId ->
|
||||
connectionViewModel.saveLastSessionId(sessionId)
|
||||
@@ -339,6 +583,8 @@ fun RelayApp() {
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
|
||||
// startDestination uses the route TEMPLATE so it matches the
|
||||
// composable registered below; optional args default to null/false.
|
||||
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
@@ -359,9 +605,152 @@ fun RelayApp() {
|
||||
// error-collector LaunchedEffects without threading state downwards.
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Relay-pushed `profiles.updated` announcements. AuthManager
|
||||
// filters out idempotent pushes (same names + same count), so
|
||||
// this only fires when the profile list actually changed.
|
||||
LaunchedEffect(connectionViewModel) {
|
||||
connectionViewModel.profilesUpdatedEvents.collect {
|
||||
snackbarHostState.showSnackbar("Profiles updated")
|
||||
}
|
||||
}
|
||||
|
||||
// === v0.4.1 polish: global unattended-access banner ===
|
||||
// Rendered at the top of the scaffold on every tab when BOTH the
|
||||
// master toggle is ON and unattended access is ON. The per-screen
|
||||
// UnattendedAccessRow inside Bridge already surfaces this, but the
|
||||
// user's typical workflow after enabling it is to leave the Bridge
|
||||
// tab — we don't want them to forget about a screen-waking opt-in
|
||||
// just because they're reading Chat history. Kept as a thin 28dp
|
||||
// amber strip so its footprint doesn't eat scroll space.
|
||||
//
|
||||
// State is read directly from the two DataStore repos instead of
|
||||
// going through BridgeViewModel. The VM is Bridge-tab-scoped; the
|
||||
// banner lives at app-root scope. Reading the repos here avoids
|
||||
// instantiating the entire BridgeViewModel (with its many side-
|
||||
// effectful init blocks) just to peek at two StateFlows.
|
||||
// Key the remembers off applicationContext (process-stable) instead
|
||||
// of LocalContext.current (changes on rotation, dark-mode swap,
|
||||
// locale change). Keying off a transient context would re-construct
|
||||
// the repos — and their DataStore handles — on every config change.
|
||||
val bridgeAppCtx = androidx.compose.ui.platform.LocalContext.current.applicationContext
|
||||
val bridgePrefsRepo = remember(bridgeAppCtx) { BridgePreferencesRepository(bridgeAppCtx) }
|
||||
val safetyPrefsRepo = remember(bridgeAppCtx) { BridgeSafetyPreferencesRepository(bridgeAppCtx) }
|
||||
// Stabilize the mapped flows in `remember` — invoking `.map` directly
|
||||
// inside composition trips the `FlowOperatorInvokedInComposition` lint
|
||||
// rule because a fresh Flow instance would be created on every
|
||||
// recomposition, defeating collectAsState's state-preservation.
|
||||
val masterEnabledFlow = remember(bridgePrefsRepo) {
|
||||
bridgePrefsRepo.settings.map { it.masterEnabled }
|
||||
}
|
||||
val unattendedEnabledFlow = remember(safetyPrefsRepo) {
|
||||
safetyPrefsRepo.settings.map { it.unattendedAccessEnabled }
|
||||
}
|
||||
val masterEnabled by masterEnabledFlow.collectAsState(initial = false)
|
||||
val unattendedEnabled by unattendedEnabledFlow.collectAsState(initial = false)
|
||||
// Sideload-only: googlePlay has no wake lock and the unattended
|
||||
// flag never gets written there — gating here is defence in depth
|
||||
// and makes the check cheap via R8 in release builds.
|
||||
val showUnattendedBanner = BuildFlavor.isSideload &&
|
||||
masterEnabled &&
|
||||
unattendedEnabled &&
|
||||
!isOnboarding &&
|
||||
!voiceUiState.voiceMode
|
||||
// === END v0.4.1 polish ===
|
||||
|
||||
// Multi-connection switcher has moved into the AgentInfoSheet's
|
||||
// Connection section (see ConnectionInfoSheet.kt) — the top-bar
|
||||
// ConnectionChip row that used to live here was duplicating that
|
||||
// surface and eating vertical space. The `connectionSheetVisible`
|
||||
// state and the ConnectionSwitcherSheet declaration further down
|
||||
// are kept for any callers that still need the modal switcher
|
||||
// (e.g. programmatic routes), but nothing in the default UI opens
|
||||
// them anymore.
|
||||
//
|
||||
// Kept as a named const so the `if (showUnattendedBanner ||
|
||||
// connectionChipVisible)` window-inset conditional below still
|
||||
// compiles without a deeper rewrite. Collapses to false now that
|
||||
// the chip is gone; when the unattended banner is absent too, the
|
||||
// Scaffold goes back to default TopAppBar status-bar padding.
|
||||
val connectionChipVisible = false
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// The banner takes its own vertical space above the Scaffold so
|
||||
// no screen's content is covered by it (unlike floating overlays
|
||||
// that would need per-screen padding compensation). Use
|
||||
// AnimatedVisibility to fade in/out on state transitions.
|
||||
AnimatedVisibility(
|
||||
visible = showUnattendedBanner,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
) {
|
||||
UnattendedGlobalBanner(
|
||||
onTap = {
|
||||
navController.navigate(Screen.Bridge.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Sideload-only update banner. UpdateViewModel short-circuits on
|
||||
// googlePlay so this block is effectively dead on that flavor.
|
||||
// bannerState hides the banner for versions the user has
|
||||
// dismissed, re-appearing automatically on a newer release.
|
||||
val updateBannerState by updateViewModel.bannerState.collectAsState()
|
||||
val availableUpdate = (updateBannerState as? UpdateCheckResult.Available)?.update
|
||||
AnimatedVisibility(
|
||||
visible = availableUpdate != null && !isOnboarding,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
) {
|
||||
availableUpdate?.let { upd ->
|
||||
UpdateBanner(
|
||||
update = upd,
|
||||
onDismiss = { updateViewModel.dismiss(upd.latestVersion) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// (The app-wide ConnectionChip row that used to live here has been
|
||||
// removed. Multi-connection switching is now reachable from the
|
||||
// AgentInfoSheet's Connection section — see ConnectionInfoSheet.kt's
|
||||
// "Multi-connection switcher" block. Keeps the top chrome tidy and
|
||||
// puts the control next to the related Profile + Personality
|
||||
// radios.)
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// weight(1f) instead of fillMaxSize(): Column arranges children
|
||||
// top-down, so a fillMaxSize child would try to claim the full
|
||||
// parent height and overflow past the banner. weight(1f) takes
|
||||
// exactly the remaining main-axis space after the banner (which
|
||||
// is 0 when the banner is hidden).
|
||||
//
|
||||
// consumeWindowInsets is conditional on banner visibility: the
|
||||
// banner pads itself for WindowInsets.statusBars, and without
|
||||
// this consume, child TopAppBars (e.g. ChatScreen's) also pad
|
||||
// for status bars — yielding ~24dp of double-padding between
|
||||
// the banner and the first row of screen content. When the
|
||||
// banner is hidden we want the default behavior (TopAppBar
|
||||
// self-pads below the status bar).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.then(
|
||||
// Any banner / chip sitting above the Scaffold that
|
||||
// pads itself for the status bar means child TopAppBars
|
||||
// would otherwise double-pad and render too far down.
|
||||
// Consume the inset here so the Scaffold tree treats
|
||||
// the top edge as already handled.
|
||||
if (showUnattendedBanner || connectionChipVisible) {
|
||||
Modifier.consumeWindowInsets(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
@@ -411,7 +800,18 @@ fun RelayApp() {
|
||||
NavigationBarItemDefaults.colors()
|
||||
},
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
// Chat's route is a template with an
|
||||
// optional `?openAgentSheet` arg — always
|
||||
// navigate to the concrete bare-"chat"
|
||||
// URI from bottom nav so we don't leak
|
||||
// the `{openAgentSheet}` placeholder into
|
||||
// the destination and so tab-switching
|
||||
// never re-opens the AgentInfoSheet.
|
||||
val target = when (screen) {
|
||||
is Screen.Chat -> Screen.Chat.route(openAgentSheet = false)
|
||||
else -> screen.route
|
||||
}
|
||||
navController.navigate(target) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
@@ -437,16 +837,39 @@ fun RelayApp() {
|
||||
// so the callback collapses to "mark complete + navigate
|
||||
// to chat". The legacy 4-arg signature was discarding the
|
||||
// relay block entirely.
|
||||
//
|
||||
// CRITICAL: pass the Activity-scoped connectionViewModel
|
||||
// explicitly instead of letting OnboardingScreen fetch
|
||||
// its own via `viewModel()`. A bare `viewModel()` call
|
||||
// inside a `composable(...)` block binds to the
|
||||
// NavBackStackEntry's store, so the onboarding VM gets
|
||||
// destroyed by `popUpTo(Onboarding) { inclusive = true }`
|
||||
// on navigation to Chat — taking the freshly-minted
|
||||
// session token with it. See the full writeup on the
|
||||
// OnboardingScreen function definition.
|
||||
OnboardingScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(Screen.Chat.route) {
|
||||
// Concrete bare-"chat" URI — the Screen.Chat.route
|
||||
// field is the route TEMPLATE (contains
|
||||
// `{openAgentSheet}`) and must not be navigated
|
||||
// to directly; build the URI via Screen.Chat.route(...).
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.Chat.route) {
|
||||
composable(
|
||||
route = Screen.Chat.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Chat.ARG_OPEN_AGENT_SHEET) {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
// Responsive bubble width based on screen width
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthDp = configuration.screenWidthDp.dp
|
||||
@@ -456,11 +879,33 @@ fun RelayApp() {
|
||||
else -> 300.dp // Compact (phone portrait)
|
||||
}
|
||||
|
||||
// Consume-once semantics: ChatScreen only treats the
|
||||
// flag as "open the sheet on entry" while it's still
|
||||
// `true` in the back-stack entry's arguments. After
|
||||
// firing, ChatScreen writes `false` back into the same
|
||||
// arguments bundle so recompositions (tab switches,
|
||||
// config changes, process resume) don't re-open the
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
|
||||
ChatScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
voiceViewModel = voiceViewModel,
|
||||
maxBubbleWidth = maxBubbleWidth
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
openAgentSheetOnEntry = openAgentSheetArg,
|
||||
onAgentSheetArgConsumed = {
|
||||
backStackEntry.arguments?.putBoolean(
|
||||
Screen.Chat.ARG_OPEN_AGENT_SHEET, false,
|
||||
)
|
||||
},
|
||||
// AgentInfoSheet footer jumps straight into the full
|
||||
// Connections CRUD screen — saves a detour through
|
||||
// Settings → Connections.
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Terminal.route) {
|
||||
@@ -479,6 +924,13 @@ fun RelayApp() {
|
||||
// scope, a shared holder or explicit VM param gets added
|
||||
// here.
|
||||
BridgeScreen(
|
||||
// Pass the connection VM so the screen can render
|
||||
// the "Relay not connected" banner when the WSS
|
||||
// isn't in a Paired+Connected state. Without this
|
||||
// the user can flip master toggle on, pass every
|
||||
// permission check, and still receive zero
|
||||
// commands — silently useless.
|
||||
connectionViewModel = connectionViewModel,
|
||||
// === PHASE3-safety-rails: bridge safety route ===
|
||||
onNavigateToBridgeSafety = {
|
||||
navController.navigate(Screen.BridgeSafetySettings.route)
|
||||
@@ -490,8 +942,15 @@ fun RelayApp() {
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToConnectionSettings = {
|
||||
navController.navigate(Screen.ConnectionSettings.route)
|
||||
chatViewModel = chatViewModel,
|
||||
// (The `onNavigateToChatWithAgentSheet` callback that
|
||||
// used to live here was removed 2026-04-21. Tapping
|
||||
// the Active Agent card on Settings now opens the
|
||||
// AgentInfoSheet inline on THAT screen — closing the
|
||||
// sheet returns the user to Settings instead of
|
||||
// leaving them on Chat after a confusing redirect.)
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToChatSettings = {
|
||||
navController.navigate(Screen.ChatSettings.route)
|
||||
@@ -524,7 +983,12 @@ fun RelayApp() {
|
||||
},
|
||||
onNavigateToAbout = {
|
||||
navController.navigate(Screen.About.route)
|
||||
}
|
||||
},
|
||||
onNavigateToProfileInspector = { profileName ->
|
||||
navController.navigate(
|
||||
Screen.ProfileInspector.route(profileName),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.VoiceSettings.route) {
|
||||
@@ -560,13 +1024,150 @@ fun RelayApp() {
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.ConnectionSettings.route) {
|
||||
ConnectionSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
// (The `composable(Screen.ConnectionSettings.route)` block
|
||||
// that used to live here — hosting the singular, legacy
|
||||
// 1400-line `ConnectionSettingsScreen` — was removed on
|
||||
// 2026-04-21 as part of the connection-settings
|
||||
// unification. Everything that screen did (pair, manual
|
||||
// URL config, TLS/insecure toggle, manual pairing code
|
||||
// fallback) now lives inline on the active card of the
|
||||
// plural `ConnectionsSettings` screen below, via the
|
||||
// expandable sections in `ActiveConnectionSections.kt`.
|
||||
// The corresponding `data object ConnectionSettings` was
|
||||
// also removed from the `Screen` sealed class, and the
|
||||
// `onNavigateToConnectionSettings` param on
|
||||
// `SettingsScreen` was dropped.)
|
||||
composable(Screen.ConnectionsSettings.route) {
|
||||
val connectionsList by connectionViewModel.connections.collectAsState()
|
||||
val activeId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val activeRelayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
ConnectionsSettingsScreen(
|
||||
connections = connectionsList,
|
||||
activeConnectionId = activeId,
|
||||
activeRelayUiState = activeRelayUiState,
|
||||
onReconnectActive = {
|
||||
connectionViewModel.connectRelay()
|
||||
connectionSwitchScope.launch {
|
||||
snackbarHostState.showSnackbar("Reconnecting to relay…")
|
||||
}
|
||||
},
|
||||
// Multi-connection: typed VM helpers (Worker B2)
|
||||
// handle the full mutations — rename persists via
|
||||
// ConnectionStore.updateConnection; revoke issues
|
||||
// the server-side /sessions/{prefix} DELETE and
|
||||
// clears local auth; remove deletes the backing
|
||||
// EncryptedSharedPreferences via ConnectionStore.
|
||||
onRenameConnection = { id, newLabel ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.renameConnection(id, newLabel)
|
||||
.onFailure { err ->
|
||||
snackbarHostState.showSnackbar(
|
||||
err.message ?: "Rename failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRepairConnection = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
// Wait for the AuthManager swap before the
|
||||
// scanner can apply a QR payload.
|
||||
connectionViewModel.switchConnection(id).join()
|
||||
navController.navigate(Screen.Pair.route(id))
|
||||
}
|
||||
},
|
||||
onRevokeConnection = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
val result = connectionViewModel.revokeConnection(id)
|
||||
if (result.isFailure) {
|
||||
// v1 constraint: revokeConnection only
|
||||
// works on the active connection.
|
||||
// Surface a snackbar so the user
|
||||
// understands why nothing happened.
|
||||
snackbarHostState.showSnackbar(
|
||||
"Only the active connection can be revoked right now",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemoveConnection = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.removeConnection(id)
|
||||
}
|
||||
},
|
||||
onAddConnection = {
|
||||
connectionSwitchScope.launch {
|
||||
// Create and switch to the placeholder before
|
||||
// opening the camera. Otherwise a fast scan can
|
||||
// save the session token into the outgoing
|
||||
// connection's auth store.
|
||||
val id = connectionViewModel.beginAddConnection(
|
||||
preAllocatedId = java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
navController.navigate(
|
||||
Screen.Pair.route(connectionId = id, autoStart = "scan")
|
||||
)
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToPairedDevices = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
}
|
||||
},
|
||||
// Pass the VM so the active card can render the
|
||||
// shared EndpointsCard inline AND the unified
|
||||
// Advanced section (manual URL / insecure toggle /
|
||||
// manual pairing code). Null-safe — if the VM
|
||||
// isn't wired (tests, previews), the active card
|
||||
// degrades to the flat layout.
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.Pair.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Pair.ARG_CONNECTION_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument(Screen.Pair.ARG_AUTO_START) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val connectionIdArg = backStackEntry.arguments
|
||||
?.getString(Screen.Pair.ARG_CONNECTION_ID)
|
||||
val autoStartArg = backStackEntry.arguments
|
||||
?.getString(Screen.Pair.ARG_AUTO_START)
|
||||
com.hermesandroid.relay.ui.screens.PairScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
autoStart = autoStartArg,
|
||||
onComplete = {
|
||||
// Both "add new" and "re-pair in place" now
|
||||
// route to this screen with connectionIdArg
|
||||
// set — add-new goes through
|
||||
// ConnectionViewModel.beginAddConnection()
|
||||
// which pre-creates the placeholder + switches
|
||||
// to it before navigating here, so
|
||||
// applyPairingPayload lands on the correct
|
||||
// auth store. Nothing extra to do on success
|
||||
// beyond popping the backstack.
|
||||
navController.popBackStack()
|
||||
},
|
||||
onCancel = {
|
||||
// If the user bailed out before completing a
|
||||
// pair, discard the placeholder we pre-created
|
||||
// on entry. Safe no-op for real (paired)
|
||||
// connections; only removes placeholders that
|
||||
// never got a pairedAt stamp.
|
||||
if (connectionIdArg != null) {
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.discardPlaceholderConnection(connectionIdArg)
|
||||
}
|
||||
}
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.ChatSettings.route) {
|
||||
@@ -590,7 +1191,9 @@ fun RelayApp() {
|
||||
composable(Screen.Analytics.route) {
|
||||
AnalyticsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
voiceViewModel = voiceViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
)
|
||||
}
|
||||
composable(Screen.DeveloperSettings.route) {
|
||||
@@ -605,9 +1208,93 @@ fun RelayApp() {
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.ProfileInspector.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.ProfileInspector.ARG_PROFILE_NAME) {
|
||||
type = NavType.StringType
|
||||
},
|
||||
// Optional `section` query arg — which tab to
|
||||
// land on. Defaults to "config" so existing
|
||||
// deep-links without the arg keep their
|
||||
// pre-deep-link behaviour.
|
||||
navArgument(Screen.ProfileInspector.ARG_SECTION) {
|
||||
type = NavType.StringType
|
||||
defaultValue = Screen.ProfileInspector.SECTION_CONFIG
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
// Build the VM with the shared inspector client and
|
||||
// the nav-back-stack's SavedStateHandle. A small
|
||||
// factory keeps the VM scoped to this destination —
|
||||
// leaving the screen (popBackStack) destroys it, so
|
||||
// entering a different profile later gets a fresh
|
||||
// VM rather than reusing stale state.
|
||||
//
|
||||
// Keyed on the profile-name arg so navigating from
|
||||
// profile A → profile B (unlikely in v1 but possible
|
||||
// via deep link) yields a fresh VM rather than
|
||||
// reusing the A VM with A's loaded state.
|
||||
val profileNameArg = backStackEntry.arguments
|
||||
?.getString(Screen.ProfileInspector.ARG_PROFILE_NAME)
|
||||
.orEmpty()
|
||||
val sectionArg = backStackEntry.arguments
|
||||
?.getString(Screen.ProfileInspector.ARG_SECTION)
|
||||
?: Screen.ProfileInspector.SECTION_CONFIG
|
||||
val inspectorViewModel: ProfileInspectorViewModel = viewModel(
|
||||
viewModelStoreOwner = backStackEntry,
|
||||
key = "profile-inspector-$profileNameArg",
|
||||
factory = object : androidx.lifecycle.ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : androidx.lifecycle.ViewModel> create(
|
||||
modelClass: Class<T>,
|
||||
extras: androidx.lifecycle.viewmodel.CreationExtras,
|
||||
): T {
|
||||
// createSavedStateHandle() pulls the
|
||||
// typed nav args out of extras — the
|
||||
// backStackEntry is the SavedStateRegistry
|
||||
// owner here, so the resulting
|
||||
// SavedStateHandle contains our
|
||||
// `profileName` arg automatically.
|
||||
val ssh = extras.createSavedStateHandle()
|
||||
return ProfileInspectorViewModel(
|
||||
client = profileInspectorClient,
|
||||
savedStateHandle = ssh,
|
||||
) as T
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Pull the model label off the current activeProfile
|
||||
// for the top-bar subtitle — read-only snapshot,
|
||||
// falls back to null when the selected profile
|
||||
// doesn't happen to match the one we're inspecting
|
||||
// (shouldn't normally happen since the entry is
|
||||
// keyed off the same Profile).
|
||||
val selectedProfile by connectionViewModel
|
||||
.selectedProfile.collectAsState()
|
||||
val modelLabel = selectedProfile
|
||||
?.takeIf { it.name == profileNameArg }
|
||||
?.model
|
||||
|
||||
ProfileInspectorScreen(
|
||||
viewModel = inspectorViewModel,
|
||||
profileModel = modelLabel,
|
||||
initialSection = sectionArg,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
} // end Column (wraps banner + Scaffold)
|
||||
|
||||
// (The ConnectionSwitcherSheet modal that used to live here was
|
||||
// driven by the removed top-bar ConnectionChip. Switching is now
|
||||
// inline in AgentInfoSheet's Connection section — see
|
||||
// ConnectionInfoSheet.kt's "Multi-connection switcher" block.
|
||||
// ConnectionSwitcherSheet.kt itself is kept so any future programmatic
|
||||
// callers (deep links, automation) can still invoke it if needed.)
|
||||
|
||||
// Sphere intro overlay — fades out after 1.5s to reveal main UI
|
||||
AnimatedVisibility(
|
||||
|
||||
+950
@@ -0,0 +1,950 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.network.ConnectionState
|
||||
import com.hermesandroid.relay.network.RelayUrlDeriver
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.asBadgeState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ──────────────────────────────────────────────────────────────────────
|
||||
* Active-connection card body sections — the "what was Settings →
|
||||
* Connection" feature set, now living inline on the active `ConnectionCard`
|
||||
* inside [com.hermesandroid.relay.ui.screens.ConnectionsSettingsScreen].
|
||||
*
|
||||
* The per-card action row (Reconnect/Rename/Re-pair/Revoke/Remove) stays
|
||||
* on `ConnectionCard` itself. Everything below the first divider — status
|
||||
* rows, endpoints expander, advanced expander (manual URL, insecure
|
||||
* toggle, manual pairing code), and the security-posture strip — is
|
||||
* extracted here so `ConnectionsSettingsScreen` stays focused on the list
|
||||
* layout and this file owns the active-card deep content.
|
||||
*
|
||||
* All composables in this file assume they render INSIDE an active
|
||||
* `ConnectionCard`'s Column (16dp padding, 8dp vertical spacing). None of
|
||||
* them introduce a new Card wrapper or scroll container.
|
||||
*
|
||||
* Call sites pass a non-null `connectionViewModel` — these sections are
|
||||
* never rendered for non-active cards, so the VM guard happens at the
|
||||
* call site.
|
||||
* ──────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
/**
|
||||
* Three tappable status rows (API / Relay / Session), always visible on
|
||||
* the active card. Replaces the old "Active Connection" quick-look card
|
||||
* that used to live at the top of `SettingsScreen` — same information
|
||||
* density, same tap-for-info-sheet behavior.
|
||||
*
|
||||
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
|
||||
* reconnect + toast; every other row falls through to the info sheet
|
||||
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
isConnected = apiReachable,
|
||||
isProbing = apiHealth == ConnectionViewModel.HealthStatus.Probing,
|
||||
statusText = when {
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking…"
|
||||
apiReachable -> "Reachable"
|
||||
else -> "Unreachable"
|
||||
},
|
||||
onClick = onOpenApiInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
// ADR 24: relayRowState carries both the phase and the active
|
||||
// endpoint role. statusText appends " · <Role>" when the
|
||||
// resolver has picked one, so the chip reads "Connected · LAN"
|
||||
// etc. without any extra wiring here.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Session",
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced expandable section — three subsections:
|
||||
* - Manual URL configuration (API URL + key + Save & Test,
|
||||
* Relay URL + Save & Test + Disconnect)
|
||||
* - Allow-insecure-connections toggle (with first-enable Ack dialog)
|
||||
* - Manual pairing code fallback (3-step flow with in-flight Connect
|
||||
* watcher + snackbar feedback)
|
||||
*
|
||||
* Wrapped in a single `SettingsExpandableCard` so the user can collapse
|
||||
* the entire block — none of it is needed for the common paired-via-QR
|
||||
* flow. Expanded-state is `rememberSaveable` so rotation / process death
|
||||
* preserves user intent.
|
||||
*
|
||||
* [onInsecureAckRequested] opens the `InsecureConnectionAckDialog` at
|
||||
* screen scope; this composable never owns it directly so the dialog
|
||||
* can persist through card recomposition in a LazyColumn.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardAdvancedSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
SettingsExpandableCard(
|
||||
title = "Advanced",
|
||||
expanded = expanded,
|
||||
onToggle = { expanded = !expanded },
|
||||
isDarkTheme = isDarkTheme,
|
||||
) {
|
||||
ManualUrlSubsection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
HorizontalDivider()
|
||||
InsecureToggleSubsection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onInsecureAckRequested = onInsecureAckRequested,
|
||||
)
|
||||
HorizontalDivider()
|
||||
ManualPairingCodeSubsection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual URL configuration subsection. Power-user only — the canonical
|
||||
* path is QR pair via the Re-pair button on the card row above.
|
||||
*
|
||||
* Kept internal rather than split further because the API + relay
|
||||
* fields share the `isTesting` + `Save & Test` idiom and the two test
|
||||
* paths talk to the same VM.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManualUrlSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val apiKeyPresent by connectionViewModel.authManager.apiKeyPresent.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
|
||||
// Keyed on the backing URL so a connection switch refreshes the input.
|
||||
var apiUrlInput by remember(apiServerUrl) { mutableStateOf(apiServerUrl) }
|
||||
var apiKeyInput by remember { mutableStateOf("") }
|
||||
var apiKeyVisible by remember { mutableStateOf(false) }
|
||||
var relayUrlInput by remember(relayUrl) { mutableStateOf(relayUrl) }
|
||||
var isTestingApi by remember { mutableStateOf(false) }
|
||||
var apiVoiceSetupResult by remember {
|
||||
mutableStateOf<ConnectionViewModel.ApiVoiceSetupResult?>(null)
|
||||
}
|
||||
var relayOverrideVisible by rememberSaveable(apiServerUrl) {
|
||||
mutableStateOf(!RelayUrlDeriver.isAutoManagedRelayUrl(relayUrl, apiServerUrl))
|
||||
}
|
||||
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
|
||||
|
||||
LaunchedEffect(apiUrlInput, relayOverrideVisible, autoRelayUrl) {
|
||||
if (!relayOverrideVisible && autoRelayUrl != null && relayUrlInput != autoRelayUrl) {
|
||||
relayUrlInput = autoRelayUrl
|
||||
connectionViewModel.clearRelayReachableResult()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiUrlInput,
|
||||
onValueChange = { apiUrlInput = it },
|
||||
label = { Text("API Server URL") },
|
||||
placeholder = { Text("http://your-server:8642") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiKeyInput,
|
||||
onValueChange = { apiKeyInput = it },
|
||||
label = { Text("API Key (optional)") },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (apiKeyPresent) "•••• already set (leave blank to keep)"
|
||||
else "Leave empty if not configured",
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
if (apiKeyPresent && apiKeyInput.isBlank()) {
|
||||
"A key is already stored — leave blank to keep it, or type to replace"
|
||||
} else {
|
||||
"Only needed if Hermes is configured with API_SERVER_KEY"
|
||||
},
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = if (apiKeyVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { apiKeyVisible = !apiKeyVisible }) {
|
||||
Icon(
|
||||
imageVector = if (apiKeyVisible) Icons.Filled.VisibilityOff
|
||||
else Icons.Filled.Visibility,
|
||||
contentDescription = if (apiKeyVisible) "Hide" else "Show",
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
isTestingApi = true
|
||||
apiVoiceSetupResult = null
|
||||
val relayOverride = if (relayOverrideVisible) relayUrlInput else null
|
||||
connectionViewModel.saveApiAndProbeVoice(
|
||||
apiUrl = apiUrlInput,
|
||||
apiKey = apiKeyInput,
|
||||
manualRelayUrlOverride = relayOverride,
|
||||
) { result ->
|
||||
isTestingApi = false
|
||||
apiVoiceSetupResult = result
|
||||
if (!result.voiceConfigReachable && result.relayAutoDerived) {
|
||||
relayOverrideVisible = true
|
||||
result.relayUrl?.let { relayUrlInput = it }
|
||||
}
|
||||
Toast.makeText(
|
||||
context,
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
"API and voice relay reachable"
|
||||
result.apiReachable ->
|
||||
"API reachable; relay URL needs review"
|
||||
else -> "Cannot reach API server"
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
|
||||
) {
|
||||
Text("Save & Test")
|
||||
}
|
||||
if (isTestingApi) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
HorizontalDivider()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Relay URL",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = if (autoRelayUrl != null && !relayOverrideVisible) {
|
||||
"Auto: $autoRelayUrl"
|
||||
} else {
|
||||
"Manual override"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Voice uses the relay's /voice routes. The app derives this from the API host unless a custom route is needed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
relayOverrideVisible = !relayOverrideVisible
|
||||
if (!relayOverrideVisible) {
|
||||
relayUrlInput = autoRelayUrl.orEmpty()
|
||||
}
|
||||
connectionViewModel.clearRelayReachableResult()
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(if (relayOverrideVisible) "Use auto relay URL" else "Use custom relay URL")
|
||||
}
|
||||
}
|
||||
|
||||
if (relayOverrideVisible) {
|
||||
OutlinedTextField(
|
||||
value = relayUrlInput,
|
||||
onValueChange = {
|
||||
relayUrlInput = it
|
||||
// Stale reachability results belong to the prior URL.
|
||||
connectionViewModel.clearRelayReachableResult()
|
||||
},
|
||||
label = { Text("Relay URL override") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text("Only needed when Auto cannot reach /voice/config")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
apiVoiceSetupResult?.let { result ->
|
||||
val color = if (result.voiceConfigReachable) {
|
||||
Color(0xFF4CAF50)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
Text(
|
||||
text = if (result.voiceConfigReachable) {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
} else {
|
||||
"Relay URL required: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
|
||||
// Save & Test + Disconnect. Connect button intentionally absent
|
||||
// — it used to cause an unpaired-auth-then-rate-limited trap.
|
||||
// /health probe with no WSS handshake is the safe surface.
|
||||
val relayReachable by connectionViewModel.relayReachableResult.collectAsState()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
connectionViewModel.testRelayReachable(
|
||||
if (relayOverrideVisible) relayUrlInput else autoRelayUrl.orEmpty(),
|
||||
)
|
||||
},
|
||||
enabled = (if (relayOverrideVisible) relayUrlInput else autoRelayUrl.orEmpty()).isNotBlank() &&
|
||||
relayReachable !is ConnectionViewModel.RelayReachable.Probing,
|
||||
) {
|
||||
Text("Test Relay")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { connectionViewModel.disconnectRelay() },
|
||||
enabled = relayConnectionState != ConnectionState.Disconnected,
|
||||
) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
when (val r = relayReachable) {
|
||||
is ConnectionViewModel.RelayReachable.Probing -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
text = "Probing /health…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
is ConnectionViewModel.RelayReachable.Ok -> {
|
||||
Text(
|
||||
text = "✓ Reachable — hermes-relay v${r.version} (${r.clients} client, ${r.sessions} session${if (r.sessions == 1) "" else "s"})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF4CAF50),
|
||||
)
|
||||
}
|
||||
is ConnectionViewModel.RelayReachable.Fail -> {
|
||||
val humanErr = classifyError(
|
||||
Exception(r.message),
|
||||
context = "save_and_test",
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "✗ ${humanErr.title}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = humanErr.body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> { /* idle */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insecure-mode toggle subsection. First enable triggers the
|
||||
* [InsecureConnectionAckDialog] at screen scope via
|
||||
* [onInsecureAckRequested]; subsequent toggles fire through the VM
|
||||
* directly.
|
||||
*/
|
||||
@Composable
|
||||
private fun InsecureToggleSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
) {
|
||||
val insecureMode by connectionViewModel.insecureMode.collectAsState()
|
||||
val insecureAckSeen by connectionViewModel.insecureAckSeen.collectAsState()
|
||||
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
|
||||
if (isInsecureConnection && relayConnectionState == ConnectionState.Connected) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Plain connection — traffic is not encrypted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Allow plain (unencrypted) connections",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Enable ws:// and http:// for local dev/testing only",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = insecureMode,
|
||||
onCheckedChange = { enabled ->
|
||||
if (enabled && !insecureAckSeen) {
|
||||
// First enable → open threat-model Ack dialog at
|
||||
// screen scope. VM is written only on confirm.
|
||||
onInsecureAckRequested()
|
||||
} else {
|
||||
connectionViewModel.setInsecureMode(enabled)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual pairing code subsection — the 3-step fallback flow for when
|
||||
* the QR scanner isn't usable (no camera, headless host, bad lighting).
|
||||
*
|
||||
* 1. Copy the phone-generated code (with Refresh to regenerate)
|
||||
* 2. Run `hermes-pair --register-code <code>` on the host
|
||||
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
|
||||
* failure through the global snackbar host
|
||||
*
|
||||
* Mirrors the legacy `ConnectionSettingsScreen` Card 3 flow verbatim
|
||||
* since it's already battle-tested. The top-level "Connect" button on
|
||||
* this card requires a relay URL — if the user hasn't set one in the
|
||||
* Manual URL subsection above, the button is disabled and an inline
|
||||
* hint points them there.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManualPairingCodeSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
) {
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
|
||||
// Connect state + auth-watcher attempt counter. Keyed counter so
|
||||
// retrying cancels any in-flight watcher and restarts with a fresh
|
||||
// 15s budget — matches the legacy Card 3 behavior byte-for-byte.
|
||||
var connectInProgress by remember { mutableStateOf(false) }
|
||||
var connectAttempt by remember { mutableStateOf(0) }
|
||||
var explainerExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(connectAttempt) {
|
||||
if (connectAttempt == 0) return@LaunchedEffect
|
||||
try {
|
||||
val terminal = kotlinx.coroutines.withTimeout(15_000) {
|
||||
connectionViewModel.authState
|
||||
.first { it is AuthState.Paired || it is AuthState.Failed }
|
||||
}
|
||||
connectInProgress = false
|
||||
when (terminal) {
|
||||
is AuthState.Paired -> snackbarHost.showSnackbar("Paired successfully")
|
||||
is AuthState.Failed -> {
|
||||
val human = classifyError(
|
||||
IllegalStateException(terminal.reason),
|
||||
context = "pair",
|
||||
)
|
||||
snackbarHost.showHumanError(human)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
connectInProgress = false
|
||||
val human = classifyError(
|
||||
java.io.IOException("No response from relay"),
|
||||
context = "pair",
|
||||
)
|
||||
snackbarHost.showHumanError(human)
|
||||
} catch (e: Exception) {
|
||||
connectInProgress = false
|
||||
snackbarHost.showHumanError(classifyError(e, context = "pair"))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Manual pairing code (fallback)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "Use this when you can't scan the pairing QR. " +
|
||||
"Follow the three steps — they're meant to be done in order on " +
|
||||
"whatever machine you have shell access to.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Step 1 — display + copy + regenerate
|
||||
ManualPairStep(number = 1, title = "Copy the code below") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = pairingCode,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.15,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("Pairing code", pairingCode)),
|
||||
)
|
||||
snackbarHost.showSnackbar("Pairing code copied")
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy pairing code",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { connectionViewModel.regeneratePairingCode() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Generate new code",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 — host command
|
||||
ManualPairStep(number = 2, title = "On the host running Hermes-Relay, run:") {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "hermes-pair --register-code $pairingCode",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val cmd = "hermes-pair --register-code $pairingCode"
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
|
||||
)
|
||||
snackbarHost.showSnackbar("Command copied")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy hermes-pair command",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — Connect button + relay-URL prerequisite check
|
||||
ManualPairStep(number = 3, title = "Come back here and tap Connect") {
|
||||
val canConnect = !connectInProgress &&
|
||||
relayUrl.isNotBlank() &&
|
||||
pairingCode.isNotBlank()
|
||||
Button(
|
||||
onClick = {
|
||||
connectInProgress = true
|
||||
connectAttempt += 1
|
||||
// Atomic apply-code-and-reset avoids races between the
|
||||
// stale session's code-regeneration and this fresh
|
||||
// authenticate()'s mirror-write. Then kick a disconnect
|
||||
// + connect so the WSS handshake uses the new code.
|
||||
connectionViewModel.authManager
|
||||
.applyServerIssuedCodeAndReset(pairingCode)
|
||||
connectionViewModel.disconnectRelay()
|
||||
connectionViewModel.connectRelay(relayUrl)
|
||||
},
|
||||
enabled = canConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (connectInProgress) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("Connecting…")
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
if (relayUrl.isBlank()) {
|
||||
Text(
|
||||
text = "Relay URL not set — open the Manual URL section above to set it first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TextButton(
|
||||
onClick = { explainerExpanded = !explainerExpanded },
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (explainerExpanded) "Hide explanation" else "How does this work?",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (explainerExpanded) {
|
||||
Text(
|
||||
text = "This is a fallback for when you can't scan the pairing QR " +
|
||||
"— for example, no camera, the host can't render a QR, or you " +
|
||||
"only have SSH access from a single device. The canonical flow " +
|
||||
"is the QR scan from `/hermes-relay-pair` or `hermes-pair`.\n\n" +
|
||||
"How it works: the phone generates a 6-character code locally. " +
|
||||
"You paste that code into the host's `hermes-pair --register-code` " +
|
||||
"command, which pre-registers it with the relay. When you tap " +
|
||||
"Connect here, the phone presents the same code to the relay " +
|
||||
"and gets a long-lived session token in return.\n\n" +
|
||||
"Bridge / device-control is gated by the master toggle on the " +
|
||||
"Bridge tab, NOT by this pairing code. Pairing only authorizes " +
|
||||
"the relay session.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security posture strip — always visible on the active card, directly
|
||||
* below the Advanced expander. Renders, in order:
|
||||
* - Transport security badge (wss:// vs ws://)
|
||||
* - Tailscale detected chip (conditional)
|
||||
* - Hardware keystore badge (conditional)
|
||||
* - Relay sessions row (always — tap to navigate)
|
||||
*
|
||||
* These are the "what's my pairing posture?" facts. Short + info-dense,
|
||||
* so they don't live behind an expander.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardSecurityPosture(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToPairedDevices: () -> Unit,
|
||||
) {
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val insecureReason by connectionViewModel.insecureReason.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
// ADR 24 — surface the live endpoint role so the insecure badge can
|
||||
// say "Plain (on LAN)" instead of "Insecure (network unknown)" when
|
||||
// the resolver already knows which candidate we're on.
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
|
||||
TransportSecurityBadge(
|
||||
isSecure = isUrlSecure(relayUrl),
|
||||
reason = insecureReason.ifBlank { null },
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
activeRole = activeEndpoint?.role,
|
||||
)
|
||||
|
||||
if (isTailscaleDetected) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Tailscale detected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF2E7D32),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPairedSession?.hasHardwareStorage == true) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Session token stored in hardware keystore",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onNavigateToPairedDevices() }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Relay sessions",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = if (pairedDevices.isNotEmpty()) {
|
||||
"${pairedDevices.size} active sessions on this server"
|
||||
} else {
|
||||
"Manage which phones can connect"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered step row for the Manual pairing code fallback. Tightly
|
||||
* coupled to its Card 3 layout — step badge sizing + content shape —
|
||||
* so it stays private-ish here rather than promoted to a shared
|
||||
* component. Lift to `ui.components` if a second caller appears.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManualPairStep(
|
||||
number: Int,
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
text = number.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -20,6 +22,7 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -57,9 +60,41 @@ fun BridgeMasterToggle(
|
||||
accessibilityGranted: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "Agent Control",
|
||||
// Called when the user taps the switch to enable but accessibility
|
||||
// hasn't been granted yet. The default no-op keeps the v0.4 behaviour
|
||||
// for callers that don't wire this up; BridgeScreen hooks a snackbar
|
||||
// + settings-deep-link so users get visible feedback instead of a
|
||||
// dead-feeling tap. See kdoc at the call site.
|
||||
onAccessibilityNeeded: () -> Unit = {},
|
||||
) {
|
||||
var showExplain by remember { mutableStateOf(false) }
|
||||
|
||||
// Subtitle is phrased around "master switch" so users understand
|
||||
// this is the parent gate for everything on the page. Sub-features
|
||||
// (unattended access, permissions, voice intents) all require this
|
||||
// to be ON before they do anything — calling it out in plain text
|
||||
// here prevents the "I toggled unattended but nothing happened"
|
||||
// confusion pattern. Copy differs by flavor: sideload is the full
|
||||
// agent-control story, googlePlay is the read-only "chat context"
|
||||
// framing.
|
||||
val isSideloadLabel = label.contains("Agent", ignoreCase = true)
|
||||
val subtitle = if (isSideloadLabel) {
|
||||
if (enabled) {
|
||||
"Master switch — agent can read screen and act via the " +
|
||||
"sub-features below."
|
||||
} else {
|
||||
"Master switch — off. All bridge features (unattended, " +
|
||||
"commands, voice intents) are inactive."
|
||||
}
|
||||
} else {
|
||||
if (enabled) {
|
||||
"Master switch — bridge is providing screen content to chat."
|
||||
} else {
|
||||
"Master switch — off. Bridge is not reading screen content."
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -76,15 +111,13 @@ fun BridgeMasterToggle(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Title + MASTER pill flow together so the pill drops
|
||||
// below on narrow widths instead of squishing the title.
|
||||
// Same pattern + rationale as the "Optional" pill in
|
||||
// BridgePermissionChecklist (see its KDoc).
|
||||
MasterTitleRow(label = label)
|
||||
Text(
|
||||
text = "Agent Control",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = if (enabled) "Active — agent can interact with this device"
|
||||
else "Off — agent cannot control this device",
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -98,8 +131,19 @@ fun BridgeMasterToggle(
|
||||
}
|
||||
Switch(
|
||||
checked = enabled && accessibilityGranted,
|
||||
onCheckedChange = { onToggle(it) },
|
||||
enabled = accessibilityGranted || enabled,
|
||||
// Always interactive — a disabled Switch swallows taps
|
||||
// silently on Android, which made users feel like the
|
||||
// app was broken when accessibility wasn't granted yet.
|
||||
// We now route the blocked-enable path through
|
||||
// onAccessibilityNeeded so the caller can surface a
|
||||
// snackbar with an "Open Settings" action.
|
||||
onCheckedChange = { wantsOn ->
|
||||
if (wantsOn && !accessibilityGranted) {
|
||||
onAccessibilityNeeded()
|
||||
} else {
|
||||
onToggle(wantsOn)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,6 +199,14 @@ fun BridgeMasterToggle(
|
||||
"enable it in Android Settings before this switch works.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"While this is on, a 'Hermes has device control' " +
|
||||
"notification stays in your notification shade — " +
|
||||
"that's tied to this master switch, not to any " +
|
||||
"sub-feature (like Unattended Access), and goes " +
|
||||
"away the moment you turn this off.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"You can turn Agent Control off at any time from this " +
|
||||
"screen or by disabling the service in Android " +
|
||||
@@ -171,6 +223,47 @@ fun BridgeMasterToggle(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title row: the flavor-dependent label plus a compact "MASTER" pill so
|
||||
* users read it as the parent gate on the page at a glance. FlowRow lets
|
||||
* the pill drop below the label on narrow widths (matches the pattern
|
||||
* used by the Optional pill in BridgePermissionChecklist).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MasterTitleRow(label: String) {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
MasterPill()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MasterPill() {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = "MASTER",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusInlineRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
|
||||
+272
-52
@@ -3,10 +3,14 @@ package com.hermesandroid.relay.ui.components
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -15,16 +19,23 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Accessibility
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PictureInPicture
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.Sms
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -39,15 +50,33 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
|
||||
|
||||
/**
|
||||
* Permission checklist card — one row per required Android permission.
|
||||
* Tapping a non-granted row fires an Intent to the corresponding Android
|
||||
* Settings screen so the user can grant the permission without leaving
|
||||
* their muscle-memory path.
|
||||
* Tiered permission checklist (v0.4.1).
|
||||
*
|
||||
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Uses vector Material icons to
|
||||
* stay inside the already-shipped icon set (no dependency on
|
||||
* compose-icons-extended, which has bitten us before — see
|
||||
* `fix(settings): revert ChevronRight…`).
|
||||
* Replaces the original four-row flat layout with four explicit sections so
|
||||
* users (and Play Store reviewers) can see at a glance which permissions are
|
||||
* required, which are optional, and which are sideload-only.
|
||||
*
|
||||
* | Section | Flavor | All rows required? |
|
||||
* |------------------------|-----------------|--------------------|
|
||||
* | Core bridge | both | yes |
|
||||
* | Notification companion | both | optional |
|
||||
* | Voice & camera | both | optional (used on-demand) |
|
||||
* | Sideload features | sideload only | optional |
|
||||
*
|
||||
* Each row dispatches its tap depending on permission type:
|
||||
* - Special perms (Accessibility, Notification Listener, Overlay) deep-link
|
||||
* to the matching `Settings.ACTION_*` intent. Same pattern as v0.4.
|
||||
* - Screen Capture launches the system MediaProjection consent dialog via
|
||||
* [com.hermesandroid.relay.accessibility.ScreenCaptureRequester] (no
|
||||
* Settings page exists for it).
|
||||
* - Runtime dangerous perms (Notifications, Mic, Camera, Contacts, SMS,
|
||||
* Phone, Location) request via parent-supplied lambdas that wrap
|
||||
* `rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission)`.
|
||||
* Status re-probes on `Lifecycle.Event.ON_RESUME` (see
|
||||
* [com.hermesandroid.relay.viewmodel.BridgeViewModel.refreshPermissionStatus]).
|
||||
*
|
||||
* Optional rows render an "Optional" badge so users don't feel pressured to
|
||||
* grant the full set. Sideload-only rows are wholly omitted on googlePlay.
|
||||
*/
|
||||
@Composable
|
||||
fun BridgePermissionChecklist(
|
||||
@@ -59,14 +88,20 @@ fun BridgePermissionChecklist(
|
||||
onTestOverlay: (() -> Unit)? = null,
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
// === PHASE3-bridge-ui-followup: extended interactions ===
|
||||
// Tapping the Screen Capture row launches the system MediaProjection
|
||||
// consent dialog (no Settings page exists for this permission, so
|
||||
// the row's onClick was previously null). Notification Listener gets
|
||||
// its own Test button on the Bridge tab for parity with the others —
|
||||
// the dedicated test on NotificationCompanionSettingsScreen still ships.
|
||||
onRequestScreenCapture: (() -> Unit)? = null,
|
||||
onTestNotificationListener: (() -> Unit)? = null,
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
onRequestNotifications: (() -> Unit)? = null,
|
||||
// === v0.4.1 tiered runtime permission requesters ===
|
||||
// Each lambda calls `rememberLauncherForActivityResult` from BridgeScreen.
|
||||
// Null disables the row's tap action (used by previews).
|
||||
onRequestMicrophone: (() -> Unit)? = null,
|
||||
onRequestCamera: (() -> Unit)? = null,
|
||||
onRequestContacts: (() -> Unit)? = null,
|
||||
onRequestSms: (() -> Unit)? = null,
|
||||
onRequestPhone: (() -> Unit)? = null,
|
||||
onRequestLocation: (() -> Unit)? = null,
|
||||
// === END v0.4.1 ===
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -88,43 +123,78 @@ fun BridgePermissionChecklist(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Tap a row to open Android Settings · Tap Test to verify the permission works.",
|
||||
text = "Tap a row to grant or open Android Settings · Tap Test to verify.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
|
||||
|
||||
// ── Core bridge (required, both flavors) ──────────────────────
|
||||
TierHeader(
|
||||
label = "Core bridge",
|
||||
subtitle = "Required for the agent to read and act on screen content.",
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Accessibility,
|
||||
title = "Accessibility Service",
|
||||
subtitle = "Read screen content, dispatch taps/types",
|
||||
subtitle = if (BuildFlavor.isSideload)
|
||||
"Read screen content, dispatch taps/types"
|
||||
else
|
||||
"Read screen content for chat context",
|
||||
granted = status.accessibilityServiceEnabled,
|
||||
onClick = { openAccessibilitySettings(context) },
|
||||
onTest = onTestAccessibility,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.ScreenShare,
|
||||
title = "Screen Capture",
|
||||
subtitle = if (status.screenCapturePermitted)
|
||||
"Granted for this session — agent can take screenshots"
|
||||
else
|
||||
"Tap to grant — agent needs this for /screenshot",
|
||||
granted = status.screenCapturePermitted,
|
||||
// MediaProjection has no Android Settings page; tapping the
|
||||
// row launches the system consent dialog directly via
|
||||
// ScreenCaptureRequester. Falls back to inert (no chevron)
|
||||
// if the parent didn't provide a launcher (e.g., previews).
|
||||
onClick = onRequestScreenCapture,
|
||||
onTest = onTestScreenCapture,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.PictureInPicture,
|
||||
title = "Display over other apps",
|
||||
subtitle = "Status overlay while bridge is active",
|
||||
granted = status.overlayPermitted,
|
||||
onClick = { openOverlaySettings(context) },
|
||||
onTest = onTestOverlay,
|
||||
// Screen Capture — sideload only. googlePlay doesn't declare
|
||||
// FOREGROUND_SERVICE_MEDIA_PROJECTION or the /screenshot route,
|
||||
// and showing a consent toggle for a capability the APK can't
|
||||
// use would confuse both users and Play reviewers.
|
||||
if (BuildFlavor.isSideload) {
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.ScreenShare,
|
||||
title = "Screen Capture",
|
||||
subtitle = if (status.screenCapturePermitted)
|
||||
"Granted for this session — agent can take screenshots"
|
||||
else
|
||||
"Tap to grant — agent needs this for /screenshot",
|
||||
granted = status.screenCapturePermitted,
|
||||
onClick = onRequestScreenCapture,
|
||||
onTest = onTestScreenCapture,
|
||||
)
|
||||
}
|
||||
// Display over other apps — sideload only. googlePlay has no
|
||||
// destructive-verb safety modal (action routes are blocked) and
|
||||
// no status overlay chip, so the SYSTEM_ALERT_WINDOW permission
|
||||
// isn't needed and showing the row would confuse users + reviewers.
|
||||
if (BuildFlavor.isSideload) {
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.PictureInPicture,
|
||||
title = "Display over other apps",
|
||||
subtitle = "Status overlay while bridge is active",
|
||||
granted = status.overlayPermitted,
|
||||
onClick = { openOverlaySettings(context) },
|
||||
onTest = onTestOverlay,
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notifications",
|
||||
subtitle = if (status.notificationsPermitted)
|
||||
"Bridge service notification can display"
|
||||
else
|
||||
"Required for the bridge foreground service indicator",
|
||||
granted = status.notificationsPermitted,
|
||||
onClick = onRequestNotifications,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Notification companion (optional, both flavors) ─────────────
|
||||
TierSpacer()
|
||||
TierHeader(
|
||||
label = "Notification companion",
|
||||
subtitle = "Optional. Lets the agent see incoming notifications for summaries and replies.",
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Notifications,
|
||||
@@ -132,14 +202,143 @@ fun BridgePermissionChecklist(
|
||||
subtitle = "Read notifications for agent summaries",
|
||||
granted = status.notificationListenerPermitted,
|
||||
onClick = { openNotificationListenerSettings(context) },
|
||||
// Parity Test button on the Bridge tab. The full functional
|
||||
// round-trip test still lives on NotificationCompanionSettingsScreen.
|
||||
onTest = onTestNotificationListener,
|
||||
optional = true,
|
||||
)
|
||||
|
||||
// ── Voice & camera (optional, both flavors) ────────────────────
|
||||
TierSpacer()
|
||||
TierHeader(
|
||||
label = "Voice & camera",
|
||||
subtitle = "Required when you use voice mode or attach camera media.",
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Mic,
|
||||
title = "Microphone",
|
||||
subtitle = "Required for voice mode (record + transcribe).",
|
||||
granted = status.microphonePermitted,
|
||||
onClick = onRequestMicrophone,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.CameraAlt,
|
||||
title = "Camera",
|
||||
subtitle = "Required to attach photos taken in-app.",
|
||||
granted = status.cameraPermitted,
|
||||
onClick = onRequestCamera,
|
||||
optional = true,
|
||||
)
|
||||
|
||||
// ── Sideload features (sideload-only, all optional) ───────────
|
||||
if (BuildFlavor.isSideload) {
|
||||
TierSpacer()
|
||||
TierHeader(
|
||||
label = "Sideload features",
|
||||
subtitle = "Optional. Powers contact lookup, SMS, dialer, and location tools.",
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Contacts,
|
||||
title = "Contacts",
|
||||
subtitle = "Resolve names to phone numbers (android_search_contacts).",
|
||||
granted = status.contactsPermitted,
|
||||
onClick = onRequestContacts,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Sms,
|
||||
title = "SMS",
|
||||
subtitle = "Send text messages directly (android_send_sms).",
|
||||
granted = status.smsPermitted,
|
||||
onClick = onRequestSms,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Call,
|
||||
title = "Phone",
|
||||
subtitle = "Place calls directly without opening the dialer (android_call).",
|
||||
granted = status.phonePermitted,
|
||||
onClick = onRequestPhone,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.LocationOn,
|
||||
title = "Location",
|
||||
subtitle = "Last-known GPS fix for context-aware queries (android_location).",
|
||||
granted = status.locationPermitted,
|
||||
onClick = onRequestLocation,
|
||||
optional = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header for each tier of the checklist. Light visual separator
|
||||
* pattern: a primary-coloured label + a one-line caption underneath.
|
||||
*/
|
||||
@Composable
|
||||
private fun TierHeader(label: String, subtitle: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical spacer + thin divider between tiers. Cheaper than nesting each
|
||||
* tier in its own Card — keeps the "one card per checklist" affordance.
|
||||
*/
|
||||
@Composable
|
||||
private fun TierSpacer() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Material 3 "Optional" pill — small Surface with rounded-pill corners and
|
||||
* the secondary-container colour. Sits inline with the row title so the
|
||||
* affordance reads at a glance without breaking the row's vertical rhythm.
|
||||
*/
|
||||
@Composable
|
||||
private fun OptionalBadge() {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
// softWrap=false prevents the pill's label from self-wrapping to
|
||||
// "Option / al" when the parent Row squeezes its available width
|
||||
// (seen on longer titles like "Notification Listener" on portrait
|
||||
// phone widths). Paired with FlowRow in PermissionRow so the whole
|
||||
// badge drops to the next line cleanly when space is tight, instead
|
||||
// of compressing awkwardly in-line.
|
||||
Text(
|
||||
text = "Optional",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun PermissionRow(
|
||||
icon: ImageVector,
|
||||
@@ -148,6 +347,7 @@ private fun PermissionRow(
|
||||
granted: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onTest: (() -> Unit)? = null,
|
||||
optional: Boolean = false,
|
||||
) {
|
||||
val rowModifier = if (onClick != null) {
|
||||
Modifier
|
||||
@@ -171,22 +371,29 @@ private fun PermissionRow(
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
// FlowRow lets the "Optional" pill drop to a new line as a whole
|
||||
// unit when the title is long enough that the two can't share a
|
||||
// line (e.g. "Notification Listener" on a narrow device). A plain
|
||||
// Row would instead starve the badge of width and its inner Text
|
||||
// would self-wrap, producing a visibly lumpy pill.
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (optional) {
|
||||
OptionalBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// === PHASE3-safety-rails-followup: in-app Test button ===
|
||||
// Compact text button next to the status icon. Tapping it runs a
|
||||
// diagnostic check on the permission and surfaces the result via
|
||||
// BridgeScreen → LocalSnackbarHost. Only renders when the parent
|
||||
// provides an onTest lambda; null hides the button on rows where
|
||||
// a meaningful diagnostic isn't available at this layer.
|
||||
if (onTest != null) {
|
||||
TextButton(
|
||||
onClick = onTest,
|
||||
@@ -201,13 +408,19 @@ private fun PermissionRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
// Status icon: green check when granted, red empty circle otherwise.
|
||||
// Optional rows that are *not* granted use a neutral tint instead of
|
||||
// error red so users don't perceive them as urgent action items.
|
||||
val (statusTint, statusDescription) = when {
|
||||
granted -> Color(0xFF4CAF50) to "Granted"
|
||||
optional -> MaterialTheme.colorScheme.onSurfaceVariant to "Not granted (optional)"
|
||||
else -> MaterialTheme.colorScheme.error to "Not granted"
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (granted) Icons.Filled.CheckCircle
|
||||
else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = if (granted) "Granted" else "Not granted",
|
||||
tint = if (granted) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error,
|
||||
contentDescription = statusDescription,
|
||||
tint = statusTint,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
if (onClick != null) {
|
||||
@@ -267,6 +480,13 @@ private fun BridgePermissionChecklistPreviewAllGranted() {
|
||||
screenCapturePermitted = true,
|
||||
overlayPermitted = true,
|
||||
notificationListenerPermitted = true,
|
||||
notificationsPermitted = true,
|
||||
microphonePermitted = true,
|
||||
cameraPermitted = true,
|
||||
contactsPermitted = true,
|
||||
smsPermitted = true,
|
||||
phonePermitted = true,
|
||||
locationPermitted = true,
|
||||
),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Compact top-bar chip showing the currently-active Hermes connection.
|
||||
* Tapping opens [ConnectionSwitcherSheet]. Kept deliberately minimal — a
|
||||
* single row of label + dropdown caret — so it fits into the Chat top bar
|
||||
* without hogging horizontal space.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AssistChip(
|
||||
onClick = onClick,
|
||||
label = {
|
||||
Row {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Switch connection",
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -10,9 +13,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -21,7 +33,10 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -32,19 +47,29 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.ChatMode
|
||||
import com.hermesandroid.relay.network.ConnectionState
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
@@ -178,7 +203,12 @@ fun SessionInfoSheet(
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val profiles by connectionViewModel.authManager.profiles.collectAsState()
|
||||
// Pass 2 (2026-04-18): the old `sessionLabels: List<String>` field was
|
||||
// replaced by a structured `agentProfiles: List<Profile>` on the VM
|
||||
// (sourced from `auth.ok`'s `profiles` entry, whose items are objects
|
||||
// with {name, model, description}). Render the profile names here so
|
||||
// the user can confirm which agents the server advertised.
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val pairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
|
||||
ModalBottomSheet(
|
||||
@@ -261,8 +291,12 @@ fun SessionInfoSheet(
|
||||
)
|
||||
|
||||
InfoRow(
|
||||
label = "Profiles",
|
||||
value = if (profiles.isEmpty()) "(none)" else profiles.joinToString(", ")
|
||||
label = "Agent profiles",
|
||||
value = if (agentProfiles.isEmpty()) {
|
||||
"(none)"
|
||||
} else {
|
||||
agentProfiles.joinToString(", ") { it.name }
|
||||
}
|
||||
)
|
||||
|
||||
// Security overhaul (2026-04-11) — show expiry + grants + storage.
|
||||
@@ -386,7 +420,7 @@ fun ApiServerInfoSheet(
|
||||
}
|
||||
|
||||
InfoRow(label = "Streaming mode", value = chatMode.toString())
|
||||
InfoRow(label = "Endpoint preference", value = streamingEndpoint)
|
||||
InfoRow(label = "Route preference", value = streamingEndpoint)
|
||||
InfoRow(
|
||||
label = "API key set",
|
||||
value = if (apiKeyPresent) "Yes (hidden)" else "No"
|
||||
@@ -410,9 +444,9 @@ fun ApiServerInfoSheet(
|
||||
onClick = {
|
||||
testing = true
|
||||
testResult = "Testing\u2026"
|
||||
connectionViewModel.testApiConnection { success ->
|
||||
connectionViewModel.testApiConnection { _success, message ->
|
||||
testing = false
|
||||
testResult = if (success) "Connection OK" else "Connection failed"
|
||||
testResult = message
|
||||
}
|
||||
},
|
||||
enabled = !testing,
|
||||
@@ -531,3 +565,790 @@ fun RelayInfoSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. AgentInfoSheet
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Consolidated "agent" sheet opened from the Chat top bar. Replaces the three
|
||||
// separate surfaces that used to split agent state across the UI:
|
||||
// - the old AlertDialog on header tap (read-only connection summary)
|
||||
// - the ProfilePicker chip (profile dropdown)
|
||||
// - the PersonalityPicker chip (personality dropdown)
|
||||
//
|
||||
// One bottom sheet, one hierarchy: Profile → Personality → Connection. Mirrors
|
||||
// ChatViewModel.startStream's precedence rule: a profile with a non-blank
|
||||
// systemMessage overrides whatever personality is selected. When that case
|
||||
// triggers, the Personality section visually de-emphasizes (alpha drop) and
|
||||
// the Profile section footer spells out the override.
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgentInfoSheet(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
// Profile + personality state — same flows the old pickers consumed.
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
|
||||
// Connection summary state.
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
|
||||
// Multi-connection switcher state — folded into this sheet in place of
|
||||
// the separate top-bar ConnectionChip (see 2026-04-20 DEVLOG). Read
|
||||
// through the store so the sheet picks up add/remove/rename events
|
||||
// without needing an explicit re-collect.
|
||||
val allConnections by connectionViewModel.connectionStore.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.connectionStore
|
||||
.activeConnectionId.collectAsState()
|
||||
|
||||
// Mid-stream gate — mirrors what ProfilePicker's `enabled` flag was doing:
|
||||
// a radio tap during an in-flight chat turn would race the request. Apply
|
||||
// to BOTH sections (profile + personality) since they both feed startStream.
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
|
||||
// Session context + analytics for the stats section. Session label
|
||||
// derived by matching the currently-active session id against the
|
||||
// cached sessions list (same pattern the old header dialog used
|
||||
// before consolidation).
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val sessions by chatViewModel.sessions.collectAsState()
|
||||
val currentSession = remember(sessions, currentSessionId) {
|
||||
sessions.firstOrNull { it.sessionId == currentSessionId }
|
||||
}
|
||||
val appStats by AppAnalytics.stats.collectAsState()
|
||||
|
||||
val profileOverridesPersonality =
|
||||
selectedProfile?.systemMessage?.isNotBlank() == true
|
||||
var endpointsExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = LocalSnackbarHost.current
|
||||
|
||||
// Transient confirmation when the user picks a different profile or
|
||||
// personality from inside the sheet. Kept short — these fire on the
|
||||
// tap, so a 1-line toast is enough; the UI state update on the next
|
||||
// chat turn is the real confirmation. Suspend snackbar dispatch goes
|
||||
// through the local coroutine scope so it doesn't block the radio tap.
|
||||
fun toast(message: String) {
|
||||
scope.launch { snackbar.showSnackbar(message) }
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
// verticalScroll wraps content so long content (when the
|
||||
// connection block expands its endpoints, or on smaller
|
||||
// phones) is reachable. ModalBottomSheet itself does not
|
||||
// scroll its children — without this the sheet clips.
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// ---- Header: avatar + agent name + live status ----
|
||||
AgentSheetHeader(
|
||||
selectedProfile = selectedProfile,
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
serverModelName = serverModelName,
|
||||
apiServerReachable = apiServerReachable,
|
||||
chatMode = chatMode,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// ---- Profile section (hidden when server advertises none) ----
|
||||
if (agentProfiles.isNotEmpty()) {
|
||||
// Apparent "server default" profile — the one the server
|
||||
// would use if the phone sent no override. We infer this
|
||||
// from the runtime metadata: the single profile (if any)
|
||||
// whose gateway is reporting running. Used to add a tiny
|
||||
// caption to that row so users can tell which of their
|
||||
// profiles the server is actively serving — especially
|
||||
// important when one of the profiles is literally named
|
||||
// "default" so "Server default" vs "default" would be
|
||||
// otherwise indistinguishable.
|
||||
val apparentActiveProfile = agentProfiles
|
||||
.firstOrNull { it.gatewayRunning }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SectionLabel(
|
||||
title = "Profile",
|
||||
hint = "Overlay an agent's model + SOUL",
|
||||
)
|
||||
|
||||
// "Server default" row — clears the profile override
|
||||
// so the request uses whatever the server's own
|
||||
// config.yaml picks. Renamed from "Default" so a
|
||||
// profile literally named "default" doesn't collide
|
||||
// with this row's label.
|
||||
ProfileRadioRow(
|
||||
primary = "Server default",
|
||||
secondary = "Use this connection's default profile",
|
||||
selected = selectedProfile == null,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedProfile != null) {
|
||||
connectionViewModel.selectProfile(null)
|
||||
toast("Using default model")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
agentProfiles.forEach { profile ->
|
||||
// v0.7.0 runtime metadata indicators:
|
||||
// - leadingDotColor: green when this profile's
|
||||
// gateway is the live one, grey otherwise.
|
||||
// - SOUL badge: primary-container when has_soul.
|
||||
// - Skills chip: surface-variant when skill_count > 0.
|
||||
// Upstream Hermes runs one gateway at a time, so
|
||||
// non-active profiles correctly report off — that's
|
||||
// informational, not disabling. Every row stays at
|
||||
// full alpha; the dot alone communicates status.
|
||||
val dotColor = if (profile.gatewayRunning) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
val dotA11y = if (profile.gatewayRunning) {
|
||||
"Gateway running"
|
||||
} else {
|
||||
"Gateway idle"
|
||||
}
|
||||
val runningLabel = if (profile.gatewayRunning) {
|
||||
" \u2022 Running"
|
||||
} else {
|
||||
" \u2022 Idle"
|
||||
}
|
||||
val soulBg = MaterialTheme.colorScheme.primaryContainer
|
||||
val soulFg = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
val skillsBg = MaterialTheme.colorScheme.surfaceVariant
|
||||
val skillsFg = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val isApparentActive =
|
||||
apparentActiveProfile?.name == profile.name
|
||||
// Emphasize the description as the primary label
|
||||
// when present — it's what users recognise (e.g.
|
||||
// "Victor") better than the profile key. Fall back
|
||||
// to the capitalised profile name when description
|
||||
// is blank.
|
||||
val hasDescription = profile.description.isNotBlank()
|
||||
val primaryLabel = if (hasDescription) {
|
||||
profile.description
|
||||
} else {
|
||||
profile.name.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
// Secondary line: `modelname • Running` / `• Idle`.
|
||||
// The dot itself carries the same information via
|
||||
// colour; the text label is the a11y-visible
|
||||
// complement so a screen reader user also gets
|
||||
// the status without relying on colour.
|
||||
val secondaryLine = profile.model + runningLabel
|
||||
// Tertiary caption: the profile identifier (when
|
||||
// we promoted the description to primary) plus an
|
||||
// "active on server" hint when this row matches
|
||||
// the apparent default.
|
||||
val tertiaryLine = buildString {
|
||||
if (hasDescription) {
|
||||
append("profile: ")
|
||||
append(profile.name)
|
||||
}
|
||||
if (isApparentActive && selectedProfile == null) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("This is the server's active profile")
|
||||
}
|
||||
}.takeIf { it.isNotBlank() }
|
||||
ProfileRadioRow(
|
||||
primary = primaryLabel,
|
||||
secondary = secondaryLine,
|
||||
tertiary = tertiaryLine,
|
||||
selected = selectedProfile?.name == profile.name,
|
||||
enabled = !isStreaming,
|
||||
contentAlpha = 1f,
|
||||
leadingDotColor = dotColor,
|
||||
leadingDotContentDescription = dotA11y,
|
||||
secondaryTrailing = if (profile.hasSoul || profile.skillCount > 0) {
|
||||
{
|
||||
if (profile.skillCount > 0) {
|
||||
ProfileMetadataBadge(
|
||||
text = "${profile.skillCount} skills",
|
||||
background = skillsBg,
|
||||
contentColor = skillsFg,
|
||||
)
|
||||
}
|
||||
if (profile.hasSoul) {
|
||||
ProfileMetadataBadge(
|
||||
text = "SOUL",
|
||||
background = soulBg,
|
||||
contentColor = soulFg,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
onSelect = {
|
||||
if (selectedProfile?.name != profile.name) {
|
||||
connectionViewModel.selectProfile(profile)
|
||||
val display = primaryLabel
|
||||
val suffix = if (profile.systemMessage?.isNotBlank() == true) {
|
||||
" — model + SOUL applied"
|
||||
} else {
|
||||
" — model applied"
|
||||
}
|
||||
toast("Switched to $display$suffix")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (profileOverridesPersonality) {
|
||||
Text(
|
||||
text = "This profile's system message overrides the personality below.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// ---- Personality section ----
|
||||
// De-emphasize when a profile's systemMessage is taking over — the
|
||||
// row is still tappable because the user may want to queue the
|
||||
// choice for after they clear the profile. No alpha on the entire
|
||||
// Column because the section header would look broken.
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.alpha(if (profileOverridesPersonality) 0.55f else 1f),
|
||||
) {
|
||||
SectionLabel(
|
||||
title = "Personality",
|
||||
hint = "System-prompt preset on this agent",
|
||||
)
|
||||
|
||||
// Default row — maps to selectedPersonality == "default" which
|
||||
// the VM resolves to whatever server-side personality is
|
||||
// currently active.
|
||||
ProfileRadioRow(
|
||||
primary = if (defaultPersonality.isNotBlank()) {
|
||||
"${defaultPersonality.replaceFirstChar { it.uppercase() }} (default)"
|
||||
} else {
|
||||
"Default"
|
||||
},
|
||||
secondary = null,
|
||||
selected = selectedPersonality == "default",
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedPersonality != "default") {
|
||||
chatViewModel.selectPersonality("default")
|
||||
if (!profileOverridesPersonality) {
|
||||
toast("Using default personality")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
personalityNames
|
||||
.filter { it != defaultPersonality }
|
||||
.forEach { name ->
|
||||
ProfileRadioRow(
|
||||
primary = name.replaceFirstChar { it.uppercase() },
|
||||
secondary = null,
|
||||
selected = selectedPersonality == name,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedPersonality != name) {
|
||||
chatViewModel.selectPersonality(name)
|
||||
if (!profileOverridesPersonality) {
|
||||
val display = name.replaceFirstChar { it.uppercase() }
|
||||
toast("Personality: $display")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// When a profile SOUL is active AND the user has picked a
|
||||
// non-default personality, make the precedence explicit
|
||||
// here at the point the confusion would otherwise land.
|
||||
// The mirror caption in the Profile section tells the same
|
||||
// story from the other direction — both are kept because a
|
||||
// user scanning either section should see the constraint.
|
||||
if (profileOverridesPersonality && selectedPersonality != "default") {
|
||||
Text(
|
||||
text = "Profile SOUL overrides personality while active.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// ---- Session + stats section ----
|
||||
// Brings back the session/messages readout the old header
|
||||
// AlertDialog used to show, plus adds current-session token
|
||||
// counters pulled straight from AppAnalytics (no new flows
|
||||
// needed — already being collected via ChatViewModel's
|
||||
// stream lifecycle hooks).
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionLabel(title = "Session", hint = null)
|
||||
|
||||
val sessionLabel = currentSession?.let {
|
||||
it.title?.takeIf { t -> t.isNotBlank() }
|
||||
?: it.sessionId.take(12)
|
||||
} ?: "—"
|
||||
InfoRow(label = "Name", value = sessionLabel)
|
||||
InfoRow(label = "Messages", value = messages.size.toString())
|
||||
|
||||
// Token counters only — skip internal accumulator fields.
|
||||
// Zero values are informative here ("no usage yet this
|
||||
// session") so we don't hide them.
|
||||
InfoRow(
|
||||
label = "Tokens",
|
||||
value = buildString {
|
||||
append(appStats.currentSessionTokensIn.toString())
|
||||
append(" in · ")
|
||||
append(appStats.currentSessionTokensOut.toString())
|
||||
append(" out")
|
||||
},
|
||||
)
|
||||
if (appStats.avgResponseTimeMs > 0L) {
|
||||
InfoRow(
|
||||
label = "Avg TTFT",
|
||||
value = "${appStats.avgResponseTimeMs} ms",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// ---- Connection section (condensed) ----
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionLabel(
|
||||
title = "Connection",
|
||||
hint = if (allConnections.size >= 2) "Switch between paired servers" else null,
|
||||
)
|
||||
|
||||
// Multi-connection switcher. Renders inline as a radio list
|
||||
// (mirrors the Profile + Personality sections above) when the
|
||||
// user has ≥2 paired connections. Replaces the separate top-
|
||||
// bar ConnectionChip that used to be the only switch surface
|
||||
// — folding it here keeps all agent/connection controls in
|
||||
// one place, matching Bailey's ask in the 2026-04-20 audit.
|
||||
//
|
||||
// Single-connection case shows no radio list (would be a
|
||||
// redundant "only one row, already selected" waste of
|
||||
// vertical space). The Auth / API reachable / endpoints
|
||||
// block below always renders regardless.
|
||||
if (allConnections.size >= 2) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
allConnections.forEach { connection ->
|
||||
val isActive = connection.id == activeConnectionId
|
||||
val hostname = com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = when {
|
||||
connection.pairedAt == null -> "$hostname • Not paired"
|
||||
else -> "$hostname • Paired"
|
||||
}
|
||||
ProfileRadioRow(
|
||||
primary = connection.label,
|
||||
secondary = statusLine,
|
||||
selected = isActive,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (!isActive) {
|
||||
connectionViewModel.switchConnection(connection.id)
|
||||
toast("Switched to ${connection.label}")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChipRow(label = "Auth") { authStateChip(authState) }
|
||||
ChipRow(label = "API reachable") {
|
||||
val (label, bg, fg) = if (apiServerReachable) {
|
||||
Triple(
|
||||
"Yes",
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
Triple(
|
||||
"No",
|
||||
MaterialTheme.colorScheme.errorContainer,
|
||||
MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
StatusChip(text = label, background = bg, contentColor = fg)
|
||||
}
|
||||
|
||||
// Pairing code only while the user is mid-pairing — no point
|
||||
// showing it once we're paired (it's consumed).
|
||||
if (authState is AuthState.Pairing && pairingCode.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Pairing code",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = pairingCode,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText(
|
||||
"Pairing code",
|
||||
pairingCode,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy pairing code",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible endpoint block — keeps the default view tidy.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { endpointsExpanded = !endpointsExpanded }
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (endpointsExpanded) "Hide routes" else "Show routes",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (endpointsExpanded) {
|
||||
Icons.Filled.KeyboardArrowUp
|
||||
} else {
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
if (endpointsExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(label = "API", value = apiServerUrl, monospace = true)
|
||||
InfoRow(label = "Relay", value = relayUrl, monospace = true)
|
||||
ChipRow(label = "Relay state") {
|
||||
connectionChip(relayConnectionState)
|
||||
}
|
||||
InfoRow(label = "Streaming", value = chatMode.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// ---- Footer action: jump to Connections settings ----
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNavigateToConnections()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("Manage connections\u2026")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AgentInfoSheet helpers ----------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(title: String, hint: String?) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (hint != null) {
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Radio-style row used by both Profile and Personality sections. Whole row
|
||||
* is a tap target (and selectable() for a11y). Disabled when [enabled] is
|
||||
* false — look and behaviour both propagate the gate.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileRadioRow(
|
||||
primary: String,
|
||||
secondary: String?,
|
||||
tertiary: String? = null,
|
||||
selected: Boolean,
|
||||
enabled: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
/**
|
||||
* Extra alpha multiplier applied to the whole row on top of the
|
||||
* disabled-state dimming. Used by the Profile section to hint that a
|
||||
* gateway-off profile is selectable-but-probably-stale (50%). 1f is
|
||||
* neutral.
|
||||
*/
|
||||
contentAlpha: Float = 1f,
|
||||
/**
|
||||
* Optional status dot rendered between the RadioButton and the text
|
||||
* column. 6 dp circle; hide by leaving null.
|
||||
*/
|
||||
leadingDotColor: Color? = null,
|
||||
/**
|
||||
* Optional accessibility content description for the leading status
|
||||
* dot. Screen readers announce this string so users don't have to
|
||||
* rely on the dot's colour to know the profile's runtime state.
|
||||
* Pass e.g. "Gateway running" / "Gateway idle". Null skips the
|
||||
* a11y announcement (and renders the dot as a decoration-only).
|
||||
*/
|
||||
leadingDotContentDescription: String? = null,
|
||||
/**
|
||||
* Optional trailing chip/badge row rendered next to the [secondary]
|
||||
* line (same baseline as the model-name text for the Profile section
|
||||
* entries). Slot-based so each call site composes its own badges.
|
||||
*/
|
||||
secondaryTrailing: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val rowAlpha = (if (enabled) 1f else 0.5f) * contentAlpha
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.selectable(
|
||||
selected = selected,
|
||||
enabled = enabled,
|
||||
role = Role.RadioButton,
|
||||
onClick = onSelect,
|
||||
)
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp)
|
||||
.alpha(rowAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = null,
|
||||
enabled = enabled,
|
||||
)
|
||||
if (leadingDotColor != null) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(leadingDotColor)
|
||||
.then(
|
||||
if (leadingDotContentDescription != null) {
|
||||
Modifier.semantics {
|
||||
contentDescription = leadingDotContentDescription
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = primary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (secondary != null || secondaryTrailing != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (secondary != null) {
|
||||
Text(
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (secondaryTrailing != null) {
|
||||
secondaryTrailing()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tertiary != null) {
|
||||
Text(
|
||||
text = tertiary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny pill rendered inline with a profile row's secondary line. Used for
|
||||
* "N skills" and "SOUL" indicators. Kept compact and visually subordinate
|
||||
* to the model name — ~labelSmall, rounded to the max, padding trimmed.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileMetadataBadge(
|
||||
text: String,
|
||||
background: Color,
|
||||
contentColor: Color,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(background)
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentSheetHeader(
|
||||
selectedProfile: Profile?,
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
serverModelName: String,
|
||||
apiServerReachable: Boolean,
|
||||
chatMode: ChatMode,
|
||||
) {
|
||||
val agentName = when {
|
||||
selectedProfile != null -> selectedProfile.name.replaceFirstChar { it.uppercase() }
|
||||
selectedPersonality != "default" -> selectedPersonality.replaceFirstChar { it.uppercase() }
|
||||
defaultPersonality.isNotBlank() -> defaultPersonality.replaceFirstChar { it.uppercase() }
|
||||
else -> "Hermes"
|
||||
}
|
||||
val modelLabel = selectedProfile?.model ?: serverModelName
|
||||
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
|
||||
val statusText = when {
|
||||
apiServerReachable -> "Connected"
|
||||
isConnecting -> "Connecting\u2026"
|
||||
else -> "Disconnected"
|
||||
}
|
||||
val statusColor = when {
|
||||
apiServerReachable -> MaterialTheme.colorScheme.primary
|
||||
isConnecting -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
val customized = selectedProfile != null || selectedPersonality != "default"
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.then(
|
||||
if (customized) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.padding(2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(44.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = agentName.firstOrNull()?.uppercase() ?: "H",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = agentName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
if (modelLabel.isNotBlank()) {
|
||||
Text(
|
||||
text = modelLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,17 +238,29 @@ fun ConnectionStatusRow(
|
||||
}
|
||||
Row(
|
||||
modifier = modifier.then(interactiveModifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ConnectionStatusBadge(state = state)
|
||||
// Small top padding on the badge so it sits visually centered with
|
||||
// a single-line label+status, but stays aligned to the top for
|
||||
// multi-line errors (where CenterVertically would drop it into the
|
||||
// middle of a three-line block).
|
||||
Box(modifier = Modifier.padding(top = 4.dp)) {
|
||||
ConnectionStatusBadge(state = state)
|
||||
}
|
||||
|
||||
// Label keeps its intrinsic width (no weight). Previously carried
|
||||
// `weight(1f, fill = false)`, which collapsed it to 1 char wide
|
||||
// when an unweighted statusText greedily claimed the whole row.
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
|
||||
// Status text gets the weight so long errors wrap inside their own
|
||||
// allocation instead of squeezing the label. `fill = false` lets
|
||||
// short statuses (e.g. "Reachable") sit naturally without forcing
|
||||
// the row to span full width.
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -257,7 +269,8 @@ fun ConnectionStatusRow(
|
||||
BadgeState.Connecting -> Color(0xFFFFA726)
|
||||
BadgeState.Probing -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
BadgeState.Disconnected -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
|
||||
if (onTest != null) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
|
||||
/**
|
||||
* Bottom sheet chooser for switching between Hermes connections. Driven by
|
||||
* the top-bar [ConnectionChip] tap and the Settings → Connections row.
|
||||
* Each row is a radio selection — tapping commits immediately and dismisses
|
||||
* the sheet so the swap kicks off before the user's finger is off the screen.
|
||||
*
|
||||
* The "Manage connections…" footer button navigates to
|
||||
* [ConnectionsSettingsScreen] for rename / re-pair / revoke / remove —
|
||||
* anything beyond plain switching.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionSwitcherSheet(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String?,
|
||||
onSelectConnection: (String) -> Unit,
|
||||
onManageConnections: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Switch connection",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
if (connections.isEmpty()) {
|
||||
// Defensive: the legacy migration should always seed connection 0,
|
||||
// but fall back to a Manage-only state if the list is empty.
|
||||
Text(
|
||||
text = "No connections yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onManageConnections,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Manage connections…")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
items(connections, key = { it.id }) { connection ->
|
||||
ConnectionRow(
|
||||
connection = connection,
|
||||
isActive = connection.id == activeConnectionId,
|
||||
onClick = {
|
||||
onSelectConnection(connection.id)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onManageConnections,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Manage connections…")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionRow(
|
||||
connection: Connection,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = if (connection.pairedAt == null) {
|
||||
"$hostname • Not paired"
|
||||
} else {
|
||||
"$hostname • Paired"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isActive,
|
||||
onClick = onClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = connection.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = statusLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+58
-7
@@ -12,17 +12,23 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -53,9 +59,20 @@ fun DestructiveVerbConfirmDialog(
|
||||
method: String,
|
||||
verb: String,
|
||||
fullText: String,
|
||||
onAllow: () -> Unit,
|
||||
onAllow: (trustVerb: Boolean) -> Unit,
|
||||
onDeny: () -> Unit,
|
||||
) {
|
||||
// Checkbox is always OFF when the dialog opens — the user must
|
||||
// actively opt in to bypass future prompts. Kept local-only: we only
|
||||
// persist the verb to the trusted set when the user then taps Allow.
|
||||
// Tapping Deny with the box checked does NOT add the verb (denying is
|
||||
// not consent to anything).
|
||||
var trustVerb by remember { mutableStateOf(false) }
|
||||
// Only offer the "Don't ask again" escape hatch when we actually have
|
||||
// a specific verb to key the trust on. Routes like /call and
|
||||
// /send_sms come through with verb="" and must always prompt — there's
|
||||
// nothing to trust.
|
||||
val canTrust = verb.isNotBlank()
|
||||
// Root-fills-overlay-with-center-alignment. The overlay already applies
|
||||
// FLAG_DIM_BEHIND so we only need to draw the card itself.
|
||||
Box(
|
||||
@@ -131,6 +148,30 @@ fun DestructiveVerbConfirmDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (canTrust) {
|
||||
// "Don't ask again" row — whole row is clickable so the
|
||||
// label is a tappable target (accessibility + fat-fingers).
|
||||
// Off by default on every open; see the @Composable KDoc.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { trustVerb = !trustVerb }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = trustVerb,
|
||||
onCheckedChange = { trustVerb = it },
|
||||
)
|
||||
Text(
|
||||
text = "Don't ask again for \"$verb\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
@@ -139,13 +180,16 @@ fun DestructiveVerbConfirmDialog(
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
// Deny never writes trust — denying a command isn't
|
||||
// consent to anything, even if the user happened to
|
||||
// tick the checkbox before changing their mind.
|
||||
onClick = onDeny,
|
||||
) {
|
||||
Text("Deny")
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onAllow,
|
||||
onClick = { onAllow(trustVerb && canTrust) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFE53935),
|
||||
contentColor = Color.White,
|
||||
@@ -184,7 +228,14 @@ private fun verbPhrase(method: String, verb: String): String {
|
||||
* package.
|
||||
*/
|
||||
@Composable
|
||||
fun BridgeStatusOverlayChip() {
|
||||
fun BridgeStatusOverlayChip(unattended: Boolean = false) {
|
||||
// v0.4.1: when unattended access is on, the chip uses an amber dot
|
||||
// and "Unattended ON" label so the user / a passerby can tell at a
|
||||
// glance that the agent is permitted to wake the screen and drive
|
||||
// the device. Default red dot + "Hermes active" preserves the v0.4
|
||||
// appearance for the regular bridge-active path.
|
||||
val dotColor = if (unattended) Color(0xFFFFA000) else Color(0xFFE53935)
|
||||
val label = if (unattended) "Unattended ON" else "Hermes active"
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
@@ -201,10 +252,10 @@ fun BridgeStatusOverlayChip() {
|
||||
modifier = Modifier
|
||||
.height(8.dp)
|
||||
.widthIn(min = 8.dp, max = 8.dp)
|
||||
.background(Color(0xFFE53935), RoundedCornerShape(50))
|
||||
.background(dotColor, RoundedCornerShape(50))
|
||||
)
|
||||
Text(
|
||||
text = "Hermes active",
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
@@ -231,7 +282,7 @@ private fun DestructiveVerbConfirmDialogPreview_TapText() {
|
||||
method = "/tap_text",
|
||||
verb = "Send",
|
||||
fullText = "Send $500 to Alice",
|
||||
onAllow = {},
|
||||
onAllow = { _ -> },
|
||||
onDeny = {},
|
||||
)
|
||||
}
|
||||
@@ -245,7 +296,7 @@ private fun DestructiveVerbConfirmDialogPreview_Type() {
|
||||
method = "/type",
|
||||
verb = "delete",
|
||||
fullText = "delete all messages in #general",
|
||||
onAllow = {},
|
||||
onAllow = { _ -> },
|
||||
onDeny = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lan
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
||||
* settings screen. Renders one row per [EndpointCandidate] stored for the
|
||||
* active device, with a health chip, a 3-dot menu (Prefer / Probe now /
|
||||
* View pin), and a bottom "Clear manual override" action when a preferred
|
||||
* role is set.
|
||||
*
|
||||
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
|
||||
* do NOT collapse into a master row. The card itself is wrapped in a
|
||||
* [SettingsExpandableCard] by the caller for page layout hygiene.
|
||||
*
|
||||
* Not visible on legacy installs: when [endpoints] is empty we render a
|
||||
* helpful one-liner instead of an empty card, so freshly-upgraded users
|
||||
* who haven't re-paired yet understand why they can't see anything.
|
||||
*/
|
||||
@Composable
|
||||
fun EndpointsCard(
|
||||
endpoints: List<EndpointCandidate>,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
preferredRole: String?,
|
||||
onPreferEndpoint: (EndpointCandidate) -> Unit,
|
||||
onClearOverride: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
) {
|
||||
if (endpoints.isEmpty()) {
|
||||
Text(
|
||||
text = "No route candidates stored for this device yet. " +
|
||||
"Scan a v3 pairing QR (Hermes 0.4.2+) to enable multi-route " +
|
||||
"switching — LAN + Tailscale + public URLs.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
endpoints.forEachIndexed { index, candidate ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
EndpointRow(
|
||||
candidate = candidate,
|
||||
isActive = activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port,
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onProbeNow = onProbeNow,
|
||||
onViewPin = onViewPin,
|
||||
)
|
||||
}
|
||||
|
||||
if (preferredRole != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onClearOverride, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Clear manual override (preferring $preferredRole)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row: role chip + host:port + transport hint + health chip + 3-dot menu.
|
||||
*/
|
||||
@Composable
|
||||
private fun EndpointRow(
|
||||
candidate: EndpointCandidate,
|
||||
isActive: Boolean,
|
||||
isPreferred: Boolean,
|
||||
onPrefer: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = roleIcon(candidate.role),
|
||||
contentDescription = null,
|
||||
tint = if (isActive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = candidate.displayLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (isActive) {
|
||||
ActiveChip()
|
||||
} else if (isPreferred) {
|
||||
PreferredChip()
|
||||
} else {
|
||||
FallbackChip()
|
||||
}
|
||||
if (!candidate.isKnownRole()) {
|
||||
// Show the raw role for custom-VPN entries so users
|
||||
// can tell "netbird-eu" from "wireguard-home" at a
|
||||
// glance without poking into the menu.
|
||||
Text(
|
||||
text = "(${candidate.role})",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port}" +
|
||||
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
// without needing to expand into a detail sheet. "View pin"
|
||||
// suspends to read CertPinStore, so we resolve it into a dialog
|
||||
// when the user taps it.
|
||||
if (!isActive) {
|
||||
TextButton(onClick = onPrefer) {
|
||||
Text("Use now")
|
||||
}
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "Endpoint actions",
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Prefer this route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onPrefer()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Probe now") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onProbeNow()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("View pin") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
scope.launch {
|
||||
pinDialogText = onViewPin(candidate)
|
||||
?: "No pin recorded yet — the phone will " +
|
||||
"record one on first TOFU connect."
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
title = { Text("TOFU pin · ${candidate.api.host}") },
|
||||
text = {
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { pinDialogText = null }) { Text("Close") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveChip() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.height(10.dp),
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = "Active",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferredChip() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFFA726).copy(alpha = 0.18f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Preferred",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFFB26A00),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outlined neutral chip rendered on non-active, non-preferred routes so
|
||||
* every row states its standing explicitly (mirror of [ActiveChip] /
|
||||
* [PreferredChip]). No background fill — just a 1dp border so it reads
|
||||
* as "available fallback" not "something is happening here".
|
||||
*/
|
||||
@Composable
|
||||
private fun FallbackChip() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Fallback",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Role → Material icon. Known roles get their canonical glyph; anything
|
||||
* else falls through to [Icons.Filled.Shield] (generic "Custom VPN").
|
||||
*/
|
||||
private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
"lan" -> Icons.Filled.Lan
|
||||
"tailscale" -> Icons.Filled.VpnKey
|
||||
"public" -> Icons.Filled.Public
|
||||
else -> Icons.Filled.Shield
|
||||
}
|
||||
@@ -47,7 +47,10 @@ fun ExtraKeysToolbar(
|
||||
onCtrlToggle: () -> Unit,
|
||||
onAltToggle: () -> Unit,
|
||||
onArrow: (SpecialKey) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onScrollUp: (() -> Unit)? = null,
|
||||
onScrollDown: (() -> Unit)? = null,
|
||||
onScrollToBottom: (() -> Unit)? = null,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
@@ -135,6 +138,51 @@ fun ExtraKeysToolbar(
|
||||
onArrow(SpecialKey.ARROW_RIGHT)
|
||||
}
|
||||
)
|
||||
|
||||
// Scrollback controls — target xterm.js's viewport, NOT the remote
|
||||
// PTY. Unlike the arrow keys above (which send ANSI escapes into the
|
||||
// running shell), these just move the local scrollback window, so
|
||||
// the user can look at older output without disturbing whatever the
|
||||
// shell thinks the cursor position is.
|
||||
if (onScrollUp != null || onScrollDown != null) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
onScrollUp?.let { scrollUp ->
|
||||
ToolbarKey(
|
||||
label = "\u21D1", // upwards double arrow — distinct from ARROW_UP
|
||||
active = false,
|
||||
weight = 1f,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
scrollUp()
|
||||
}
|
||||
)
|
||||
}
|
||||
onScrollDown?.let { scrollDown ->
|
||||
ToolbarKey(
|
||||
label = "\u21D3", // downwards double arrow
|
||||
active = false,
|
||||
weight = 1f,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
scrollDown()
|
||||
}
|
||||
)
|
||||
}
|
||||
// "Jump to bottom" — small and always present when any scroll
|
||||
// callback is. Covers the case where the user has scrolled way up
|
||||
// and wants to snap back without swiping endlessly.
|
||||
onScrollToBottom?.let { scrollToBottom ->
|
||||
ToolbarKey(
|
||||
label = "\u21F2", // south-east double arrow; reads as "end"
|
||||
active = false,
|
||||
weight = 1f,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.CalendarToday
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.WbSunny
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.HermesCardField
|
||||
|
||||
/**
|
||||
* Inline rich-card render for a [HermesCard] extracted from an assistant
|
||||
* message via the `CARD:{json}` marker pipeline in
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler].
|
||||
*
|
||||
* Layout (top → bottom):
|
||||
* - Accent stripe (leading 3dp bar, colored by [HermesCard.accent])
|
||||
* - Header row: type icon + title + optional subtitle
|
||||
* - Body (markdown) if present
|
||||
* - Fields table (label : value rows)
|
||||
* - Actions row (AssistChip / Button per [HermesCardAction])
|
||||
* - Footer (muted labelSmall) if present
|
||||
*
|
||||
* Unknown [HermesCard.type] renders via the generic path — title + body +
|
||||
* fields + actions — so a newer agent emitting a type the phone build
|
||||
* doesn't recognize still gets a coherent card, not an empty bubble.
|
||||
*
|
||||
* Action dispatch is fully delegated to [onActionTap]. The bubble is
|
||||
* stateless w.r.t. dispatch tracking — it reads [dispatches] (from the
|
||||
* owning [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and
|
||||
* renders a confirmation row instead of the action buttons once the user
|
||||
* has chosen.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HermesCardBubble(
|
||||
card: HermesCard,
|
||||
cardKey: String,
|
||||
dispatches: List<HermesCardDispatch>,
|
||||
onActionTap: (cardKey: String, action: HermesCardAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxWidth: Dp = 280.dp,
|
||||
) {
|
||||
val accentColor = accentToColor(card.accent)
|
||||
val typeIcon = iconForType(card.type)
|
||||
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Card: ${card.title ?: card.type}"
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min),
|
||||
) {
|
||||
// Accent stripe — runs full card height so tall cards keep the
|
||||
// color tie. Using the SAME tertiary accent strategy as the
|
||||
// voice/phone-action bubble marker in MessageBubble.kt so the
|
||||
// visual language stays consistent.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.background(accentColor),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (typeIcon != null) {
|
||||
Icon(
|
||||
imageVector = typeIcon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (!card.title.isNullOrBlank()) {
|
||||
Text(
|
||||
text = card.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
if (!card.subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = card.subtitle,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body — markdown, so the agent can embed inline code /
|
||||
// emphasis / links. Uses the existing MarkdownContent
|
||||
// renderer from MessageBubble's stack.
|
||||
if (!card.body.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
MarkdownContent(
|
||||
content = card.body,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// Fields table
|
||||
if (card.fields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
card.fields.forEach { field ->
|
||||
FieldRow(field)
|
||||
}
|
||||
}
|
||||
|
||||
// Actions OR dispatch confirmation
|
||||
if (card.actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (alreadyChosen != null) {
|
||||
val chosen = card.actions.firstOrNull {
|
||||
it.value == alreadyChosen.actionValue
|
||||
}
|
||||
ChoseRow(chosen?.label ?: alreadyChosen.actionValue)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
card.actions.forEach { action ->
|
||||
ActionButton(
|
||||
action = action,
|
||||
onClick = { onActionTap(cardKey, action) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
if (!card.footer.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = card.footer,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FieldRow(field: HermesCardField) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = field.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(88.dp),
|
||||
)
|
||||
Text(
|
||||
text = field.value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = if (looksMonospaceWorthy(field.value)) FontFamily.Monospace else null,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Heuristic — values that look like paths, commands, or code get a mono font. */
|
||||
private fun looksMonospaceWorthy(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
return trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("$") ||
|
||||
trimmed.startsWith("`") ||
|
||||
trimmed.contains("://") ||
|
||||
trimmed.matches(Regex("""^\S+\s+--\S.*$""")) // flags-style
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoseRow(label: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f))
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Chose: $label",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
action: HermesCardAction,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
when (action.style) {
|
||||
HermesCardAction.Styles.PRIMARY -> Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) { Text(action.label, style = MaterialTheme.typography.labelMedium) }
|
||||
HermesCardAction.Styles.DANGER -> OutlinedButton(
|
||||
onClick = onClick,
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) { Text(action.label, style = MaterialTheme.typography.labelMedium) }
|
||||
else -> OutlinedButton(onClick = onClick) {
|
||||
Text(action.label, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic accent → ColorScheme token. Unknown values fall back to the
|
||||
* neutral `info` tint (primary) so the stripe still shows up.
|
||||
*/
|
||||
@Composable
|
||||
private fun accentToColor(accent: String?): Color = when (accent) {
|
||||
HermesCard.Accents.SUCCESS -> MaterialTheme.colorScheme.primary
|
||||
HermesCard.Accents.WARNING -> MaterialTheme.colorScheme.tertiary
|
||||
HermesCard.Accents.DANGER -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.primary // info + unknown
|
||||
}
|
||||
|
||||
/** Built-in type → header icon. Unknown types show a neutral "auto" spark. */
|
||||
private fun iconForType(type: String): ImageVector? = when (type) {
|
||||
HermesCard.BuiltInTypes.APPROVAL_REQUEST -> Icons.Filled.Shield
|
||||
HermesCard.BuiltInTypes.LINK_PREVIEW -> Icons.Filled.Language
|
||||
HermesCard.BuiltInTypes.CALENDAR_EVENT -> Icons.Filled.CalendarToday
|
||||
HermesCard.BuiltInTypes.WEATHER -> Icons.Filled.WbSunny
|
||||
HermesCard.BuiltInTypes.SKILL_RESULT -> Icons.Filled.AutoAwesome
|
||||
else -> Icons.Filled.AutoAwesome
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a [HermesCardAction] tap to a side-effecting action on the
|
||||
* current Android context. Kept as a plain top-level helper so both the
|
||||
* ChatViewModel and any future preview harness can reuse it without
|
||||
* dragging in ViewModel dependencies.
|
||||
*
|
||||
* - [HermesCardAction.Modes.OPEN_URL] → `ACTION_VIEW` intent.
|
||||
* - Other modes return false — the caller is expected to route them
|
||||
* (send as a new user message, or interpret as a slash command in
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel]).
|
||||
*/
|
||||
fun handleCardActionExternally(
|
||||
context: android.content.Context,
|
||||
action: HermesCardAction,
|
||||
): Boolean {
|
||||
if (action.mode != HermesCardAction.Modes.OPEN_URL) return false
|
||||
return runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(action.value))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -6,17 +6,22 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -33,6 +38,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -59,15 +65,46 @@ fun MessageBubble(
|
||||
* Invoked when the user taps a LOADING+"Tap to download" placeholder
|
||||
* (the cellular deferral case).
|
||||
*/
|
||||
onAttachmentManualFetch: (messageId: String, attachmentIndex: Int) -> Unit = { _, _ -> }
|
||||
onAttachmentManualFetch: (messageId: String, attachmentIndex: Int) -> Unit = { _, _ -> },
|
||||
/**
|
||||
* Invoked when the user taps an action button on an inline
|
||||
* [com.hermesandroid.relay.data.HermesCard]. Routed through
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel.dispatchCardAction]
|
||||
* by the owning screen — that path records the dispatch stamp (so the
|
||||
* card collapses) and forwards the action value per its mode.
|
||||
* Defaults to no-op so legacy callers / tests don't have to wire it.
|
||||
*/
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> }
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
|
||||
val backgroundColor = when (message.role) {
|
||||
MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.surfaceVariant
|
||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
// Phone/voice-origin action bubble marker.
|
||||
//
|
||||
// Voice mode (sideload classifier → RealVoiceBridgeIntentHandler) emits
|
||||
// these with `agentName = "Voice action"` and an id prefixed
|
||||
// `voice-intent-action-*` / `voice-intent-result-*`. Chat mode parity
|
||||
// (ChatHandler.onToolCallComplete for android_* tools) emits them with
|
||||
// `agentName = "Phone action"`. We match on either so a single render
|
||||
// branch applies the accent to both origins.
|
||||
//
|
||||
// Chosen marker: subtle thin vertical accent bar on the leading edge of
|
||||
// the bubble in colorScheme.tertiary, so an action bubble is
|
||||
// immediately distinguishable from a regular LLM reply when they
|
||||
// interleave in the scrollback. Subtle on purpose — the content still
|
||||
// carries the signal, the bar just flags "this was a phone control
|
||||
// action, not LLM narration".
|
||||
val isActionBubble = !isUser && !isSystem && (
|
||||
message.agentName == "Voice action" ||
|
||||
message.agentName == "Phone action" ||
|
||||
message.id.startsWith("voice-intent-")
|
||||
)
|
||||
|
||||
val backgroundColor = when {
|
||||
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||
message.role == MessageRole.SYSTEM -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
isActionBubble -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.45f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
val textColor = when (message.role) {
|
||||
@@ -118,12 +155,31 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
// Message bubble.
|
||||
//
|
||||
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
||||
// a Row with a thin leading tertiary-colored accent bar. The bar
|
||||
// is rendered as a separate Box so it hugs the bubble's left edge
|
||||
// regardless of content height (tall bubbles with multi-line
|
||||
// markdown stretch the bar via fillMaxHeight + IntrinsicSize).
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = maxBubbleWidth),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
if (isActionBubble) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 8.dp, end = 6.dp)
|
||||
.width(3.dp)
|
||||
.height(if (message.content.isBlank()) 14.dp else 24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f))
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
Modifier.leftEdgeGlow(
|
||||
@@ -159,6 +215,29 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Rich cards — rendered between the markdown body and
|
||||
// attachments so the reading order stays: narration → card
|
||||
// → attached file. Each card gets a stable key built from
|
||||
// its optional id or falling back to its positional index,
|
||||
// so a reload-from-history doesn't lose "I already chose X"
|
||||
// state tracked in [ChatMessage.cardDispatches].
|
||||
if (!isUser && !isSystem && message.cards.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
message.cards.forEachIndexed { index, card ->
|
||||
val cardKey = card.id ?: "idx:$index"
|
||||
HermesCardBubble(
|
||||
card = card,
|
||||
cardKey = cardKey,
|
||||
dispatches = message.cardDispatches,
|
||||
onActionTap = { key, action ->
|
||||
onCardAction(message.id, key, action)
|
||||
},
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments — dispatched through the unified InboundAttachmentCard
|
||||
// so outbound and inbound attachments share the same render pipeline.
|
||||
// Outbound attachments (user-authored) always have state=LOADED so
|
||||
@@ -204,6 +283,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Row (bubble + optional leading accent bar)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,150 +13,33 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* ASCII morphing sphere — the visual embodiment of the AI agent.
|
||||
*
|
||||
* Inspired by Amp Code's Supernova orb. Renders a sphere from monospace
|
||||
* characters with layered procedural effects driven by [SphereState]:
|
||||
* characters with layered procedural effects. Algorithm lives in
|
||||
* [forEachSphereCell] (see `MorphingSphereCore.kt`) so the same math powers
|
||||
* Android here, the JS browser preview in `preview/web/`, and any future
|
||||
* renderer (Compose Desktop, terminal TUI).
|
||||
*
|
||||
* - **Hybrid brightness**: concentric distance-based zones + orbiting directional
|
||||
* light that shifts the highlight across the surface ("the eye")
|
||||
* - **Dual noise**: structural FBM (slow undulation) + turbulence (fast shimmer)
|
||||
* - **Breathing radius**: slow expand/contract
|
||||
* - **Core heartbeat**: brightness throb near center
|
||||
* - **Radial flow**: outward energy drift
|
||||
* - **Ripple waves**: concentric brightness rings radiating outward
|
||||
* - **State-driven colors**: palette shifts per agent state
|
||||
*
|
||||
* Pure Compose Canvas — no OpenGL, no external libraries.
|
||||
* This file is the Android/Compose renderer only — it owns animation state
|
||||
* (`animateFloatAsState`, `rememberInfiniteTransition`) and text drawing.
|
||||
*/
|
||||
|
||||
/** Agent visual state — controls animation parameters and color palette. */
|
||||
enum class SphereState {
|
||||
/** Calm breathing, slow wandering eye, gentle ripples. Present, waiting. */
|
||||
Idle,
|
||||
/** Faster pulse, tighter core, rapid eye scanning. Processing. */
|
||||
Thinking,
|
||||
/** Energy radiates outward, strong ripples, focused eye. Speaking. */
|
||||
Streaming,
|
||||
/** Voice mode — listening to user. Cool palette, subtle amplitude-driven motion. */
|
||||
Listening,
|
||||
/** Voice mode — speaking to user. Warm core, dramatic amplitude-driven motion. */
|
||||
Speaking,
|
||||
/** Red shift, erratic motion. Something wrong. */
|
||||
Error
|
||||
}
|
||||
|
||||
// ── State parameter system ───────────────────────────────────────────
|
||||
|
||||
private data class SphereParams(
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float
|
||||
)
|
||||
|
||||
private data class SphereColors(
|
||||
val r1: Float, val g1: Float, val b1: Float, // color pole 1
|
||||
val r2: Float, val g2: Float, val b2: Float // color pole 2
|
||||
)
|
||||
|
||||
private fun paramsFor(state: SphereState) = when (state) {
|
||||
SphereState.Idle -> SphereParams(
|
||||
breatheSpeed = 0.5f, breatheAmp = 0.04f,
|
||||
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
|
||||
coreTightness = 0.75f, turbulenceAmp = 0.06f,
|
||||
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
|
||||
)
|
||||
SphereState.Thinking -> SphereParams(
|
||||
breatheSpeed = 0.8f, breatheAmp = 0.02f,
|
||||
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.90f, turbulenceAmp = 0.12f,
|
||||
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
|
||||
)
|
||||
SphereState.Streaming -> SphereParams(
|
||||
breatheSpeed = 0.3f, breatheAmp = 0.06f,
|
||||
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
|
||||
coreTightness = 0.60f, turbulenceAmp = 0.08f,
|
||||
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
|
||||
)
|
||||
SphereState.Listening -> SphereParams(
|
||||
// Calm base — voiceAmplitude modulates on top (see render loop).
|
||||
breatheSpeed = 0.55f, breatheAmp = 0.035f,
|
||||
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
|
||||
coreTightness = 0.78f, turbulenceAmp = 0.05f,
|
||||
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
|
||||
)
|
||||
SphereState.Speaking -> SphereParams(
|
||||
// Assertive base — amplitude pushes it dramatically further.
|
||||
breatheSpeed = 0.45f, breatheAmp = 0.05f,
|
||||
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.55f, turbulenceAmp = 0.07f,
|
||||
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
|
||||
)
|
||||
SphereState.Error -> SphereParams(
|
||||
breatheSpeed = 1.2f, breatheAmp = 0.03f,
|
||||
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
|
||||
coreTightness = 0.80f, turbulenceAmp = 0.15f,
|
||||
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
private fun colorsFor(state: SphereState) = when (state) {
|
||||
SphereState.Idle -> SphereColors(
|
||||
0.25f, 0.85f, 0.40f, // green
|
||||
0.61f, 0.42f, 0.94f // purple
|
||||
)
|
||||
SphereState.Thinking -> SphereColors(
|
||||
0.30f, 0.55f, 0.95f, // blue
|
||||
0.55f, 0.35f, 0.90f // purple
|
||||
)
|
||||
SphereState.Streaming -> SphereColors(
|
||||
0.20f, 0.90f, 0.50f, // green
|
||||
0.25f, 0.80f, 0.85f // teal
|
||||
)
|
||||
SphereState.Listening -> SphereColors(
|
||||
// Cool soft blue/purple — cooler than Idle's green/purple.
|
||||
0.35f, 0.55f, 0.95f, // #597EF2 soft blue
|
||||
0.65f, 0.45f, 0.95f // #A573F2 soft purple
|
||||
)
|
||||
SphereState.Speaking -> SphereColors(
|
||||
// Vibrant green/teal — same family as Streaming but punchier.
|
||||
// The render loop pushes core toward white as amplitude peaks.
|
||||
0.25f, 0.92f, 0.55f, // #40EB8C vivid green
|
||||
0.30f, 0.85f, 0.88f // #4DD9E0 teal
|
||||
)
|
||||
SphereState.Error -> SphereColors(
|
||||
0.90f, 0.30f, 0.25f, // red
|
||||
0.85f, 0.50f, 0.20f // orange
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main composable ──────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -168,66 +51,28 @@ fun MorphingSphere(
|
||||
fixedTime: Float? = null,
|
||||
fixedColorPhase: Float? = null
|
||||
) {
|
||||
// Clamp amplitude once — downstream math assumes 0..1.
|
||||
val amp = voiceAmplitude.coerceIn(0f, 1f)
|
||||
|
||||
// ── Animated state parameters (smooth 800ms transitions) ─────
|
||||
val targetP = remember(state) { paramsFor(state) }
|
||||
val targetC = remember(state) { colorsFor(state) }
|
||||
val spec = tween<Float>(800, easing = FastOutSlowInEasing)
|
||||
|
||||
// ── voiceMode expansion scalar ───────────────────────────────
|
||||
// 1.0 = normal, ~1.08 = expanded (bounded to avoid data-ring overflow).
|
||||
val voiceRadiusScale by animateFloatAsState(
|
||||
targetValue = if (voiceMode) 1.08f else 1.0f,
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "voiceExpand"
|
||||
)
|
||||
|
||||
val baseBreatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
|
||||
val breatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
|
||||
val breatheAmp by animateFloatAsState(targetP.breatheAmp, spec, label = "bAmp")
|
||||
val lightSpeedX by animateFloatAsState(targetP.lightSpeedX, spec, label = "lsX")
|
||||
val lightSpeedY by animateFloatAsState(targetP.lightSpeedY, spec, label = "lsY")
|
||||
val lightInfluence by animateFloatAsState(targetP.lightInfluence, spec, label = "lInf")
|
||||
val coreTightness by animateFloatAsState(targetP.coreTightness, spec, label = "core")
|
||||
val baseTurbulence by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
|
||||
val turbulenceAmp by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
|
||||
val rippleScale by animateFloatAsState(targetP.rippleScale, spec, label = "rip")
|
||||
val heartbeatSpeed by animateFloatAsState(targetP.heartbeatSpeed, spec, label = "hb")
|
||||
val radialFlowSpeed by animateFloatAsState(targetP.radialFlowSpeed, spec, label = "rf")
|
||||
|
||||
// ── Voice amplitude modulation ────────────────────────────────
|
||||
// Listening = subtle (≤30% boost); Speaking = dramatic (up to 3×).
|
||||
// Idle/Thinking/Streaming/Error ignore amplitude — existing behavior preserved.
|
||||
val breatheSpeed = when (state) {
|
||||
SphereState.Listening -> lerp(baseBreatheSpeed, baseBreatheSpeed * 1.3f, amp * 0.5f)
|
||||
SphereState.Speaking -> lerp(baseBreatheSpeed, baseBreatheSpeed * 2.0f, amp)
|
||||
else -> baseBreatheSpeed
|
||||
}
|
||||
val turbulenceAmp = when (state) {
|
||||
SphereState.Listening -> baseTurbulence + amp * 0.15f
|
||||
SphereState.Speaking -> baseTurbulence + amp * 0.5f
|
||||
else -> baseTurbulence
|
||||
}
|
||||
// Core warmth: 0.30 is the existing constant baked into the render loop's
|
||||
// warmth term (see line where `warmth = (1f - normDist^2) * 0.12f` is mixed).
|
||||
// Speaking pushes this multiplier from 0.3 → 1.0 as amplitude rises, driving
|
||||
// the core bright→white. Listening holds at 0.3 (no change vs. other states).
|
||||
val coreWarmth = when (state) {
|
||||
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
|
||||
else -> 0.30f
|
||||
}
|
||||
// Perimeter wobble — existing code uses a fixed 0.06 multiplier.
|
||||
val wobbleAmplitude = when (state) {
|
||||
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
|
||||
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
|
||||
else -> 0.06f
|
||||
}
|
||||
// Data ring orbit speed — existing code uses `t * 0.4f`.
|
||||
val dataRingSpeed = when (state) {
|
||||
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
|
||||
else -> 0.4f
|
||||
}
|
||||
|
||||
val cr1 by animateFloatAsState(targetC.r1, spec, label = "cr1")
|
||||
val cg1 by animateFloatAsState(targetC.g1, spec, label = "cg1")
|
||||
val cb1 by animateFloatAsState(targetC.b1, spec, label = "cb1")
|
||||
@@ -235,7 +80,6 @@ fun MorphingSphere(
|
||||
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
|
||||
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
|
||||
|
||||
// ── Continuous time animations ──────────────────────────────
|
||||
val transition = rememberInfiniteTransition(label = "sphere")
|
||||
val animatedTime by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
@@ -259,25 +103,11 @@ fun MorphingSphere(
|
||||
val time = fixedTime ?: animatedTime
|
||||
val colorPhase = fixedColorPhase ?: animatedColorPhase
|
||||
|
||||
// Multiple character sets that rotate over time for surface "activity"
|
||||
val charSets = arrayOf(
|
||||
" ·:;=+*#%@", // technical dots
|
||||
" .:;=+*#%@", // classic with semicolons
|
||||
" ·;:+=*%#@", // shuffled mid-range
|
||||
" .:;+=*#@%" // variant ordering
|
||||
)
|
||||
// Data ring characters (orbit the sphere like processing data)
|
||||
val dataChars = "01<>[]{}|/\\~^"
|
||||
|
||||
val cols = 58
|
||||
val rows = 34
|
||||
|
||||
val paint = remember {
|
||||
Paint().apply {
|
||||
typeface = Typeface.MONOSPACE
|
||||
isAntiAlias = true
|
||||
}
|
||||
}
|
||||
// Cache covers the ~25 distinct glyphs across charSets/dataChars/debrisChars.
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
val canvasW = size.width
|
||||
@@ -285,277 +115,43 @@ fun MorphingSphere(
|
||||
val cellW = canvasW / cols
|
||||
val cellH = canvasH / rows
|
||||
val charSize = (cellW * 1.3f).coerceAtMost(cellH * 1.1f)
|
||||
paint.textSize = charSize
|
||||
|
||||
val cx = cols / 2f
|
||||
val cy = rows / 2f
|
||||
val charAspect = cellW / cellH
|
||||
|
||||
// Reduced from 0.72 so data ring (1.55x) fits within grid.
|
||||
// voiceRadiusScale is ~1.08 in voiceMode, 1.0 otherwise — bounded so the
|
||||
// data ring outer edge (1.55x) still stays within the drawable region.
|
||||
val maxRadiusFromRows = (rows / 2f) * 0.60f
|
||||
val maxRadiusFromCols = (cols / 2f) * charAspect * 0.60f
|
||||
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * voiceRadiusScale
|
||||
val t = time
|
||||
val style = TextStyle(
|
||||
fontSize = charSize.toSp(),
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
|
||||
// ── Breathing ────────────────────────────────────────────
|
||||
val breathe = sin(t * breatheSpeed) * breatheAmp
|
||||
val breathingRadius = baseRadius * (1f + breathe)
|
||||
val frame = SphereFrame(
|
||||
cols = cols, rows = rows, charAspect = charAspect,
|
||||
state = state, time = time, colorPhase = colorPhase,
|
||||
breatheSpeed = breatheSpeed, breatheAmp = breatheAmp,
|
||||
lightSpeedX = lightSpeedX, lightSpeedY = lightSpeedY,
|
||||
lightInfluence = lightInfluence, coreTightness = coreTightness,
|
||||
turbulenceAmp = turbulenceAmp, rippleScale = rippleScale,
|
||||
heartbeatSpeed = heartbeatSpeed, radialFlowSpeed = radialFlowSpeed,
|
||||
cr1 = cr1, cg1 = cg1, cb1 = cb1,
|
||||
cr2 = cr2, cg2 = cg2, cb2 = cb2,
|
||||
intensity = intensity, toolCallBurst = toolCallBurst,
|
||||
voiceAmplitude = amp, voiceMode = voiceMode,
|
||||
voiceRadiusScale = voiceRadiusScale
|
||||
)
|
||||
|
||||
// ── Orbiting directional light ("the eye") ──────────────
|
||||
// Lissajous orbit (different X/Y speeds) + noise jitter
|
||||
// for organic, non-repeating path. lx/ly at ±0.65 creates
|
||||
// strong enough asymmetry that the highlight visibly shifts.
|
||||
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
|
||||
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
|
||||
val lightAngle1 = t * lightSpeedX + noiseJitter1
|
||||
val lightAngle2 = t * lightSpeedY + noiseJitter2
|
||||
val lx = sin(lightAngle1) * 0.65f
|
||||
val ly = cos(lightAngle2) * 0.65f
|
||||
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
|
||||
|
||||
// ── Core heartbeat ──────────────────────────────────────
|
||||
val heartbeat = sin(t * heartbeatSpeed) * 0.5f + 0.5f
|
||||
|
||||
// ── Color palette (animated poles + phase oscillation) ───
|
||||
val pulse = sin(colorPhase) * 0.5f + 0.5f
|
||||
val colR = lerp(cr1, cr2, pulse)
|
||||
val colG = lerp(cg1, cg2, pulse)
|
||||
val colB = lerp(cb1, cb2, pulse)
|
||||
|
||||
val distWeight = 1f - lightInfluence
|
||||
|
||||
// Intensity/tool call modulation of state params
|
||||
val effTurbulence = turbulenceAmp + intensity * 0.04f + toolCallBurst * 0.15f
|
||||
val effRadialFlow = radialFlowSpeed + intensity * 0.3f
|
||||
val effRipple = rippleScale + intensity * 0.5f + toolCallBurst * 1.0f
|
||||
|
||||
for (row in 0 until rows) {
|
||||
for (col in 0 until cols) {
|
||||
val dx = (col - cx) * charAspect
|
||||
val dy = (row - cy)
|
||||
val dist = sqrt(dx * dx + dy * dy)
|
||||
val angle = atan2(dy, dx)
|
||||
|
||||
// ── Perimeter (subtle 6% wobble — amplified by voice) ───
|
||||
val perimeterNoise = fbm(
|
||||
angle * 1.8f + t * 0.08f,
|
||||
angle * 0.7f + t * 0.12f
|
||||
) * 2f - 1f
|
||||
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
|
||||
val glowRadius = distortedRadius * 1.35f
|
||||
val dataRingInner = distortedRadius * 1.40f
|
||||
val dataRingOuter = distortedRadius * 1.55f
|
||||
val normDist = dist / distortedRadius
|
||||
|
||||
if (dist > dataRingOuter) continue
|
||||
|
||||
val px = col * cellW
|
||||
val py = row * cellH + cellH * 0.8f
|
||||
|
||||
if (normDist <= 1f) {
|
||||
// ── INSIDE SPHERE ────────────────────────────
|
||||
|
||||
// Surface normal
|
||||
val nx = dx / distortedRadius
|
||||
val ny2 = dy / distortedRadius
|
||||
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
|
||||
val nz = sqrt(nzSq)
|
||||
|
||||
// Distance-based brightness (concentric zones)
|
||||
val distBrightness = (1f - normDist * normDist * coreTightness)
|
||||
.coerceAtLeast(0.15f)
|
||||
|
||||
// Directional light (shifts highlight across surface)
|
||||
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
// Structural noise (slow undulation)
|
||||
val structural = fbm(
|
||||
col * 0.25f + t * 0.18f,
|
||||
row * 0.25f + t * 0.13f,
|
||||
octaves = 2
|
||||
) * 0.15f - 0.075f
|
||||
|
||||
// Turbulence (fast shimmer, boosted by intensity + tool calls)
|
||||
val turbulence = fbm(
|
||||
col * 0.8f + t * 0.6f,
|
||||
row * 0.8f + t * 0.45f,
|
||||
octaves = 2
|
||||
) * effTurbulence - effTurbulence * 0.5f
|
||||
|
||||
// Radial flow (outward energy drift, faster when streaming)
|
||||
val radialFlow = fbm(
|
||||
angle * 2f + t * 0.15f,
|
||||
dist * 0.3f - t * effRadialFlow,
|
||||
octaves = 2
|
||||
) * 0.06f - 0.03f
|
||||
|
||||
// Ripple waves (stronger during streaming/tool calls)
|
||||
val ripple = (
|
||||
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
|
||||
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
|
||||
) * effRipple
|
||||
|
||||
// Core heartbeat (subtle glow, concentrated at center)
|
||||
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
|
||||
|
||||
// ── Hybrid brightness ────────────────────────
|
||||
val brightness = distWeight * distBrightness +
|
||||
lightInfluence * directionalLight +
|
||||
heartbeatFx
|
||||
val charNoise = structural + turbulence + radialFlow + ripple
|
||||
|
||||
// Character rotation: cycle through char sets over time
|
||||
// Each cell picks a set based on position + time, creating
|
||||
// surface "activity" where characters shift independently
|
||||
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
|
||||
val chars = charSets[rotationPhase.and(3)] // mod 4 via bitmask
|
||||
|
||||
val charIdx = ((brightness + charNoise) * (chars.length - 1))
|
||||
.toInt().coerceIn(1, chars.length - 1)
|
||||
val ch = chars[charIdx]
|
||||
|
||||
// Edge fade (quadratic, starts at 0.80)
|
||||
val edgeFade = when {
|
||||
normDist > 0.80f -> {
|
||||
val ef = (normDist - 0.80f) / 0.20f
|
||||
1f - ef * ef
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
// Scanline: dimming on odd rows (CRT/holographic feel)
|
||||
val scanline = if (row % 2 == 1) 0.82f else 1f
|
||||
|
||||
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
|
||||
.coerceIn(0.1f, 1f)
|
||||
|
||||
// Core warmth (center bleeds towards white).
|
||||
// coreWarmth is 0.30 for all non-voice states (→ 0.12 multiplier,
|
||||
// the historical value) and scales up to 1.0 when Speaking peaks.
|
||||
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
|
||||
val lightBoost = directionalLight * 0.08f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
((colR + lightBoost + warmth) * 255).toInt().coerceIn(0, 255),
|
||||
((colG + lightBoost * 0.5f + warmth) * 255).toInt().coerceIn(0, 255),
|
||||
((colB + lightBoost + warmth) * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
|
||||
} else if (dist <= glowRadius) {
|
||||
// ── GLOW / DEBRIS ZONE ───────────────────────
|
||||
|
||||
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
|
||||
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
|
||||
|
||||
val sparsityNoise = fbm(
|
||||
angle * 3.5f + t * 0.25f,
|
||||
dist * 0.4f + t * 0.08f,
|
||||
octaves = 2
|
||||
)
|
||||
val sparsityThreshold = 0.35f + glowT * 0.25f
|
||||
if (sparsityNoise < sparsityThreshold) continue
|
||||
|
||||
val debrisChars = "·:;- "
|
||||
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
|
||||
.toInt().coerceIn(0, debrisChars.length - 1)
|
||||
val ch = debrisChars[debrisIdx]
|
||||
if (ch == ' ') continue
|
||||
|
||||
val alpha = glowFalloff * 0.85f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
(colR * 255).toInt().coerceIn(0, 255),
|
||||
(colG * 255).toInt().coerceIn(0, 255),
|
||||
(colB * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
|
||||
} else if (dist >= dataRingInner) {
|
||||
// ── DATA RING ────────────────────────────────
|
||||
// Sparse orbiting characters like processing data.
|
||||
// Angle offset by time = characters appear to orbit.
|
||||
|
||||
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
|
||||
|
||||
// Orbiting: offset angle by time (different layers at different speeds).
|
||||
// dataRingSpeed is 0.4 default, spun up to ~1.6 at Speaking peak.
|
||||
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
|
||||
// Sparsity: only render ~15% of ring positions
|
||||
val ringNoise = fbm(
|
||||
orbitAngle * 4f + t * 0.3f,
|
||||
ringT * 3f + t * 0.15f,
|
||||
octaves = 2
|
||||
)
|
||||
if (ringNoise < 0.55f) continue
|
||||
|
||||
// Pick character from data set, cycling with orbit
|
||||
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
|
||||
.toInt().mod(dataChars.length)
|
||||
val ch = dataChars[dataIdx]
|
||||
|
||||
// Fade: bright at inner edge, fading outward
|
||||
val ringFade = (1f - ringT).coerceIn(0f, 1f)
|
||||
val alpha = ringFade * 0.65f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
(colR * 0.85f * 255).toInt().coerceIn(0, 255),
|
||||
(colG * 0.85f * 255).toInt().coerceIn(0, 255),
|
||||
(colB * 0.85f * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
}
|
||||
}
|
||||
forEachSphereCell(frame) { cell ->
|
||||
val layout = textMeasurer.measure(cell.char.toString(), style)
|
||||
// Legacy Paint used y as baseline (`row*cellH + cellH*0.8f`).
|
||||
// Compose `drawText` uses top-left — offset by firstBaseline to match.
|
||||
val px = cell.col * cellW
|
||||
val py = cell.row * cellH + cellH * 0.8f - layout.firstBaseline
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
color = Color(cell.r, cell.g, cell.b, cell.alpha),
|
||||
topLeft = Offset(px, py)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Procedural noise ─────────────────────────────────────────────────
|
||||
|
||||
private fun hash(x: Int, y: Int): Float {
|
||||
var h = x * 374761393 + y * 668265263
|
||||
h = (h xor (h ushr 13)) * 1274126177
|
||||
h = h xor (h ushr 16)
|
||||
return (h and 0x7fffffff) / 2147483647f
|
||||
}
|
||||
|
||||
private fun smoothNoise(x: Float, y: Float): Float {
|
||||
val xi = floor(x).toInt()
|
||||
val yi = floor(y).toInt()
|
||||
val xf = x - xi
|
||||
val yf = y - yi
|
||||
val u = xf * xf * (3f - 2f * xf)
|
||||
val v = yf * yf * (3f - 2f * yf)
|
||||
val n00 = hash(xi, yi)
|
||||
val n10 = hash(xi + 1, yi)
|
||||
val n01 = hash(xi, yi + 1)
|
||||
val n11 = hash(xi + 1, yi + 1)
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
|
||||
}
|
||||
|
||||
private fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
|
||||
var value = 0f
|
||||
var amplitude = 0.5f
|
||||
var frequency = 1f
|
||||
for (i in 0 until octaves) {
|
||||
value += amplitude * smoothNoise(x * frequency, y * frequency)
|
||||
amplitude *= 0.5f
|
||||
frequency *= 2f
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
|
||||
|
||||
// ── Previews ─────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "Idle", showBackground = true, backgroundColor = 0xFF0D0D0D, widthDp = 360, heightDp = 640)
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Pure, platform-agnostic core of the ASCII morphing sphere.
|
||||
*
|
||||
* No Android, no Compose — only `kotlin.math`. Intended as the single source of
|
||||
* truth for the sphere algorithm. Android renders via Compose Canvas; a JS port
|
||||
* in `preview/web/` mirrors this file for browser iteration; future renderers
|
||||
* (Compose Desktop, terminal TUI) can call this same core.
|
||||
*/
|
||||
|
||||
/** Agent visual state — controls animation parameters and color palette. */
|
||||
enum class SphereState {
|
||||
Idle,
|
||||
Thinking,
|
||||
Streaming,
|
||||
Listening,
|
||||
Speaking,
|
||||
Error
|
||||
}
|
||||
|
||||
/** Animated parameter bundle — interpolated by the caller for smooth state transitions. */
|
||||
data class SphereParams(
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float
|
||||
)
|
||||
|
||||
/** Two color poles mixed by a time-varying phase. */
|
||||
data class SphereColors(
|
||||
val r1: Float, val g1: Float, val b1: Float,
|
||||
val r2: Float, val g2: Float, val b2: Float
|
||||
)
|
||||
|
||||
fun paramsFor(state: SphereState): SphereParams = when (state) {
|
||||
SphereState.Idle -> SphereParams(
|
||||
breatheSpeed = 0.5f, breatheAmp = 0.04f,
|
||||
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
|
||||
coreTightness = 0.75f, turbulenceAmp = 0.06f,
|
||||
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
|
||||
)
|
||||
SphereState.Thinking -> SphereParams(
|
||||
breatheSpeed = 0.8f, breatheAmp = 0.02f,
|
||||
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.90f, turbulenceAmp = 0.12f,
|
||||
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
|
||||
)
|
||||
SphereState.Streaming -> SphereParams(
|
||||
breatheSpeed = 0.3f, breatheAmp = 0.06f,
|
||||
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
|
||||
coreTightness = 0.60f, turbulenceAmp = 0.08f,
|
||||
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
|
||||
)
|
||||
SphereState.Listening -> SphereParams(
|
||||
breatheSpeed = 0.55f, breatheAmp = 0.035f,
|
||||
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
|
||||
coreTightness = 0.78f, turbulenceAmp = 0.05f,
|
||||
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
|
||||
)
|
||||
SphereState.Speaking -> SphereParams(
|
||||
breatheSpeed = 0.45f, breatheAmp = 0.05f,
|
||||
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.55f, turbulenceAmp = 0.07f,
|
||||
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
|
||||
)
|
||||
SphereState.Error -> SphereParams(
|
||||
breatheSpeed = 1.2f, breatheAmp = 0.03f,
|
||||
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
|
||||
coreTightness = 0.80f, turbulenceAmp = 0.15f,
|
||||
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
fun colorsFor(state: SphereState): SphereColors = when (state) {
|
||||
SphereState.Idle -> SphereColors(
|
||||
0.25f, 0.85f, 0.40f,
|
||||
0.61f, 0.42f, 0.94f
|
||||
)
|
||||
SphereState.Thinking -> SphereColors(
|
||||
0.30f, 0.55f, 0.95f,
|
||||
0.55f, 0.35f, 0.90f
|
||||
)
|
||||
SphereState.Streaming -> SphereColors(
|
||||
0.20f, 0.90f, 0.50f,
|
||||
0.25f, 0.80f, 0.85f
|
||||
)
|
||||
SphereState.Listening -> SphereColors(
|
||||
0.35f, 0.55f, 0.95f,
|
||||
0.65f, 0.45f, 0.95f
|
||||
)
|
||||
SphereState.Speaking -> SphereColors(
|
||||
0.25f, 0.92f, 0.55f,
|
||||
0.30f, 0.85f, 0.88f
|
||||
)
|
||||
SphereState.Error -> SphereColors(
|
||||
0.90f, 0.30f, 0.25f,
|
||||
0.85f, 0.50f, 0.20f
|
||||
)
|
||||
}
|
||||
|
||||
/** All inputs needed to render one frame — populated by the caller from animation state. */
|
||||
data class SphereFrame(
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
val charAspect: Float,
|
||||
val state: SphereState,
|
||||
val time: Float,
|
||||
val colorPhase: Float,
|
||||
// Animated base params (caller smooths transitions)
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float,
|
||||
// Animated colors
|
||||
val cr1: Float, val cg1: Float, val cb1: Float,
|
||||
val cr2: Float, val cg2: Float, val cb2: Float,
|
||||
// Intensity + modulation
|
||||
val intensity: Float,
|
||||
val toolCallBurst: Float,
|
||||
val voiceAmplitude: Float,
|
||||
val voiceMode: Boolean,
|
||||
val voiceRadiusScale: Float,
|
||||
// Gaze bias — lets callers aim the sphere's "eye" (bright spot) at a
|
||||
// specific direction without moving the sphere body. `lightAngleBlend`
|
||||
// blends between natural rotation (0) and the bias override (1).
|
||||
// Defaults keep the original behavior for every existing caller.
|
||||
val lightAngleBiasX: Float = 0f,
|
||||
val lightAngleBiasY: Float = 0f,
|
||||
val lightAngleBlend: Float = 0f,
|
||||
// Shadow contrast — darkens distBrightness on the hemisphere facing away
|
||||
// from the light, making the bright spot ("eye") clearly distinguishable
|
||||
// from the shadow side. 0 = uniform pearl shading (legacy behavior, full
|
||||
// parity preserved); 1 = shadow side fully uses directionalLight to scale
|
||||
// the distBrightness (strong Lambertian contrast).
|
||||
val shadowStrength: Float = 0f
|
||||
)
|
||||
|
||||
/** What to draw at one grid cell. RGB + alpha in 0..1. */
|
||||
data class SphereCell(
|
||||
val col: Int,
|
||||
val row: Int,
|
||||
val char: Char,
|
||||
val r: Float,
|
||||
val g: Float,
|
||||
val b: Float,
|
||||
val alpha: Float
|
||||
)
|
||||
|
||||
private val charSets = arrayOf(
|
||||
" ·:;=+*#%@",
|
||||
" .:;=+*#%@",
|
||||
" ·;:+=*%#@",
|
||||
" .:;+=*#@%"
|
||||
)
|
||||
private const val dataChars = "01<>[]{}|/\\~^"
|
||||
|
||||
/**
|
||||
* Iterates the `rows × cols` grid for one frame and invokes `onCell` for every
|
||||
* cell that should be drawn. Cells outside the drawable region (or excluded by
|
||||
* sparsity) are silently skipped — the callback sees only visible glyphs.
|
||||
*/
|
||||
fun forEachSphereCell(frame: SphereFrame, onCell: (SphereCell) -> Unit) {
|
||||
val amp = frame.voiceAmplitude.coerceIn(0f, 1f)
|
||||
|
||||
// ── Voice modulation of animated base params ─────────────────
|
||||
val breatheSpeed = when (frame.state) {
|
||||
SphereState.Listening -> lerp(frame.breatheSpeed, frame.breatheSpeed * 1.3f, amp * 0.5f)
|
||||
SphereState.Speaking -> lerp(frame.breatheSpeed, frame.breatheSpeed * 2.0f, amp)
|
||||
else -> frame.breatheSpeed
|
||||
}
|
||||
val turbulenceAmp = when (frame.state) {
|
||||
SphereState.Listening -> frame.turbulenceAmp + amp * 0.15f
|
||||
SphereState.Speaking -> frame.turbulenceAmp + amp * 0.5f
|
||||
else -> frame.turbulenceAmp
|
||||
}
|
||||
val coreWarmth = when (frame.state) {
|
||||
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
|
||||
else -> 0.30f
|
||||
}
|
||||
val wobbleAmplitude = when (frame.state) {
|
||||
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
|
||||
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
|
||||
else -> 0.06f
|
||||
}
|
||||
val dataRingSpeed = when (frame.state) {
|
||||
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
|
||||
else -> 0.4f
|
||||
}
|
||||
|
||||
val cx = frame.cols / 2f
|
||||
val cy = frame.rows / 2f
|
||||
val charAspect = frame.charAspect
|
||||
|
||||
// Matches legacy 0.60 envelope so the data ring (1.55×) fits the grid.
|
||||
val maxRadiusFromRows = (frame.rows / 2f) * 0.60f
|
||||
val maxRadiusFromCols = (frame.cols / 2f) * charAspect * 0.60f
|
||||
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * frame.voiceRadiusScale
|
||||
val t = frame.time
|
||||
|
||||
val breathe = sin(t * breatheSpeed) * frame.breatheAmp
|
||||
val breathingRadius = baseRadius * (1f + breathe)
|
||||
|
||||
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
|
||||
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
|
||||
val naturalAngle1 = t * frame.lightSpeedX + noiseJitter1
|
||||
val naturalAngle2 = t * frame.lightSpeedY + noiseJitter2
|
||||
val blend = frame.lightAngleBlend.coerceIn(0f, 1f)
|
||||
val lightAngle1 = naturalAngle1 * (1f - blend) + frame.lightAngleBiasX * blend
|
||||
val lightAngle2 = naturalAngle2 * (1f - blend) + frame.lightAngleBiasY * blend
|
||||
val lx = sin(lightAngle1) * 0.65f
|
||||
val ly = cos(lightAngle2) * 0.65f
|
||||
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
|
||||
|
||||
val heartbeat = sin(t * frame.heartbeatSpeed) * 0.5f + 0.5f
|
||||
|
||||
val pulse = sin(frame.colorPhase) * 0.5f + 0.5f
|
||||
val colR = lerp(frame.cr1, frame.cr2, pulse)
|
||||
val colG = lerp(frame.cg1, frame.cg2, pulse)
|
||||
val colB = lerp(frame.cb1, frame.cb2, pulse)
|
||||
|
||||
val distWeight = 1f - frame.lightInfluence
|
||||
|
||||
val effTurbulence = turbulenceAmp + frame.intensity * 0.04f + frame.toolCallBurst * 0.15f
|
||||
val effRadialFlow = frame.radialFlowSpeed + frame.intensity * 0.3f
|
||||
val effRipple = frame.rippleScale + frame.intensity * 0.5f + frame.toolCallBurst * 1.0f
|
||||
|
||||
for (row in 0 until frame.rows) {
|
||||
for (col in 0 until frame.cols) {
|
||||
val dx = (col - cx) * charAspect
|
||||
val dy = (row - cy)
|
||||
val dist = sqrt(dx * dx + dy * dy)
|
||||
val angle = atan2(dy, dx)
|
||||
|
||||
val perimeterNoise = fbm(
|
||||
angle * 1.8f + t * 0.08f,
|
||||
angle * 0.7f + t * 0.12f
|
||||
) * 2f - 1f
|
||||
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
|
||||
val glowRadius = distortedRadius * 1.35f
|
||||
val dataRingInner = distortedRadius * 1.40f
|
||||
val dataRingOuter = distortedRadius * 1.55f
|
||||
val normDist = dist / distortedRadius
|
||||
|
||||
if (dist > dataRingOuter) continue
|
||||
|
||||
if (normDist <= 1f) {
|
||||
// ── INSIDE SPHERE ────────────────────────────
|
||||
val nx = dx / distortedRadius
|
||||
val ny2 = dy / distortedRadius
|
||||
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
|
||||
val nz = sqrt(nzSq)
|
||||
|
||||
val distBrightness = (1f - normDist * normDist * frame.coreTightness)
|
||||
.coerceAtLeast(0.15f)
|
||||
|
||||
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
val structural = fbm(
|
||||
col * 0.25f + t * 0.18f,
|
||||
row * 0.25f + t * 0.13f,
|
||||
octaves = 2
|
||||
) * 0.15f - 0.075f
|
||||
|
||||
val turbulence = fbm(
|
||||
col * 0.8f + t * 0.6f,
|
||||
row * 0.8f + t * 0.45f,
|
||||
octaves = 2
|
||||
) * effTurbulence - effTurbulence * 0.5f
|
||||
|
||||
val radialFlow = fbm(
|
||||
angle * 2f + t * 0.15f,
|
||||
dist * 0.3f - t * effRadialFlow,
|
||||
octaves = 2
|
||||
) * 0.06f - 0.03f
|
||||
|
||||
val ripple = (
|
||||
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
|
||||
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
|
||||
) * effRipple
|
||||
|
||||
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
|
||||
|
||||
// Shadow modulation — leave distBrightness alone on the lit
|
||||
// side (directionalLight=1 → factor=1), dim it on the shadow
|
||||
// side (directionalLight=0 → factor = 1 - shadowStrength).
|
||||
val shadowFactor = 1f - frame.shadowStrength * (1f - directionalLight)
|
||||
val brightness = distWeight * distBrightness * shadowFactor +
|
||||
frame.lightInfluence * directionalLight +
|
||||
heartbeatFx
|
||||
val charNoise = structural + turbulence + radialFlow + ripple
|
||||
|
||||
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
|
||||
val chars = charSets[rotationPhase.and(3)]
|
||||
|
||||
val charIdx = ((brightness + charNoise) * (chars.length - 1))
|
||||
.toInt().coerceIn(1, chars.length - 1)
|
||||
val ch = chars[charIdx]
|
||||
|
||||
val edgeFade = when {
|
||||
normDist > 0.80f -> {
|
||||
val ef = (normDist - 0.80f) / 0.20f
|
||||
1f - ef * ef
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
val scanline = if (row % 2 == 1) 0.82f else 1f
|
||||
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
|
||||
.coerceIn(0.1f, 1f)
|
||||
|
||||
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
|
||||
val lightBoost = directionalLight * 0.08f
|
||||
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = (colR + lightBoost + warmth).coerceIn(0f, 1f),
|
||||
g = (colG + lightBoost * 0.5f + warmth).coerceIn(0f, 1f),
|
||||
b = (colB + lightBoost + warmth).coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
} else if (dist <= glowRadius) {
|
||||
// ── GLOW / DEBRIS ZONE ───────────────────────
|
||||
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
|
||||
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
|
||||
|
||||
val sparsityNoise = fbm(
|
||||
angle * 3.5f + t * 0.25f,
|
||||
dist * 0.4f + t * 0.08f,
|
||||
octaves = 2
|
||||
)
|
||||
val sparsityThreshold = 0.35f + glowT * 0.25f
|
||||
if (sparsityNoise < sparsityThreshold) continue
|
||||
|
||||
val debrisChars = "·:;- "
|
||||
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
|
||||
.toInt().coerceIn(0, debrisChars.length - 1)
|
||||
val ch = debrisChars[debrisIdx]
|
||||
if (ch == ' ') continue
|
||||
|
||||
val alpha = glowFalloff * 0.85f
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = colR.coerceIn(0f, 1f),
|
||||
g = colG.coerceIn(0f, 1f),
|
||||
b = colB.coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
} else if (dist >= dataRingInner) {
|
||||
// ── DATA RING ────────────────────────────────
|
||||
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
|
||||
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
|
||||
val ringNoise = fbm(
|
||||
orbitAngle * 4f + t * 0.3f,
|
||||
ringT * 3f + t * 0.15f,
|
||||
octaves = 2
|
||||
)
|
||||
if (ringNoise < 0.55f) continue
|
||||
|
||||
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
|
||||
.toInt().mod(dataChars.length)
|
||||
val ch = dataChars[dataIdx]
|
||||
|
||||
val ringFade = (1f - ringT).coerceIn(0f, 1f)
|
||||
val alpha = ringFade * 0.65f
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = (colR * 0.85f).coerceIn(0f, 1f),
|
||||
g = (colG * 0.85f).coerceIn(0f, 1f),
|
||||
b = (colB * 0.85f).coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Procedural noise (public so renderers can share / verify behavior) ──
|
||||
|
||||
fun hash(x: Int, y: Int): Float {
|
||||
var h = x * 374761393 + y * 668265263
|
||||
h = (h xor (h ushr 13)) * 1274126177
|
||||
h = h xor (h ushr 16)
|
||||
return (h and 0x7fffffff) / 2147483647f
|
||||
}
|
||||
|
||||
fun smoothNoise(x: Float, y: Float): Float {
|
||||
val xi = floor(x).toInt()
|
||||
val yi = floor(y).toInt()
|
||||
val xf = x - xi
|
||||
val yf = y - yi
|
||||
val u = xf * xf * (3f - 2f * xf)
|
||||
val v = yf * yf * (3f - 2f * yf)
|
||||
val n00 = hash(xi, yi)
|
||||
val n10 = hash(xi + 1, yi)
|
||||
val n01 = hash(xi, yi + 1)
|
||||
val n11 = hash(xi + 1, yi + 1)
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
|
||||
}
|
||||
|
||||
fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
|
||||
var value = 0f
|
||||
var amplitude = 0.5f
|
||||
var frequency = 1f
|
||||
for (i in 0 until octaves) {
|
||||
value += amplitude * smoothNoise(x * frequency, y * frequency)
|
||||
amplitude *= 0.5f
|
||||
frequency *= 2f
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
/**
|
||||
* Personality picker — shows server-configured personalities from GET /api/config.
|
||||
* "Default" uses the server's active personality (config.display.personality).
|
||||
* Other entries are from config.agent.personalities.
|
||||
*/
|
||||
@Composable
|
||||
fun PersonalityPicker(
|
||||
selected: String,
|
||||
personalities: List<String>,
|
||||
defaultName: String,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val displayName = if (selected == "default") {
|
||||
if (defaultName.isNotBlank()) {
|
||||
defaultName.replaceFirstChar { it.uppercase() }
|
||||
} else "Default"
|
||||
} else {
|
||||
selected.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
TextButton(onClick = { expanded = true }) {
|
||||
Text(
|
||||
text = displayName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Select personality"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
// Default (server's active personality)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = if (defaultName.isNotBlank()) {
|
||||
"${defaultName.replaceFirstChar { it.uppercase() }} (default)"
|
||||
} else "Default",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onSelect("default")
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
|
||||
if (personalities.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
|
||||
personalities.filter { it != defaultName }.forEach { personality ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = personality.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onSelect(personality)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
|
||||
/**
|
||||
* Settings card that opens the Profile Inspector full-screen viewer for
|
||||
* the currently-active agent profile.
|
||||
*
|
||||
* Placement-wise this lives immediately under `ActiveAgentCard` in
|
||||
* [com.hermesandroid.relay.ui.screens.SettingsScreen] so the card lines
|
||||
* up visually with the active-agent pick the user just saw above.
|
||||
*
|
||||
* When no profile is selected, the card renders at 50% alpha with a
|
||||
* "No active agent" subtitle and its onClick is a no-op — matching the
|
||||
* existing disabled-row convention used elsewhere in Settings. This
|
||||
* keeps the card visible (discoverable) rather than hidden, so users
|
||||
* understand the feature exists even before they've picked a profile.
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileInspectorCard(
|
||||
activeProfile: Profile?,
|
||||
onClick: (profileName: String) -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val enabled = activeProfile != null
|
||||
val profileName = activeProfile?.name
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
.alpha(if (enabled) 1f else 0.5f),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (enabled && profileName != null) {
|
||||
Modifier.clickable { onClick(profileName) }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ManageSearch,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Inspect Agent",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = if (enabled) {
|
||||
"View config, SOUL, memory, skills"
|
||||
} else {
|
||||
"No active agent"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user