Compare commits
511
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:
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -77,16 +94,28 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: debug-apk
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
# Product flavors (googlePlay, sideload) nest APKs under
|
||||
# app/build/outputs/apk/<flavor>/debug/ — the `*` matches both.
|
||||
path: app/build/outputs/apk/*/debug/*.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -100,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
|
||||
@@ -111,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,65 @@
|
||||
name: CI dashboard plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-dashboard-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test dashboard plugin
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: plugin/dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard deps
|
||||
working-directory: plugin/dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard bundle
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify server-owned version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
- name: Install dashboard API test deps
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
test -s plugin/dashboard/dist/index.js
|
||||
test -s plugin/dashboard/dist/style.css
|
||||
grep -q "hr-modal-card" plugin/dashboard/dist/style.css
|
||||
@@ -0,0 +1,116 @@
|
||||
name: CI desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck-and-build:
|
||||
name: Type-check + build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@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
|
||||
|
||||
tray-shell:
|
||||
name: Tray shell checks
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Cargo check tray shell
|
||||
run: npm run tray:check
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
@@ -0,0 +1,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-server.yml`,
|
||||
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
|
||||
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
|
||||
# "required status checks" treat a check that doesn't run as failing — so
|
||||
# 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."
|
||||
@@ -0,0 +1,128 @@
|
||||
# Hermes-Relay — Python Server CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# server-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check -> focused server tests
|
||||
|
||||
name: CI — Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-server-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Server — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax check (Python)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (server/plugin.relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
python -m py_compile plugin/relay/channels/bridge.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Validate Server version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Server — focused route/auth/session tests
|
||||
#
|
||||
# Tests are ADVISORY on dev (push or PR) so WIP commits don't block the
|
||||
# merge queue. Strict on main — the dev → main release-merge PR surfaces
|
||||
# any real failures before release.
|
||||
# ──────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Focused Server tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
# pushes to main and PRs whose base branch is main; true (= advisory)
|
||||
# for everything else (dev pushes, dev-targeted PRs, feature branches).
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
@@ -3,22 +3,53 @@ name: Claude Code Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
env:
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.title, 'release:') }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Skip aggregate release PR review
|
||||
if: env.IS_RELEASE_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
|
||||
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
timeout-minutes: 15
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
|
||||
+23
-112
@@ -6,134 +6,45 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned, labeled]
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
auth:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
authorized: ${{ steps.check.outputs.authorized }}
|
||||
steps:
|
||||
- name: Check collaborator status
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'issues' && ['opened', 'labeled'].includes(context.payload.action)) {
|
||||
core.setOutput('authorized', 'true');
|
||||
return;
|
||||
}
|
||||
const sender = context.payload.sender?.login;
|
||||
if (!sender) { core.setOutput('authorized', 'false'); return; }
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, username: sender,
|
||||
});
|
||||
const allowed = ['admin', 'write', 'maintain'].includes(data.permission);
|
||||
core.setOutput('authorized', allowed ? 'true' : 'false');
|
||||
} catch {
|
||||
core.setOutput('authorized', 'false');
|
||||
}
|
||||
|
||||
triage:
|
||||
needs: auth
|
||||
claude:
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude') ||
|
||||
(github.event_name == 'issues' && github.event.action == 'opened')
|
||||
)
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Triage this GitHub issue. Analysis-only — do NOT write code or create PRs.
|
||||
|
||||
1. **Classify** — bug, feature request, question, or docs issue?
|
||||
2. **Priority** — critical, high, medium, low based on impact.
|
||||
3. **Affected area** — which module(s)? Check CLAUDE.md for architecture.
|
||||
(ui/, network/, viewmodel/, auth/, data/, relay_server/, plugin/)
|
||||
4. **Reproduction** — for bugs, is there enough info? Ask for device, Android version, steps.
|
||||
5. **Suggested approach** — brief outline (files, strategy).
|
||||
6. **Labels** — suggest appropriate labels.
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
Keep it concise and actionable.
|
||||
claude_args: "--max-turns 5"
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
fix:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' &&
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'claude-fix'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Implement a fix for this GitHub issue. Read CLAUDE.md for project conventions.
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
1. Understand the issue — read relevant source files.
|
||||
2. Implement the minimal fix.
|
||||
3. Follow conventions: Kotlin + Jetpack Compose, kotlinx.serialization, Conventional Commits.
|
||||
4. Run `./gradlew assembleDebug` and fix any errors.
|
||||
5. Create a PR with Conventional Commits format title.
|
||||
|
||||
Do NOT over-engineer. Only change what is needed.
|
||||
claude_args: "--max-turns 25"
|
||||
|
||||
chat:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'assigned' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Responding to a collaborator comment. Read CLAUDE.md for project context.
|
||||
|
||||
Default mode is analysis — investigate, explain, suggest. Do NOT write code
|
||||
unless explicitly asked ("fix this", "implement", "create a PR").
|
||||
|
||||
If asked to fix: follow conventions (Kotlin, Compose, Conventional Commits),
|
||||
run `./gradlew assembleDebug`, and create a PR.
|
||||
claude_args: "--max-turns 15"
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Hermes-Relay-Android — Release Pipeline
|
||||
#
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "android-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
TAG_VERSION="${{ steps.version.outputs.version }}"
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
echo "libs.versions.toml version: $TOML_VERSION"
|
||||
|
||||
if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
ci:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
# Keep the tag release gate aligned with CI — Android's broad Gradle
|
||||
# `test` aggregate currently hangs in deferred JVM suites tracked by
|
||||
# issue #32, so the release gate runs the stable connection/pairing slice.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--console=plain
|
||||
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
needs: [validate, ci]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
if: env.HERMES_KEYSTORE_BASE64 != ''
|
||||
run: |
|
||||
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build release artifacts (APK + AAB)
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
# `assembleRelease` and `bundleRelease` are flavor-wide task aliases
|
||||
# (added by `flavorDimensions += "track"` in app/build.gradle.kts), so
|
||||
# this one line builds ALL four artifacts at once. Filenames come from
|
||||
# `archivesName` (set in app/build.gradle.kts) which injects the app
|
||||
# version, so `<version>` below is `libs.versions.appVersionName`:
|
||||
# app/build/outputs/apk/googlePlay/release/hermes-relay-<version>-googlePlay-release.apk
|
||||
# app/build/outputs/apk/sideload/release/hermes-relay-<version>-sideload-release.apk
|
||||
# app/build/outputs/bundle/googlePlayRelease/hermes-relay-<version>-googlePlay-release.aab
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
- name: List produced artifacts (debug aid)
|
||||
run: |
|
||||
echo "=== APK outputs ==="
|
||||
find app/build/outputs/apk -name '*.apk' -print 2>/dev/null || true
|
||||
echo "=== AAB outputs ==="
|
||||
find app/build/outputs/bundle -name '*.aab' -print 2>/dev/null || true
|
||||
|
||||
- name: Generate checksums
|
||||
# Flavor dimension adds an extra path segment to the AGP output layout.
|
||||
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
|
||||
# (note the concatenated camelCase — AGP path quirk, documented but
|
||||
# different between APK and AAB). The globs below match both flavors.
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/*/release/*.apk bundle/*Release/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
# `hermes-relay-<version>-sideload-release.apk` for the full
|
||||
# Phase 3 / Tier 3/4/6 feature set; the
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` is what gets
|
||||
# uploaded to Play Console. APK twin of the googlePlay flavor
|
||||
# and AAB twin of the sideload flavor are included for parity
|
||||
# (useful for diff tooling, not primary downloads).
|
||||
files: |
|
||||
app/build/outputs/apk/*/release/*.apk
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "⚠️ **Debug-signed** (no \`HERMES_KEYSTORE_BASE64\` secret) — NOT suitable for Play Store. Add the secret in repo settings to enable release signing." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### Artifacts" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,241 @@
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-cli-binaries:
|
||||
name: Build cross-platform CLI binaries via Bun compile
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@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
|
||||
|
||||
# Keep the package.json scripts as the single source of truth for Bun
|
||||
# compile flags so release and local smoke builds cannot diverge.
|
||||
- name: Build Windows x64
|
||||
run: npm run build:bin:win
|
||||
|
||||
- name: Build Linux x64
|
||||
run: npm run build:bin:linux
|
||||
|
||||
- name: Build macOS x64
|
||||
run: npm run build:bin:mac-x64
|
||||
|
||||
- name: Build macOS arm64
|
||||
run: npm run build:bin:mac-arm
|
||||
|
||||
- name: Size guard (<150 MB each)
|
||||
run: |
|
||||
set -e
|
||||
for f in dist/bin/hermes-relay-*; do
|
||||
sz=$(stat -c%s "$f")
|
||||
mb=$(( sz / 1024 / 1024 ))
|
||||
echo " $f - ${mb} MB"
|
||||
if [ "$sz" -gt 157286400 ]; then
|
||||
echo "FAIL: $f exceeds 150 MB - Bun likely shipped a debug build or we added a large dep."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Smoke-test Linux binary
|
||||
run: |
|
||||
set -e
|
||||
chmod +x dist/bin/hermes-relay-linux-x64
|
||||
for cmd in --version --help doctor; do
|
||||
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
|
||||
exit_code=$?
|
||||
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
|
||||
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
|
||||
echo "Raw output was: [$out]"
|
||||
exit 1
|
||||
fi
|
||||
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
|
||||
done
|
||||
|
||||
- name: Upload CLI release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-cli-release
|
||||
path: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
retention-days: 7
|
||||
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
|
||||
- name: Build tray installer
|
||||
run: npm run tray:build
|
||||
|
||||
- name: Normalize installer asset name
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist/tray | Out-Null
|
||||
$installer = Get-ChildItem -Path tray/src-tauri/target/release/bundle/nsis -Filter '*_x64-setup.exe' | Select-Object -First 1
|
||||
if (-not $installer) { throw 'NSIS installer was not produced' }
|
||||
Copy-Item -Force $installer.FullName dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
|
||||
- name: Smoke-test tray exe launch
|
||||
shell: pwsh
|
||||
run: |
|
||||
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $home | Out-Null
|
||||
$env:USERPROFILE = $home
|
||||
$env:HOME = $home
|
||||
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 5
|
||||
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
Write-Host "tray launch smoke OK pid=$($proc.Id)"
|
||||
|
||||
- name: Upload Windows tray release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows-tray-release
|
||||
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
retention-days: 7
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-cli-binaries
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
- name: Extract desktop version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
set -e
|
||||
find release-assets -type f ! -name SHA256SUMS.txt -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum \
|
||||
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
|
||||
cat release-assets/SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
|
||||
**Experimental phase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows tray app (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=${{ github.ref_name }}`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
|
||||
|
||||
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
|
||||
files: |
|
||||
release-assets/desktop-cli-release/hermes-relay-win-x64.exe
|
||||
release-assets/desktop-cli-release/hermes-relay-linux-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-arm64
|
||||
release-assets/desktop-windows-tray-release/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Release Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Server release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Server version sync
|
||||
run: python scripts/check-server-version-sync.py --expect "$TAG_VERSION"
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
test:
|
||||
name: Test Server package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
python -m py_compile plugin/relay/voice_auth.py
|
||||
python -m py_compile plugin/tools/android_tool.py
|
||||
python -m py_compile plugin/tools/desktop_tool.py
|
||||
python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
|
||||
package:
|
||||
name: Build and publish Server package
|
||||
needs: [validate, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd dist
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
tag_name: server-v${{ needs.validate.outputs.version }}
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
|
||||
This release contains the server/Python plugin package.
|
||||
Android releases use `android-v*` tags. Desktop releases use
|
||||
`desktop-v*` tags. Historical server releases before this lane
|
||||
rename used `relay-v*` tags.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==${{ needs.validate.outputs.version }}
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
dist/SHA256SUMS.txt
|
||||
@@ -1,131 +0,0 @@
|
||||
# Hermes-Relay — Release Pipeline
|
||||
#
|
||||
# Triggered when a version tag (v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs CI checks, builds a release APK, and creates a GitHub Release.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
TAG_VERSION="${{ steps.version.outputs.version }}"
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
echo "libs.versions.toml version: $TOML_VERSION"
|
||||
|
||||
if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
ci:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
needs: [validate, ci]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
if: env.HERMES_KEYSTORE_BASE64 != ''
|
||||
run: |
|
||||
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build release artifacts (APK + AAB)
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/release/*.apk bundle/release/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
files: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
app/build/outputs/bundle/release/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "## Release v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "⚠️ **Debug-signed** (no \`HERMES_KEYSTORE_BASE64\` secret) — NOT suitable for Play Store. Add the secret in repo settings to enable release signing." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### Artifacts" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
ls -la app/build/outputs/apk/release/ app/build/outputs/bundle/release/ >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
+25
-3
@@ -24,6 +24,9 @@ Thumbs.db
|
||||
local.properties
|
||||
/build/
|
||||
/app/build/
|
||||
/relay-core/build/
|
||||
/relay-ui/build/
|
||||
/quest/build/
|
||||
/app/release/
|
||||
*.apk
|
||||
*.aab
|
||||
@@ -43,19 +46,31 @@ certs/
|
||||
|
||||
# Local tools
|
||||
.subframe/
|
||||
voice-lab-runs/
|
||||
realtime-voice-runs/
|
||||
realtime-agent-runs/
|
||||
voice_rec_*.wav
|
||||
|
||||
# Ad-hoc debugging artifacts (logcat dumps, screenshots, UI XMLs)
|
||||
.scratch/
|
||||
|
||||
# VitePress
|
||||
user-docs/.vitepress/cache/
|
||||
user-docs/.vitepress/dist/
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
# Anchor VitePress-only npm manifests to root/user-docs so desktop/package.json is tracked.
|
||||
/package.json
|
||||
/package-lock.json
|
||||
/user-docs/package.json
|
||||
/user-docs/package-lock.json
|
||||
|
||||
# Local upstream reference
|
||||
# Local upstream references (not shipped in this repo)
|
||||
hermes-agent-upstream/
|
||||
hermes-agent-fork/
|
||||
|
||||
# Claude Code internal state (worktrees, image cache, conversation logs)
|
||||
.claude/
|
||||
.claude-launcher/
|
||||
|
||||
# Kotlin compiler cache
|
||||
.kotlin/
|
||||
@@ -63,3 +78,10 @@ hermes-agent-upstream/
|
||||
# Release signing & Play Store credentials — never commit these
|
||||
play-service-account.json
|
||||
keystore.properties
|
||||
|
||||
# Desktop TUI smoke harness runtime artifacts
|
||||
.smoke-relay.pid
|
||||
.smoke-relay.log
|
||||
|
||||
# Generated tray frontend vendor assets copied from desktop/node_modules
|
||||
desktop/tray/ui/vendor/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
# hermes-relay
|
||||
|
||||
## 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.
|
||||
|
||||
## Setup
|
||||
|
||||
### Quick start (canonical installer)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
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/`.
|
||||
|
||||
See [docs/relay-server.md](docs/relay-server.md) for Docker, systemd, TLS, and configuration options.
|
||||
|
||||
### 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.
|
||||
|
||||
## Tool usage patterns
|
||||
|
||||
### Read before act
|
||||
ALWAYS call android_read_screen before tapping. Never guess coordinates.
|
||||
|
||||
### Prefer text over coordinates
|
||||
Use android_tap_text("Continue") over android_tap(x=540, y=1200).
|
||||
|
||||
### Wait after navigation
|
||||
After opening an app or tapping a button that triggers loading,
|
||||
always call android_wait with expected text before next action.
|
||||
|
||||
### 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."
|
||||
|
||||
## 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
|
||||
+1120
-6
File diff suppressed because it is too large
Load Diff
@@ -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.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -29,70 +29,96 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
**Non-standard endpoints (may be version-specific):**
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
|
||||
These endpoints work on our hermes-agent v0.7.0 but are **not in the upstream source**. They may be fork-specific, version-specific, or added by plugins. Always use `detectChatMode()` to probe availability.
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
|
||||
| Endpoint | Purpose | Fallback |
|
||||
|----------|---------|----------|
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Use `/v1/runs` or `/v1/chat/completions` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions` | Session CRUD | Use `X-Hermes-Session-Id` header with `/v1/chat/completions` |
|
||||
| `GET /api/skills` | Skill discovery | Hardcoded command list |
|
||||
| `GET /api/config` | Server config (personalities, model) | No fallback — personality picker empty |
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`; Android Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** (`/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** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document it as non-standard and implement a fallback.
|
||||
- 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, mark it clearly in code comments and ensure `detectChatMode()` handles its absence gracefully.
|
||||
- **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:** Retire `hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
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
|
||||
│ ├── android_relay.py
|
||||
│ ├── 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 compatibility patch; retire per surface as upstream replaces it
|
||||
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
|
||||
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
|
||||
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
|
||||
```
|
||||
|
||||
## Project Conventions
|
||||
@@ -101,30 +127,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:** `type: description` — e.g. `feat: add chat channel UI`, `fix: WSS reconnect race condition`
|
||||
- **Branch from main** — feature branches for anything non-trivial
|
||||
- **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`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
|
||||
### Testing
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Python:** 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-server.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -132,89 +170,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) |
|
||||
| `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 |
|
||||
| `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/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/.../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/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/.../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] clone repo, [2] `pip install -e`, [3] symlink plugin, [4] register skills in `config.yaml`, [5] `hermes-pair` shell shim, [6] systemd user unit (optional — skipped on macOS/WSL-without-systemd/containers, or via `$HERMES_RELAY_NO_SYSTEMD`). Updates via `git pull` in the clone + `systemctl --user restart hermes-relay`. |
|
||||
| `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` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
|
||||
| `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 compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `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
|
||||
@@ -228,8 +307,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
|
||||
@@ -242,147 +319,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
|
||||
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
|
||||
|
||||
# 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/release/app-release.aab`
|
||||
- **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; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| 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 | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
## Upstream References
|
||||
|
||||
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 surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
|
||||
## Questions?
|
||||
|
||||
- **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,14 +5,16 @@
|
||||
<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>Your self-hosted Hermes agent, native on your phone.</strong><br>
|
||||
Chat, voice, and full agent management over your own infrastructure —<br>
|
||||
plus an experimental desktop CLI that gives the agent hands on your computer.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Platform-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://developer.android.com"><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%20%28alpha%29-orange.svg" alt="Desktop CLI (alpha)"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
|
||||
@@ -29,101 +31,182 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Two surfaces, one pair
|
||||
|
||||
Two steps: install the Android app on your phone, then install the plugin on your Hermes server.
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#quick-start-android)** | Native phone client — streaming chat, hands-free voice, full agent management (models, keys, skills, profiles), and on sideload builds the agent can read your screen and act on it. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop CLI](#desktop-cli-alpha)** | The agent reaching back to **your machine** — local tool routing (files, terminal, screenshots, clipboard) plus a remote shell to the host. | **Alpha** — `desktop-v*` releases, expect heavy changes |
|
||||
|
||||
### 1. Install the Android app
|
||||
Both share the same WSS relay and credentials store. **Pair once from either, both work.**
|
||||
|
||||
<!-- 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>
|
||||
-->
|
||||
---
|
||||
|
||||
## Quick Start (Android)
|
||||
|
||||
Install → connect → talk, in about two minutes. A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**.
|
||||
|
||||
### 1. Install the app
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases/latest)
|
||||
- **APK** — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Full walkthrough — integrity verification, signing fingerprint, what's in each build — in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
#### Sideload APK (GitHub Releases)
|
||||
Sideload builds check GitHub for new releases and show a one-tap update banner when you're behind; Play builds update through the Play Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
### 2. Have Hermes running
|
||||
|
||||
1. Download **`app-release.apk`** from [the latest release](https://github.com/Codename-11/hermes-relay/releases/latest) (not `app-release.aab` — that's the Google Play format).
|
||||
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
|
||||
3. Open the APK from your downloads and tap **Install**.
|
||||
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
|
||||
Run upstream Hermes with its API server and dashboard enabled:
|
||||
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)"
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
echo "Android API URL: http://<this-computer-ip>:8642"
|
||||
echo "Android API key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
Windows commands, dashboard auth notes, and upstream links: [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
|
||||
### 3. Connect and talk
|
||||
|
||||
Open the app, choose **Standard Hermes**, and enter your server's address and API key. The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|---|---|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session also unlocks voice. That's the whole standard setup.
|
||||
|
||||
**Going places?** Put your server's Tailscale URL in the setup form's "Remote access" field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
|
||||
### 4. Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
The installer clones 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`, and drops a thin `hermes-pair` shim into `~/.local/bin/`. After restart, pair your phone via either of these equivalent entry points:
|
||||
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen; if you can't scan, use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**. (`/hermes-relay-pair` and the dashed `hermes-pair` shim remain for chat-surface and older builds.)
|
||||
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **Updating:** `hermes-relay-update` — idempotent; or re-run the install one-liner.
|
||||
- **Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a "Relay" tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
|
||||
Scan the QR from the Android app's onboarding screen and you're connected. One scan configures **both** the direct-chat API server **and** the WSS relay (for terminal/bridge) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Updating:** `cd ~/.hermes/hermes-relay && git pull` — pulls new plugin, skill, and docs in one step. Because the installer uses `pip install -e` and `external_dirs`, nothing needs to be re-copied; restart hermes-agent and the updated skill + plugin are picked up on next load.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server · macOS / Linux / Windows for the desktop CLI.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26), [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+.
|
||||
## Desktop CLI (alpha)
|
||||
|
||||
## What It Does
|
||||
> **Alpha — expect heavy changes.** With [hermes-desktop](https://hermes-agent.nousresearch.com) now covering chat and management on the desktop, this surface is being refocused into a pure remote **"hands" connector**: the agent reaching back through the relay to run tools on your machine (files, terminal, screenshots, clipboard, editor). The chat and shell features that overlap hermes-desktop will be removed in a future release. Binaries are unsigned during the experimental phase — SmartScreen/Gatekeeper warnings are expected.
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — all native on Android.
|
||||
The agent's brain stays on the host; the CLI lets it call `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, and more **on your machine** over the same WSS relay — with a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch. No Node required; installs are self-contained native binaries.
|
||||
|
||||
| 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 |
|
||||
**Install** (Windows PowerShell / macOS / Linux):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
```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 daemon # headless tool router — agent reaches you anytime
|
||||
hermes-relay # interactive Hermes TUI in tmux (legacy, being refocused)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
- **Docs:** [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track:** tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## 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
|
||||
|
||||
## Getting Started
|
||||
- **Streaming chat** — direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage provider keys (write-only, masked, server-rate-limited reveal), create and edit agent profiles including `SOUL.md`, and browse, install, and update skills from the hub. One dashboard sign-in covers it all
|
||||
- **Voice mode** — talk hands-free on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice providers and an opt-in provider-native Realtime Agent with background task handoff
|
||||
- **Works away from home** — add your server's Tailscale or public URL and the app roams automatically: LAN at home, fallback elsewhere. Routes are editable per connection, and an unreachable server gets a diagnosis ("away from the server's network? add a route"), not just a red dot
|
||||
- **Multi-Connection + profiles** — pair with multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay an agent profile's model + `SOUL.md` per chat
|
||||
- **Phone control (bridge)** — with the Relay plugin paired, the agent reads the screen and acts on it: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros, and event-driven waits. Guarded by safety rails: per-app blocklist (banking/payments/2FA default-blocked), destructive-verb confirmation, idle auto-disable, full activity log
|
||||
- **Notification companion** — opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts
|
||||
|
||||
1. **Install the app** from the link above
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
> 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 detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
### Desktop CLI
|
||||
|
||||
- **Local tool routing** — `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` run on your machine; agent-proposed patches render as colored diffs with interactive approval
|
||||
- **Daemon mode** — headless tool router; the agent can reach you with no shell open
|
||||
- **Multi-endpoint pairing, reconnect-on-drop, TOFU cert pinning** — same model as the Android app
|
||||
- **Self-update** — `hermes-relay update` verifies SHA256 and atomic-swaps the binary
|
||||
|
||||
## Install with an AI agent
|
||||
|
||||
If an AI assistant (Claude, GPT, etc.) manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
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 server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via the plugin-provided `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
|
||||
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) --> Relay Server (:8767) [terminal, bridge — future]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Hermes Dashboard (:9119) [manage + standard voice — cookie sign-in]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
Desktop CLI (WSS) --> Relay (:8767) [desktop tools, tui, terminal]
|
||||
```
|
||||
|
||||
Chat 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 connects directly to the Hermes API Server with the API key — the same pattern used by Open WebUI and other Hermes frontends. The Manage tab and standard voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla install needs no plugin for either. The optional relay on `:8767` adds the power surfaces — terminal, bridge phone control, media handoff, desktop tools, and relay-side voice providers (preferred automatically when paired). One QR can configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, 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/)** | **Quick start, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, Desktop `desktop-v*`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -144,7 +227,7 @@ scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
scripts/dev.bat relay # Start Server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
@@ -152,15 +235,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 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-server / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
@@ -169,13 +258,14 @@ hermes-relay/
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact) |
|
||||
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
### Relay Server (optional — terminal/bridge only)
|
||||
### Server (optional — bridge, terminal, TUI, media, and relay voice routes)
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
@@ -193,7 +283,7 @@ See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setu
|
||||
|
||||
### Hermes Plugin (for contributors)
|
||||
|
||||
End users should install via the [one-liner](#2-install-the-server-plugin-one-liner) at the top. For local development from a clone:
|
||||
End users should install via the [one-liner](#4-optional-install-relay-for-power-tools) above. For local development from a clone:
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
@@ -201,7 +291,7 @@ cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 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 the plugin-provided `hermes pair` to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. `/hermes-relay-pair` and the dashed `hermes-pair` shim remain available for chat-surface and older-build compatibility.
|
||||
|
||||
## Hermes Agent
|
||||
|
||||
@@ -209,7 +299,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
|
||||
|
||||
|
||||
+357
-54
@@ -3,7 +3,7 @@
|
||||
> The full recipe for cutting a new release. Read this end-to-end before
|
||||
> tagging your first release.
|
||||
|
||||
## Versioning
|
||||
## Release Tracks And Versioning
|
||||
|
||||
Hermes-Relay follows [SemVer](https://semver.org/): `MAJOR.MINOR.PATCH`,
|
||||
with optional prerelease identifiers.
|
||||
@@ -13,6 +13,23 @@ with optional prerelease identifiers.
|
||||
- `PATCH` — bug fixes, backwards compatible
|
||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||
|
||||
Hermes-Relay now ships three independently versioned surfaces:
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Android app | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Server / Python package | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-server-version.sh` | `.github/workflows/release-server.yml` |
|
||||
| Desktop CLI | `desktop-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-desktop.yml` |
|
||||
|
||||
This split is intentional. The server now carries features for both Android
|
||||
and desktop, so server fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and desktop CLI alphas can continue on their own cadence.
|
||||
Historical Android releases before this naming split used bare `v*` tags, and
|
||||
historical server releases used `relay-v*` tags. New releases use the explicit
|
||||
surface prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
**Source of truth:** `gradle/libs.versions.toml`
|
||||
|
||||
```toml
|
||||
@@ -44,6 +61,125 @@ Never decrement `appVersionCode` — Play Console rejects any upload whose
|
||||
code is lower than or equal to a previous upload on the same track. Confirm
|
||||
current values with `scripts\dev.bat version`.
|
||||
|
||||
Always bump Android releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
`scripts/bump-version.sh` remains as a backward-compatible alias for the
|
||||
Android script.
|
||||
|
||||
### Server / Python package versioning
|
||||
|
||||
Server version metadata lives in these server-owned files and must stay in
|
||||
lockstep:
|
||||
|
||||
| File | Line | Purpose |
|
||||
|---|---|---|
|
||||
| `pyproject.toml` | `version = "..."` | Python package metadata |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` |
|
||||
| `plugin/plugin.yaml` | `version: ...` | Hermes plugin metadata |
|
||||
| `plugin/dashboard/manifest.json` | `"version": "..."` | Hermes dashboard plugin metadata |
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Server releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Check the current metadata with:
|
||||
|
||||
```bash
|
||||
python scripts/check-server-version-sync.py
|
||||
```
|
||||
|
||||
The `server-v*` release workflow validates the tag against the same metadata,
|
||||
runs server tests, builds a wheel and sdist, generates checksums, and publishes
|
||||
a GitHub Release with the package artifacts.
|
||||
|
||||
## Branching policy
|
||||
|
||||
> **Updated 2026-04-19:** moved from `main`-only to `main + dev`. See
|
||||
> `docs/decisions.md` §23 for the rationale.
|
||||
|
||||
Hermes-Relay uses **`main` + `dev` with feature branches and no-ff
|
||||
merges**. `main` is **released state only** — every commit on `main`
|
||||
corresponds to a shipped version or a release-merge of `dev`. Day-to-day
|
||||
integration happens on `dev`.
|
||||
|
||||
**Merging is decoupled from releasing.** Feature branches land on `dev`
|
||||
continuously as they go green in CI — there is no "one feature per
|
||||
release" rule. The `[Unreleased]` section of `CHANGELOG.md` on `dev` is
|
||||
the accumulator: every merged PR appends bullets there. A release is a
|
||||
separate act, taken when the accumulated state on `dev` is worth shipping
|
||||
(see "When to cut a release" below). Cutting a release means opening a
|
||||
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
|
||||
then tagging `main`.
|
||||
|
||||
**Server tracks `dev` for staging.** The hermes-host deployment pulls
|
||||
`dev` so merged features get exercised against real data before they
|
||||
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
|
||||
see state that lives on `main` and on release tags.
|
||||
|
||||
### Branch names
|
||||
|
||||
| Prefix | When | Example |
|
||||
|---|---|---|
|
||||
| `feature/<name>` | New feature (>1-2 commits) | `feature/bridge-scroll-tool` |
|
||||
| `fix/<name>` | Focused bug fix | `fix/media-projection-fgs` |
|
||||
| `docs/<name>` | Docs-only changes larger than a typo | `docs/sideload-guide` |
|
||||
| `chore/<name>` | Cleanup / refactor / tooling | `chore/sync-version-sources` |
|
||||
|
||||
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 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"
|
||||
- `git bisect` needs to treat the whole branch as one unit
|
||||
- Someone reviews history in 6 months and wants to know "what was the
|
||||
bundle of changes that introduced feature X"
|
||||
|
||||
Squash merges lose that detail and are **not** the house style.
|
||||
|
||||
### Version bumps happen at release-prep on `dev`, NOT on feature branches
|
||||
|
||||
Feature branches **never** touch `gradle/libs.versions.toml`,
|
||||
server-owned version metadata, or `desktop/package.json`.
|
||||
If two feature branches both bumped a release version, they'd collide on
|
||||
version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||
use `release(server): server-vX.Y.Z`; desktop commits use the existing
|
||||
`release: desktop-vX.Y.Z` convention. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection
|
||||
|
||||
Light branch protection is enabled:
|
||||
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Server) before merge. Force push and
|
||||
branch deletion blocked.
|
||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
||||
branches PR in. PR must pass CI. Force push and branch deletion
|
||||
blocked.
|
||||
- Signed commits + review approval NOT required (solo-dev overhead).
|
||||
|
||||
## One-time Setup
|
||||
|
||||
### 1. Release signing keystore
|
||||
@@ -73,10 +209,12 @@ hermes.key.password=YOUR_KEY_PASSWORD
|
||||
```
|
||||
|
||||
`local.properties`, `*.keystore`, and `*.jks` are already gitignored.
|
||||
Relative `hermes.keystore.path` values resolve from the repo root, so
|
||||
`release.keystore` works when the keystore lives beside this file.
|
||||
|
||||
> If the keystore at `hermes.keystore.path` is missing, `app/build.gradle.kts`
|
||||
> silently falls back to debug signing. The build succeeds but Play Console
|
||||
> rejects the AAB — always verify with `keytool -list -printcert` (step 3
|
||||
> rejects the AAB — always verify with `keytool -printcert` (step 3
|
||||
> below).
|
||||
|
||||
#### CI builds
|
||||
@@ -100,17 +238,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)
|
||||
|
||||
@@ -134,44 +295,92 @@ to Play Console. Manual UI uploads work without this.
|
||||
### 4. GitHub Actions secrets
|
||||
|
||||
In the repo: **Settings > Secrets and variables > Actions > New repository
|
||||
secret.** Add all four (see the table in "Required GitHub Secrets" below).
|
||||
secret.** Add all four (see the table in "Required Android Release Secrets"
|
||||
below).
|
||||
|
||||
If `HERMES_KEYSTORE_BASE64` is missing, CI release builds fall back to
|
||||
debug signing and print a warning in the workflow summary — those
|
||||
artifacts will not be accepted by Play Console.
|
||||
|
||||
## When to cut a release
|
||||
|
||||
Cut a release when **any of the following** is true:
|
||||
|
||||
- The `[Unreleased]` section of `CHANGELOG.md` has enough user-facing
|
||||
change that a version number is worth attaching.
|
||||
- A user-facing bug is fixed and you want affected users to pick it up
|
||||
via `hermes-relay-update` or a Play Store auto-update.
|
||||
- A regulatory / policy deadline applies (new Play Console target SDK,
|
||||
etc).
|
||||
- You've been sitting on unreleased work for more than a couple of
|
||||
weeks and the delta-from-last-release is growing faster than it
|
||||
should.
|
||||
|
||||
**Don't** cut a release just because a feature landed. If one feature
|
||||
isn't enough to justify a version bump, wait — merge the next one, let
|
||||
it sit alongside in `[Unreleased]`, and ship them together. A release
|
||||
is a statement to users that "this is a thing worth updating to," so
|
||||
the threshold is intent-driven, not event-driven.
|
||||
|
||||
If you want to dogfood accumulated `main` state without declaring GA,
|
||||
tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
|
||||
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||
the unstable build.
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Bump the version
|
||||
### 1. Bump the Android app version
|
||||
|
||||
Edit `gradle/libs.versions.toml`:
|
||||
Use `scripts/bump-android-version.sh`. It rewrites
|
||||
`gradle/libs.versions.toml`, increments `appVersionCode` monotonically,
|
||||
and runs a sanity check. Don't edit the Android version files by hand.
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
appVersionName = "0.1.1" # bump per SemVer
|
||||
appVersionCode = "2" # ALWAYS increment, even for prereleases
|
||||
```bash
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Confirm:
|
||||
Confirm the bump:
|
||||
|
||||
```bat
|
||||
scripts\dev.bat version
|
||||
```
|
||||
|
||||
The script's diff output should show `gradle/libs.versions.toml` carrying
|
||||
the new app version and a higher `appVersionCode`.
|
||||
|
||||
### 2. Update release notes and changelog
|
||||
|
||||
- `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
|
||||
**Download** section near the top — it tells users to grab
|
||||
`app-release.apk` (not the `.aab`) and links to the sideload guide.
|
||||
The v0.1.0 body is a good template.
|
||||
- `CHANGELOG.md` — cumulative history; append a new section.
|
||||
(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.
|
||||
- `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
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
keytool -list -printcert -jarfile app\build\outputs\bundle\release\app-release.aab
|
||||
keytool -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
|
||||
```
|
||||
|
||||
The `keytool` output must show your release certificate (the CN/OU/O
|
||||
@@ -179,32 +388,86 @@ values you entered during `keytool -genkey`). If it shows
|
||||
`CN=Android Debug, O=Android, C=US`, the keystore wasn't picked up —
|
||||
recheck `local.properties` before continuing.
|
||||
|
||||
Optional device smoke test: `scripts\dev.bat release` then
|
||||
`adb install -r app\build\outputs\apk\release\app-release.apk`.
|
||||
Product flavors (`googlePlay`, `sideload`) nest outputs under a flavor
|
||||
directory: APKs live in `app/build/outputs/apk/<flavor>/release/` and
|
||||
AABs live in `app/build/outputs/bundle/<flavor>Release/`. Every file is
|
||||
prefixed `hermes-relay-<version>-` via `archivesName` in
|
||||
`app/build.gradle.kts`.
|
||||
|
||||
### 4. Commit and tag
|
||||
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 on `dev`, merge to `main`, tag from `main`
|
||||
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
|
||||
```bash
|
||||
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md
|
||||
git commit -m "release: v0.1.1"
|
||||
git push origin main
|
||||
# From a clean dev checkout:
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
git tag v0.1.1
|
||||
git push origin v0.1.1
|
||||
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
app/src/main/assets/whats_new.txt docs/play-store-listing.md
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag android-v0.6.2
|
||||
git push origin android-v0.6.2
|
||||
```
|
||||
|
||||
Pushing a tag matching `v*` triggers `.github/workflows/release.yml`,
|
||||
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
|
||||
Server/Python version files are intentionally not part of an Android app
|
||||
release unless the server package itself is also being released.
|
||||
|
||||
### Server / Python package release
|
||||
|
||||
Use this when Server behavior changes independently of Android app
|
||||
delivery, for example desktop channel support, bridge routes, pairing
|
||||
server fixes, voice auth, or packaging changes.
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md
|
||||
git commit -m "release(server): server-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag server-v0.6.2
|
||||
git push origin server-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
|
||||
validates all server-owned version metadata with
|
||||
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
|
||||
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
|
||||
package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
|
||||
**Manual upload (default):**
|
||||
|
||||
1. Download `app-release.aab` from the GitHub Release assets, or use your
|
||||
local build at `app\build\outputs\bundle\release\app-release.aab`.
|
||||
2. In Play Console: **Release > Testing > Internal testing** (or **Closed
|
||||
testing** for the 14-day clock).
|
||||
1. Download the file ending in `-googlePlay-release.aab` from the GitHub
|
||||
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** (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.**
|
||||
@@ -232,8 +495,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
|
||||
|
||||
@@ -247,9 +511,9 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
`RELEASE_NOTES.md` this will already be baked in. If for some reason
|
||||
it's missing, edit the body with:
|
||||
```bash
|
||||
gh release view vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
gh release view android-vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
# edit /tmp/body.md to add/fix the Download section
|
||||
gh release edit vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
gh release edit android-vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
```
|
||||
(This step was only needed as a retrofit for v0.1.0 — v0.1.1+ inherit
|
||||
the Download section automatically from `RELEASE_NOTES.md`.)
|
||||
@@ -258,23 +522,47 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
|
||||
## CI Behavior
|
||||
|
||||
On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
Android, Server, dashboard, and desktop now have separate CI/release lanes.
|
||||
This keeps a dashboard CSS fix from running the full server suite, and keeps
|
||||
server changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
1. Validates the tag matches `appVersionName` in
|
||||
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||
2. Runs `./gradlew assembleDebug` and `./gradlew test`.
|
||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||
regression slice with explicit timeouts.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. Builds both artifacts: `./gradlew bundleRelease assembleRelease`.
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
6. Creates a GitHub Release named `v<version>` with `RELEASE_NOTES.md` as
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `v0.2.0-beta.1`) as a prerelease automatically.
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
|
||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
||||
|
||||
## Required GitHub Secrets
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-server.yml`:
|
||||
|
||||
1. Validates the tag matches all server-owned version metadata checked by
|
||||
`scripts/check-server-version-sync.py`.
|
||||
2. Runs server syntax checks and the focused route/auth/session test slice.
|
||||
3. Builds the Python wheel and sdist with `python -m build`.
|
||||
4. Generates `dist/SHA256SUMS.txt`.
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
|
||||
sdist, and checksum file attached.
|
||||
|
||||
On every push of a tag matching `desktop-v*`,
|
||||
`.github/workflows/release-desktop.yml` builds and publishes the desktop
|
||||
CLI binaries. Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
runs the dashboard API tests, and verifies the modal CSS markers are present
|
||||
in the built bundle.
|
||||
|
||||
## Required Android Release Secrets
|
||||
|
||||
| Secret | Purpose | How to populate |
|
||||
|-----------------------------|-------------------------------------|--------------------------------------------------|
|
||||
@@ -286,26 +574,41 @@ 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`, branch from the affected release tag and only
|
||||
bump the version source for the surface you are shipping.
|
||||
|
||||
1. `git checkout -b fix/short-name v0.1.0` — branch from the released tag.
|
||||
For an Android app hotfix:
|
||||
|
||||
1. `git checkout -b fix/short-name android-v0.6.1` — branch from the released
|
||||
Android tag (not from `main` or `dev`).
|
||||
2. Apply the fix, add a test, commit.
|
||||
3. Bump `appVersionName` and `appVersionCode` in
|
||||
3. Run `bash scripts/bump-android-version.sh 0.6.2` to update
|
||||
`gradle/libs.versions.toml`.
|
||||
4. Update `RELEASE_NOTES.md` and `CHANGELOG.md`.
|
||||
5. `git tag v0.1.1 && git push origin v0.1.1` — CI builds and publishes.
|
||||
6. Upload to Play Console as normal.
|
||||
7. Merge the hotfix branch back into `main` so the fix isn't lost.
|
||||
4. Update `RELEASE_NOTES.md`, `CHANGELOG.md`, in-app What's New, and Play
|
||||
listing notes as needed.
|
||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Upload to Play Console as normal.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Server hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-server-version.sh <next-version>`, merge to
|
||||
`main`, and tag `server-v<next-version>`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||
You pushed a tag before bumping `gradle/libs.versions.toml`, or vice versa.
|
||||
Fix: update the file, commit, delete the remote tag
|
||||
(`git push --delete origin vX`), re-tag, and push again.
|
||||
(`git push --delete origin android-vX`), re-tag, and push again.
|
||||
|
||||
**Play Console rejects the AAB as debug-signed**
|
||||
Run `keytool -list -printcert -jarfile <aab>` locally — if it shows
|
||||
Run `keytool -printcert -jarfile <aab>` locally — if it shows
|
||||
`CN=Android Debug`, fix `local.properties` for local builds or
|
||||
`HERMES_KEYSTORE_BASE64` for CI. For CI, check the workflow summary; if it
|
||||
says "Debug-signed", one of the four `HERMES_*` secrets is missing or the
|
||||
|
||||
+32
-70
@@ -1,82 +1,44 @@
|
||||
# Hermes-Relay v0.2.0
|
||||
# Unreleased
|
||||
|
||||
Voice mode, terminal, and a full security + pairing overhaul. 54 commits since v0.1.0.
|
||||
## Changed
|
||||
|
||||
- Android now defaults to a standard Hermes layout with **Chat**, **Manage**, and **Settings** in bottom navigation. Terminal and Bridge remain available under **Settings → Power tools** and through existing routes.
|
||||
- Added a native **Manage** surface backed by the Hermes dashboard/admin API for Skills, Cron, MCP servers/catalog, Profiles, Models, and Config. It supports dashboard sign-in, common management actions, cron run details, and read-only profile SOUL details without requiring relay pairing.
|
||||
- Relay-only features now show a consistent **Requires pairing** / **Pair to unlock** gate when the active connection is not paired.
|
||||
- Connections now model API auth, dashboard auth, and relay pairing separately. Dashboard URLs derive from the API host on port `9119` by default.
|
||||
|
||||
---
|
||||
|
||||
# Hermes-Relay-Android v0.8.1
|
||||
|
||||
**Release Date:** May 26, 2026
|
||||
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
|
||||
|
||||
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
- **Most people**: grab **`app-release.apk`** below and sideload it. See the [sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for step-by-step instructions.
|
||||
- **Google Play users**: the app is on Internal testing — production rollout coming soon.
|
||||
- **`app-release.aab`** is the Google Play format — *not* installable directly.
|
||||
- **Verify integrity** with `SHA256SUMS.txt` before installing.
|
||||
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
## Highlights
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
### Voice Mode (new)
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
Talk to your Hermes agent with your voice. Tap the mic in the chat bar to enter voice mode — the sphere expands, listens while you speak, transcribes via your server's STT provider, streams the response through the normal chat pipeline, and speaks it back sentence-by-sentence via TTS. No API keys on the phone — everything routes through the relay plugin using whatever TTS/STT providers you've configured in `~/.hermes/config.yaml`.
|
||||
---
|
||||
|
||||
- **Three interaction modes** — Tap-to-talk (default), Hold-to-talk, Continuous
|
||||
- **Reactive layered-sine waveform** — three overlapping waves with amplitude-driven phase velocity, pill-shaped edge merge, color-keyed to voice state
|
||||
- **Enter/exit chimes** — synthesized tonal sweeps
|
||||
- **Streaming TTS** — sentence-boundary detection plays the first sentence while the rest is still generating
|
||||
- **Interrupt** — tap stop while the agent is speaking to cancel TTS + SSE stream
|
||||
- **Sphere voice states** — Listening (soft blue/purple) and Speaking (vivid green/teal)
|
||||
- **Voice settings** — Settings > Voice for interaction mode, silence threshold, provider info, and Test Voice
|
||||
- **6 TTS + 5 STT providers** supported via hermes-agent config
|
||||
## Fixed
|
||||
|
||||
### Terminal (Phase 2)
|
||||
### Voice mode crash with barge-in on legacy TTS playback
|
||||
|
||||
- **tmux-backed persistent shells** — reconnecting reattaches to your existing session
|
||||
- **Tabs** — multiple terminal sessions with tab bar
|
||||
- **Scrollback search** — search through terminal history
|
||||
- **Session info sheet** — tap for session metadata
|
||||
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
|
||||
|
||||
### Pairing & Security
|
||||
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
|
||||
|
||||
- **Session TTL picker** — choose 1d / 7d / 30d / 90d / 1y / Never when pairing
|
||||
- **Per-channel grants** — control which channels each paired device can access
|
||||
- **Android Keystore** — session tokens in hardware-backed encrypted storage
|
||||
- **TOFU certificate pinning** — first-connect pins the relay's TLS cert
|
||||
- **Paired Devices screen** — list, extend, revoke paired devices
|
||||
- **Transport security badges** — visual connection security indicator
|
||||
- **HMAC-SHA256 QR signing** — pairing QR codes are signed to prevent tampering
|
||||
|
||||
### Inbound Media
|
||||
|
||||
- Agent-produced screenshots and files via relay MediaRegistry with opaque tokens
|
||||
- Discord-style rendering for image / video / audio / PDF / text attachments
|
||||
- LLM-emitted `MEDIA:/path` markers fetched via `/media/by-path`
|
||||
|
||||
### Settings Refactor
|
||||
|
||||
- Category-list landing page replacing the mega-scroll
|
||||
- Dedicated sub-screens: Connection, Chat, Voice, Media, Appearance, Paired Devices, Analytics, Developer
|
||||
|
||||
### Error Feedback
|
||||
|
||||
- **RelayErrorClassifier** — every failure now names what broke (not "error: unknown")
|
||||
- **Global SnackbarHost** — transient error toasts from any screen
|
||||
- **Mic permission banner** — "Open Settings" action instead of a confusing toast
|
||||
|
||||
### Relay Infrastructure
|
||||
|
||||
- **`.env` autoload** — relay loads `~/.hermes/.env` at Python import time
|
||||
- **systemd user service** — `install.sh` installs and enables `hermes-relay.service` automatically
|
||||
- No more `nohup` / `pkill` — just `systemctl --user restart hermes-relay`
|
||||
|
||||
### Other
|
||||
|
||||
- Global font-scale preference
|
||||
- Save & Test health probe for relay connection verification
|
||||
- App screenshots in `assets/screenshots/`
|
||||
- Gradle task to suppress Android 15 logcat spam
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- [Hermes agent](https://github.com/NousResearch/hermes-agent) v0.8.0+
|
||||
- Relay plugin installed via `install.sh` (for voice, terminal, media, and pairing features)
|
||||
|
||||
## Found a bug?
|
||||
|
||||
[Open an issue](https://github.com/Codename-11/hermes-relay/issues/new) — we read every one.
|
||||
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# 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 `android-v*` and Server `server-v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
|
||||
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
|
||||
|
||||
- **`@hermes-relay/cli` v0.1** — Node thin-client at [`desktop/`](desktop/). Remote chat + pair + status + tools subcommands over the relay's `tui` WSS channel. Shares `~/.hermes/remote-sessions.json` with the Android client (pair once, both work).
|
||||
- **v0.2 — resilience + pairing UX** — multi-endpoint pairing (ADR 24: `--pair-qr` probes LAN/Tailscale/Public, strict-priority within-tier race, 4s timeout, 60s cache), reconnect-on-drop state machine (1s→30s exp backoff, 5min on 429, gate re-check post-sleep), TOFU cert pinning via pre-WS TLS probe (SPKI sha256, `sha256/<base64>` OkHttp-compatible).
|
||||
- **v0.2 — UX polish** — bare `hermes-relay` → `shell` (full Hermes CLI over PTY with `clear; exec hermes` after tmux settles); contextual connect banner (`Connected via LAN (plain) — server 0.6.0`); `status` surfaces grants + TTL + endpoint role from `auth.ok`; new `devices` subcommand talking to relay `GET/DELETE/PATCH /sessions` over HTTP.
|
||||
- **Phase B — client-side tool routing** — server-side `plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` register `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` via `tools.registry` (mirror of `android_*` pattern — **zero hermes-agent core change**). Client-side `DesktopToolRouter` attaches to the `desktop` channel, dispatches under a 30s AbortController, heartbeats `desktop.status` every 30s. One-time per-URL consent gate + `--no-tools` kill-switch.
|
||||
- **`hermes-relay daemon`** — headless WSS + tool router that keeps desktop tools serving without a visible shell. Fails closed on missing stored consent (`--allow-tools` escape hatch with an explicit `--token`). JSON-line logs by default, auto-human on TTY. Inherits transport's reconnect state machine; `setImmediate(exit)` to flush final log line before process dies.
|
||||
- **Pre-release hardening** — `hermes-relay doctor` (local diagnostic report, human + `--json`, no token leakage); `uninstall.{sh,ps1}` (3-tier: default keeps session store, `--purge` wipes it with cross-surface warning, `--service` stub); interactive first-run prompts (`resolveFirstRunUrl` — auto-picks single stored session, numbered picker for multiple, welcome banner for fresh install); version-aware install (`upgrading X → Y` readback pre-install, post-install confirmation).
|
||||
- **Self-setup skill** — [`skills/devops/hermes-relay-desktop-setup/SKILL.md`](skills/devops/hermes-relay-desktop-setup/SKILL.md) lets any Hermes agent install, pair, and troubleshoot the CLI with **live local diagnostics** via `desktop_terminal` (can read the user's Node version, PATH, binary location directly — something the Android setup skill can't match).
|
||||
|
||||
**Shipped — `desktop-v0.3.0-alpha.6` (seamless-local dev pass, done 2026-04-23):** Plan at [`docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md`](docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md). Nine features across six parallel agent workstreams, all opt-in: workspace-awareness envelope + active-editor signal (#1+#8), `hermes-relay update` self-update subcommand (#2), `desktop_open_in_editor` tool + interactive patch approval with unified-diff rendering (#3+#4), conversation picker on connect (#5), clipboard bridge + screenshot handlers (#9+#12), and a `hermes` alias so muscle-memory works without the `-relay` suffix (#13). Integration day: 2026-04-23.
|
||||
|
||||
**Active — `desktop-v0.3.0-alpha.7` (native image paste):** Plan at [`docs/plans/2026-04-23-desktop-alpha-7-native-paste.md`](docs/plans/2026-04-23-desktop-alpha-7-native-paste.md). Two-repo workstream: client slash commands `/paste` (clipboard), `/screenshot` (primary display), `/image <path>` (file) land in `hermes-relay chat`, each echoes a one-line feedback and attaches the image to the next `prompt.submit` so the vision-capable model sees it in the same turn — parity with Claude Desktop's paste UX minus OS-level Ctrl+V (terminals don't pipe image bytes to stdin). Client half is new `desktop/src/chatAttach.ts` + slash-command branches in `desktop/src/commands/chat.ts`. Server half is ONE new `@method("image.attach.bytes")` on the fork's `tui_gateway/server.py` (branch `feat/image-attach-bytes` → merged to `axiom`); the fork's existing `_enrich_with_attached_images` already handles multimodal payload plumbing and session-scoped image state, so this release is almost entirely about bridging client-captured bytes to server-side state that's been there for months. Relay channel unchanged — `tui` is a transparent RPC forwarder. Graceful fallback when hermes-host hasn't been updated yet: client catches `method not found`, prints a pointer at the axiom rollout, REPL stays alive.
|
||||
|
||||
**Active — desktop control / computer-use:** Enhanced plan at [`docs/plans/desktop-control-computer-use-enhanced.md`](docs/plans/desktop-control-computer-use-enhanced.md); earlier MVP implementation record at [`docs/plans/desktop-computer-use-mvp.md`](docs/plans/desktop-computer-use-mvp.md). Windows now has the first Tauri tray/overlay app as the primary Easy/Standard install surface: pair, start/pause daemon, Devices/Revoke, Task Log, Settings, overlay status chip, emergency stop, and bundled CLI sidecar. The existing CLI and daemon remain the primary advanced/headless surface. `desktop_computer_*` schemas are registered on the normal desktop tool channel but advertised only behind the explicit experimental computer-use flag. Host input still requires desktop-tool consent plus a visible, task-scoped assist/control grant; there is no unrestricted or silent mouse/keyboard automation.
|
||||
|
||||
**Desktop control UX direction:** Tauri v2 (Rust + static web UI) is the native shell for the polished Easy-tier experience: tray icon, always-visible overlay chip, task log, settings, and one-click pause/emergency stop. Easy tier pairs once, shows a connected/observing chip, and exposes Devices / Revoke / Task Log / Settings / Emergency Stop from the tray. Standard tier adds full tray management; Advanced tier remains CLI + daemon + JSON policy (`~/.hermes/desktop-control.json`) for operators. The default policy baseline blocks password managers, credential prompts, banking/payment/crypto surfaces, OS security/admin settings, and private-key/token material until locally overridden.
|
||||
|
||||
**Deferred to alpha.8 / alpha.9 / v1.0:**
|
||||
|
||||
- **Per-project session stickiness** — blocked on hermes-agent plugin hook that consumes the workspace envelope; premature until the envelope shape stabilizes in use.
|
||||
- **Shell-history context hook** — needs rc-file-edit install path, which our install philosophy currently avoids. Design pass required.
|
||||
- **Desktop notifications for long-running daemon work** — let daemon bake in real-world use first; latency/idle-detection thresholds best tuned with telemetry.
|
||||
- **Environment-variable passthrough** — security-sensitive; needs per-var prompt UX + threat model before shipping.
|
||||
- **Global hotkey to summon a prompt** — OS-specific helper installers; out of scope for binary-only release.
|
||||
- **Watch mode** (`hermes-relay daemon --watch`) — needs a DSL and clear safety bounds; own feature branch.
|
||||
- **Native assist/control grant modal hardening** — the tray-managed daemon now has a local grant bridge and Grant Requests view. Next pass should polish native modal behavior, notification routing, and multi-client grant ownership.
|
||||
- **Kitty / iTerm2 inline image protocols for paste feedback** — would show a thumbnail of the attached image directly in the terminal after `/paste` instead of a plain text line. Most terminals don't support them; the slash-command feedback line works anywhere. Revisit if users request it.
|
||||
|
||||
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
|
||||
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls the GitHub Releases API, filters to `desktop-v*`, compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
|
||||
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
|
||||
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
|
||||
- **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.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Hermes-Relay — TODO
|
||||
|
||||
Open items that don't fit a formal Phase plan but shouldn't be lost. Items move from here into a Plan in `docs/spec.md` or an Obsidian Phase plan once they're ready to schedule.
|
||||
|
||||
For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisions.md`.
|
||||
|
||||
---
|
||||
|
||||
## Hands-free agentic voice backlog
|
||||
|
||||
Goal: make Hermes usable for hands-free work without leaving the operator blind
|
||||
to tool state, safety prompts, or the current task.
|
||||
|
||||
- **Waveform output-start sync** — current input waveform timing feels good, but
|
||||
the agent-output waveform can unfold and begin movement before audible speech
|
||||
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
|
||||
gate the unfolded Speaking waveform on the first real playback frame/audio
|
||||
amplitude. Processing can stay as the folded circular spinner until output is
|
||||
actually audible.
|
||||
- **Voice command layer** — reserve local commands that bypass normal agent
|
||||
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
|
||||
overlay", "return to Hermes", and "new chat". These should work while the
|
||||
agent is thinking, speaking, or using tools.
|
||||
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
|
||||
short status updates such as "I'm checking the relay logs" or "I found an
|
||||
error" without waiting for final assistant text. Long tool calls should emit
|
||||
periodic, low-noise progress updates.
|
||||
- **Realtime tool timeline parity** — the voice overlay should render the same
|
||||
live thinking blocks, streaming assistant text, and tool call progress as the
|
||||
normal chat surface without requiring exit/reload.
|
||||
- **Hands-free confirmation flow** — risky actions need first-class spoken and
|
||||
visual confirmation: "yes", "no", "cancel", "confirm", plus a visible and
|
||||
audible countdown for destructive actions.
|
||||
- **Voice session memory/status** — add a compact "where are we?" summary for
|
||||
the current voice task: active objective, last tool result, pending next step,
|
||||
and whether the agent is waiting on the user.
|
||||
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
|
||||
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
|
||||
spoken tool progress, confirmations, and overlay availability.
|
||||
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
|
||||
is solved. The target path is proper AEC, playback-ducking, and a rule that
|
||||
output audio can never become a user turn.
|
||||
- **Audio quality guardrails** — normalize output volume across realtime and
|
||||
fallback TTS providers, keep pronunciation hints/profile voice tuning, and
|
||||
measure provider-specific delay, chunk gaps, and tail clipping.
|
||||
- **Pluggable Realtime Agent media transports** — add an OpenAI-first WebRTC
|
||||
transport option for Realtime Agent so mobile audio can use provider-native
|
||||
jitter buffering, interruption, and media handling instead of only relay
|
||||
WebSocket PCM. Design this as a provider transport interface
|
||||
(`websocket`, `webrtc`, future `livekit`/SIP-style bridges) so other
|
||||
realtime providers can opt in without forking the Hermes broker/tool
|
||||
contract. Hermes must still own tools, memory, confirmations, current data,
|
||||
and durable transcript state.
|
||||
- **Voice engine selector** — implemented as an opt-in experimental Realtime
|
||||
Agent engine in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
Follow-up work is provider-native turn-taking, richer confirmation handling,
|
||||
and quality/latency evaluation before promotion beyond Experimental.
|
||||
- **Realtime-native Hermes bridge prototype** — first relay-brokered slice
|
||||
implemented in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
Remaining work: let OpenAI/xAI realtime sessions own more of the live speech
|
||||
turn while still proxying every tool, confirmation, memory, and Android bridge
|
||||
action through Hermes/relay safety.
|
||||
|
||||
---
|
||||
|
||||
## Research / open questions
|
||||
|
||||
### Proper Hermes plugin / skill / tool distribution
|
||||
|
||||
**Status:** open question, no plan yet.
|
||||
|
||||
We currently distribute Hermes-Relay via a one-shot `install.sh` that clones the repo, `pip install -e`s the package into the user's hermes-agent venv, and registers `skills/` via the `external_dirs` config knob. This works but it's a custom protocol — every project that wants to ship a Hermes plugin reinvents it.
|
||||
|
||||
Things to look into:
|
||||
|
||||
- **Does upstream hermes-agent have or plan a canonical plugin registry / package format?** If yes, we should migrate to it. If no, we may want to propose one upstream so third-party plugins (ours and others) get a standard install path.
|
||||
- **Skill distribution as separate from plugin distribution** — right now skills ride along with the plugin install via `external_dirs`. Should skills be installable independently (e.g. `hermes skill install <git-url>`)? Would that fragment maintenance or improve reuse?
|
||||
- **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 (`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/`.
|
||||
|
||||
---
|
||||
|
||||
## Smaller deferred items
|
||||
|
||||
- **MediaProjection consent flow** — wired in MainActivity (2026-04-12), needs end-to-end test on a real device
|
||||
- **WorkManager upgrade for auto-disable timer** — currently a coroutine `Job + delay()` in `AutoDisableWorker.kt`; documented at top of file. Upgrade when androidx.work joins the classpath
|
||||
- **Wave 3 voice-bridge multi-turn confirmation** — currently a 5s TTS countdown with cancel; conversational confirmation is the follow-up
|
||||
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
|
||||
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
|
||||
- **`llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
|
||||
- **`markdown-renderer` 0.40.x API update** — pinned at `0.30.0` in `gradle/libs.versions.toml` because 0.40.2 introduced breaking API changes that `app/src/main/kotlin/com/hermesandroid/relay/ui/components/MarkdownContent.kt` hasn't been updated for. Specifically: `markdownColor()` drops `codeText`/`linkText`, `MarkdownCodeBlock`/`MarkdownCodeFence` inner lambdas now take a 3rd `TextStyle` arg, and `MarkdownHighlightedCode`'s 3rd param is now `TextStyle` instead of `Highlights.Builder`. Dependabot auto-merged the bump on 2026-04-13 which silently broke CI; reverted for the v0.3.0 release. Update requires reading the new library API docs and testing in Studio — not a blind fix. Consider adding a dependabot ignore rule for `markdown-renderer` major bumps until this is handled.
|
||||
- **Dependabot auto-merge guardrails** — Dependabot merged breaking bumps despite CI failing. Investigate why `.github/workflows/dependabot-auto-merge.yml` isn't gating on CI status, and consider adding an ignore rule for packages we know need manual attention on major bumps (`markdown-renderer`, compose BOM, activity-compose).
|
||||
+97
-70
@@ -1,4 +1,3 @@
|
||||
import java.io.File
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
@@ -8,12 +7,35 @@ plugins {
|
||||
alias(libs.plugins.play.publisher)
|
||||
}
|
||||
|
||||
// Rename output artifacts to include the app version. AGP respects
|
||||
// `archivesName` for both APK (assemble*) and AAB (bundle*) outputs, so
|
||||
// this single line produces `hermes-relay-<version>-<flavor>-<buildType>`
|
||||
// filenames across debug, release, and per-flavor variants. The version
|
||||
// is pulled from libs.versions.toml so bumping via scripts/bump-version.sh
|
||||
// keeps artifact names in sync with no second source of truth.
|
||||
base {
|
||||
archivesName.set("hermes-relay-${libs.versions.appVersionName.get()}")
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -32,11 +54,10 @@ android {
|
||||
Properties().apply { localProps.inputStream().use { stream -> load(stream) } }
|
||||
} else null
|
||||
|
||||
storeFile = file(
|
||||
System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
)
|
||||
val keystorePath = System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
storeFile = rootProject.file(keystorePath)
|
||||
storePassword = System.getenv("HERMES_KEYSTORE_PASSWORD")
|
||||
?: props?.getProperty("hermes.keystore.password") ?: ""
|
||||
keyAlias = System.getenv("HERMES_KEY_ALIAS")
|
||||
@@ -46,6 +67,45 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bridge release tracks ─────────────────────────────────────────────────
|
||||
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
|
||||
// media, notification companion, relay sessions, and status. It does not
|
||||
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
|
||||
// control, SMS/call/contact/location, or unattended-control permissions.
|
||||
//
|
||||
// googlePlay — canonical Play Store install. Bridge Core only.
|
||||
//
|
||||
// sideload — Device Control for users who install directly (GitHub
|
||||
// Releases, F-Droid, ADB). AccessibilityService, gestures,
|
||||
// screenshots, overlay/status chip, and phone utilities are
|
||||
// declared in the sideload manifest.
|
||||
//
|
||||
// applicationIdSuffix decision: sideload gets `.sideload` so both tracks can
|
||||
// coexist on the same device. The Play build keeps the base
|
||||
// `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") {
|
||||
dimension = "track"
|
||||
// No applicationIdSuffix — this IS the canonical Play Store install.
|
||||
}
|
||||
create("sideload") {
|
||||
dimension = "track"
|
||||
applicationIdSuffix = ".sideload"
|
||||
versionNameSuffix = "-sideload"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("boolean", "DEV_MODE", "true")
|
||||
@@ -90,6 +150,23 @@ android {
|
||||
kotlin.srcDirs("src/androidTest/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
// JVM unit tests run against the stubbed Android SDK jar, where every
|
||||
// platform API method throws RuntimeException("... not mocked") by
|
||||
// default. With returnDefaultValues = true, those stubs instead
|
||||
// return the Java defaults (0 / null / false / empty). This unblocks
|
||||
// tests that exercise production code calling android.util.Log (which
|
||||
// UnattendedAccessManager does defensively in catch blocks) without
|
||||
// needing every test to mockkStatic(Log::class). Regression discovered
|
||||
// when v0.5.0 CI release caught UnattendedAccessManagerTest's
|
||||
// acquireForAction_uninitialized + refreshKeyguardState_threwException
|
||||
// both failing with RuntimeException from unmocked Log.w calls.
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
// Robolectric (VoicePlayerTest) needs merged Android resources +
|
||||
// manifest on the unit-test classpath to bootstrap its sandbox.
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
// Google Play Publisher — optional automated upload to Play Console.
|
||||
@@ -130,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)
|
||||
@@ -141,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)
|
||||
@@ -174,73 +259,15 @@ dependencies {
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
// MockWebServer for ADR 24 EndpointResolver tests — probes HEAD /health
|
||||
// across priority groups against real local sockets so the behavior we
|
||||
// validate matches on-device.
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
// ─── 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")
|
||||
}
|
||||
}
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the v0.4.1 [BridgeMasterToggle] polish: tapping
|
||||
* the Switch to ON while accessibility hasn't been granted must route
|
||||
* through the new [BridgeMasterToggle.onAccessibilityNeeded] callback
|
||||
* instead of silently flipping the toggle on.
|
||||
*
|
||||
* The Switch is intentionally *not* `enabled = false` when accessibility
|
||||
* is missing — a disabled Switch swallows taps silently on Android, which
|
||||
* reads as a broken control to users. Instead the onCheckedChange handler
|
||||
* short-circuits through onAccessibilityNeeded so BridgeScreen can surface
|
||||
* an "Open Settings" snackbar.
|
||||
*/
|
||||
class BridgeMasterToggleTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun switch_whenAccessibilityMissing_togglingOn_callsOnAccessibilityNeeded() {
|
||||
var toggleCalls = 0
|
||||
var toggleLastValue: Boolean? = null
|
||||
var needsCalls = 0
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeMasterToggle(
|
||||
enabled = false,
|
||||
status = null,
|
||||
accessibilityGranted = false,
|
||||
onToggle = { wantsOn ->
|
||||
toggleCalls++
|
||||
toggleLastValue = wantsOn
|
||||
},
|
||||
onAccessibilityNeeded = { needsCalls++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The Switch is the only toggleable node in this composable — find it
|
||||
// without relying on an accessibility label (the design spec doesn't
|
||||
// currently give the Switch one) to avoid flakiness if the label
|
||||
// changes.
|
||||
composeTestRule.onNode(isToggleable()).assertIsOff()
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"onAccessibilityNeeded should fire exactly once when user taps " +
|
||||
"to enable without accessibility permission",
|
||||
1,
|
||||
needsCalls,
|
||||
)
|
||||
assertEquals(
|
||||
"onToggle must NOT fire on the blocked-enable path — otherwise " +
|
||||
"callers would see a phantom enable event even though a11y " +
|
||||
"isn't granted",
|
||||
0,
|
||||
toggleCalls,
|
||||
)
|
||||
assertEquals(
|
||||
"sanity: onToggle lastValue should remain untouched",
|
||||
null,
|
||||
toggleLastValue,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switch_whenAccessibilityGrantedAndOn_togglingOff_callsOnToggleFalse() {
|
||||
// Complementary path: when accessibility IS granted and the Switch
|
||||
// is currently checked, flipping it off must go through onToggle
|
||||
// (not onAccessibilityNeeded). Locks in that the new conditional
|
||||
// didn't accidentally hijack the normal toggle-off path.
|
||||
var toggleCalls = 0
|
||||
var toggleLastValue: Boolean? = null
|
||||
var needsCalls = 0
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeMasterToggle(
|
||||
enabled = true,
|
||||
status = null,
|
||||
accessibilityGranted = true,
|
||||
onToggle = { wantsOn ->
|
||||
toggleCalls++
|
||||
toggleLastValue = wantsOn
|
||||
},
|
||||
onAccessibilityNeeded = { needsCalls++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertEquals("onToggle must fire exactly once", 1, toggleCalls)
|
||||
assertFalse(
|
||||
"toggling a checked switch should pass wantsOn=false",
|
||||
toggleLastValue!!,
|
||||
)
|
||||
assertEquals(
|
||||
"onAccessibilityNeeded must NOT fire on the normal toggle-off path",
|
||||
0,
|
||||
needsCalls,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switch_whenAccessibilityGrantedAndOff_togglingOn_callsOnToggleTrue() {
|
||||
var toggleCalls = 0
|
||||
var toggleLastValue: Boolean? = null
|
||||
var needsCalls = 0
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeMasterToggle(
|
||||
enabled = false,
|
||||
status = null,
|
||||
accessibilityGranted = true,
|
||||
onToggle = { wantsOn ->
|
||||
toggleCalls++
|
||||
toggleLastValue = wantsOn
|
||||
},
|
||||
onAccessibilityNeeded = { needsCalls++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertEquals("onToggle must fire exactly once", 1, toggleCalls)
|
||||
assertTrue(
|
||||
"toggling an unchecked switch with a11y granted should pass wantsOn=true",
|
||||
toggleLastValue!!,
|
||||
)
|
||||
assertEquals(
|
||||
"onAccessibilityNeeded must NOT fire when a11y is already granted",
|
||||
0,
|
||||
needsCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PowerFeatureGateUiTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun requiresPairingCard_showsPairToUnlock() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Requires pairing").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair to unlock").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("This feature uses relay grants", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredPairingCard_showsPairAgain() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Pairing expired").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair again").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardSignInCard_usesDashboardLanguage() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Manage",
|
||||
summary = "Open dashboard-backed management features.",
|
||||
status = PowerFeatureGateStatus.DashboardSignInRequired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Dashboard sign-in required").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Open sign-in").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the v0.4.1 [UnattendedAccessRow] `masterEnabled`
|
||||
* gate. When the master Agent Control switch is off, the unattended-access
|
||||
* Switch must be disabled and the subtitle must advise the user to enable
|
||||
* the master switch first — otherwise they'd flip unattended on and see
|
||||
* nothing happen (the wake-lock acquire path short-circuits on master-off
|
||||
* regardless of this flag).
|
||||
*/
|
||||
class UnattendedAccessRowTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun masterDisabled_switchIsDisabled() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun masterDisabled_subtitleExplainsWhy() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Requires Agent Control", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("enable the master switch above first", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun masterEnabled_switchIsInteractive() {
|
||||
// Regression: don't accidentally disable the Switch in the common path.
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNode(isToggleable()).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun masterEnabled_offSubtitle_doesNotMentionMasterRequirement() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
UnattendedAccessRow(
|
||||
enabled = false,
|
||||
warningSeen = true,
|
||||
credentialLockDetected = false,
|
||||
onToggle = {},
|
||||
onWarningSeen = {},
|
||||
masterEnabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The off-but-master-on subtitle is the "actions only land when the
|
||||
// screen is already on" copy, NOT the master-gated one.
|
||||
composeTestRule
|
||||
.onNodeWithText("bridge actions only land", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies the voice overlay renders exactly one transcript row per turn
|
||||
* even when [VoiceUiState.transcribedText] and [VoiceUiState.responseText]
|
||||
* are populated alongside the same content in [ChatMessage]s.
|
||||
*
|
||||
* Pre-fix bug: the overlay rendered from THREE sources — `transcribedText`
|
||||
* (a top "YOU" row), `responseText` (a `StreamingResponseRow`), and
|
||||
* `transcriptMessages` (the scrolling chat-history list). During a voice
|
||||
* turn ChatViewModel committed the user's send and streamed the assistant
|
||||
* reply into its own message flow, so the same content ended up in both
|
||||
* `transcribedText`/`responseText` AND in `transcriptMessages` — every
|
||||
* turn appeared twice on screen.
|
||||
*
|
||||
* Fix: the overlay consumes only `transcriptMessages` now. This test asserts
|
||||
* that even when the other two fields are set, the on-screen count of each
|
||||
* turn's text is exactly one.
|
||||
*/
|
||||
class VoiceModeOverlayTranscriptTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun userAndAgentTurns_renderExactlyOnce_evenWithLegacyFieldsSet() {
|
||||
val userText = "Hello agent"
|
||||
val agentText = "Hi there"
|
||||
|
||||
val messages = listOf(
|
||||
ChatMessage(
|
||||
id = "u-1",
|
||||
role = MessageRole.USER,
|
||||
content = userText,
|
||||
timestamp = 1L,
|
||||
isStreaming = false,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "a-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = agentText,
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Speaking,
|
||||
// Legacy fields — if the overlay still read these,
|
||||
// each turn's text would appear twice.
|
||||
transcribedText = userText,
|
||||
responseText = agentText,
|
||||
interactionMode = InteractionMode.TapToTalk,
|
||||
),
|
||||
onMicTap = {},
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
transcriptMessages = messages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val userOccurrences = composeTestRule
|
||||
.onAllNodesWithText(userText, substring = false)
|
||||
.fetchSemanticsNodes()
|
||||
.size
|
||||
val agentOccurrences = composeTestRule
|
||||
.onAllNodesWithText(agentText, substring = false)
|
||||
.fetchSemanticsNodes()
|
||||
.size
|
||||
|
||||
assertEquals(
|
||||
"user turn must render exactly once (no double-entry from transcribedText)",
|
||||
1,
|
||||
userOccurrences,
|
||||
)
|
||||
assertEquals(
|
||||
"agent turn must render exactly once (no double-entry from responseText)",
|
||||
1,
|
||||
agentOccurrences,
|
||||
)
|
||||
}
|
||||
}
|
||||
+64
-162
@@ -1,22 +1,19 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the onboarding pager flow.
|
||||
*
|
||||
* These tests require an Android device or emulator because they use
|
||||
* Compose UI testing APIs and interact with real Compose components.
|
||||
* Instrumented tests for the Standard-first onboarding pager.
|
||||
*/
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@@ -24,270 +21,175 @@ class OnboardingFlowTest {
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
onComplete = { _, _, _ -> }
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page 1: Welcome ---
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesRelayTitle() {
|
||||
fun firstPage_showsHermesForAndroidTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes-Relay")
|
||||
.onNodeWithText("Hermes-Relay for Android")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsWelcomeDescription() {
|
||||
fun firstPage_showsStandardFirstDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
|
||||
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Skip button ---
|
||||
|
||||
@Test
|
||||
fun skipButton_isAlwaysVisible_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip")
|
||||
.onNodeWithText("Standard")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Navigation: Next button ---
|
||||
|
||||
@Test
|
||||
fun nextButton_isDisplayed_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.onNodeWithText("Advanced")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Setup Guide")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Docs")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toPage2() {
|
||||
fun nextButton_navigatesForward_toChatPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1 -> Page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2 is "Talk to Your Agent"
|
||||
composeTestRule
|
||||
.onNodeWithText("Talk to Your Agent")
|
||||
.onNodeWithText("Chat")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughAllPages() {
|
||||
fun canNavigateForward_throughStandardAndPowerPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1: Hermes-Relay (Welcome)
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2: Talk to Your Agent (Chat)
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 3: Remote Terminal
|
||||
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 4: Device Bridge
|
||||
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Connect").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 5: Connect to Hermes
|
||||
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 6: Relay Server (last page)
|
||||
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Back button ---
|
||||
|
||||
@Test
|
||||
fun backButton_hiddenOnFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// On page 1, Back should not exist
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_visibleOnPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Go to page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
|
||||
// Go back to page 1
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Page 5: Connect page ---
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiServerUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4) // 0-indexed, page 5 is index 4
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiKeyField() {
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Key (optional)", substring = true)
|
||||
.onNodeWithText("Standard Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_whereDoIFindThis_showsHelpDialog() {
|
||||
fun standardSetup_showsApiFields() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Tap "Where do I find this?"
|
||||
composeTestRule
|
||||
.onNodeWithText("Where do I find this?")
|
||||
.performClick()
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should show
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_helpDialog_canBeDismissed() {
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Where do I find this?").performClick()
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog is showing
|
||||
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
|
||||
|
||||
// Dismiss it
|
||||
composeTestRule.onNodeWithText("Got it").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should be gone
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- Page 6: Relay page ---
|
||||
|
||||
@Test
|
||||
fun relayPage_showsOptionalMessaging() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5) // Last page
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("This is optional", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayPage_showsRelayUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Relay URL (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Get Started button ---
|
||||
|
||||
@Test
|
||||
fun lastPage_showsGetStartedButton() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
// Default URL is "http://localhost:8642" which is non-blank
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.onNodeWithText("Connect")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
// --- Skip button visibility across pages ---
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnAllPages() {
|
||||
fun connectPage_keepsPairingOptional() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Check skip on first page
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
|
||||
// Navigate through all pages and check skip
|
||||
for (i in 0 until 5) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
repeat(4) {
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now — set up later in Settings")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,52 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for Terminal and Bridge empty state screens.
|
||||
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
|
||||
*/
|
||||
class EmptyStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
// --- Terminal Screen ---
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTitle() {
|
||||
fun terminalScreen_showsCurrentTopBar() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val terminalViewModel = TerminalViewModel(app)
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote Terminal")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPhase2Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 2")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Secure shell access", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Full ANSI terminal emulator", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("tmux session management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Bridge Screen ---
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTitle() {
|
||||
fun bridgeScreen_showsCurrentTopBar() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Device Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPhase3Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Agent-controlled device interaction", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Permission management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Google Play flavor manifest overlay.
|
||||
|
||||
Merged on top of `app/src/main/AndroidManifest.xml` by AGP when the
|
||||
`googlePlayDebug` / `googlePlayRelease` variants are built.
|
||||
|
||||
Google Play ships Hermes Bridge Core only. It intentionally does not merge
|
||||
any Device Control services or permissions.
|
||||
|
||||
This file is intentionally kept as an empty overlay so future flavor-specific
|
||||
permissions / activities have an obvious home. Mirror structural additions
|
||||
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
|
||||
track-specific.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Media3 ExoPlayer contributes a power-management permission from its
|
||||
library manifest. Strip it from the merged Play artifact. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WAKE_LOCK"
|
||||
tools:node="remove" />
|
||||
|
||||
<application />
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,53 @@
|
||||
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 ===
|
||||
*
|
||||
* Flavor-specific factory function that [com.hermesandroid.relay.viewmodel.VoiceViewModel]
|
||||
* calls exactly once during `initialize()`. The Play build always returns
|
||||
* the no-op handler.
|
||||
*
|
||||
* Both the `googlePlay` and `sideload` flavors export a function with this
|
||||
* exact signature + package so `VoiceViewModel` has a single static call
|
||||
* site and no reflection.
|
||||
*
|
||||
* 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) ===
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
/**
|
||||
* === PHASE3-voice-intents (googlePlay flavor): no-op voice→bridge handler ===
|
||||
*
|
||||
* On the Google Play track we ship the conservative feature description:
|
||||
* no accessibility-service usage, no device-control voice routing.
|
||||
*
|
||||
* This implementation never references any bridge / accessibility class and
|
||||
* always returns [IntentResult.NotApplicable] so [VoiceViewModel] falls
|
||||
* through to normal chat for every utterance.
|
||||
*
|
||||
* Sibling: `app/src/sideload/kotlin/.../VoiceBridgeIntentHandlerImpl.kt`
|
||||
* ships the real classifier.
|
||||
*/
|
||||
internal class NoopVoiceBridgeIntentHandler : VoiceBridgeIntentHandler {
|
||||
|
||||
override suspend fun tryHandle(transcribedText: String): IntentResult {
|
||||
// Play APK path: every utterance is chat. No classification runs,
|
||||
// no bridge envelopes are sent. Nothing to cancel either.
|
||||
return IntentResult.NotApplicable
|
||||
}
|
||||
|
||||
override fun cancelPending() {
|
||||
// no-op — nothing to cancel on Play.
|
||||
}
|
||||
|
||||
override fun hasPendingDestructive(): Boolean = false
|
||||
}
|
||||
|
||||
// === END PHASE3-voice-intents (googlePlay) ===
|
||||
@@ -22,6 +22,9 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -39,6 +42,18 @@
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- === PHASE3-notif-listener: notification companion service === -->
|
||||
<service
|
||||
android:name=".notifications.HermesNotificationCompanion"
|
||||
android:label="@string/notification_companion_label"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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,7 @@
|
||||
v0.1.0 — First Release
|
||||
v0.8.1 - Voice mode crash fix
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
Voice
|
||||
* Fixed a crash that could hit voice mode when barge-in was enabled on the
|
||||
legacy text-to-speech path — the agent's first words no longer cut off
|
||||
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
|
||||
paths were never affected.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,22 +1,69 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
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.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val connectionViewModel: ConnectionViewModel by viewModels()
|
||||
|
||||
// === PHASE3-bridge-ui-followup: MediaProjection consent flow ===
|
||||
// ActivityResultLauncher for the system screen-capture consent dialog.
|
||||
// Must be registered BEFORE the activity reaches STARTED state, hence
|
||||
// declared as a property (registerForActivityResult is safe to call
|
||||
// from a property initializer on ComponentActivity).
|
||||
//
|
||||
// We do NOT call MediaProjectionHolder directly from here. On Android
|
||||
// 14+, getMediaProjection() must run from inside a foreground service
|
||||
// that has already called startForeground(type=mediaProjection), and
|
||||
// that startForeground call must happen AFTER consent. So we hand the
|
||||
// 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
|
||||
// launch the dialog without leaking this Activity through the VM.
|
||||
private val mediaProjectionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val data = result.data
|
||||
if (!BuildFlavor.isSideload) {
|
||||
Log.w(TAG, "Ignoring MediaProjection result on Google Play Bridge Core build")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
Log.i(TAG, "MediaProjection consent granted — handing off to FGS")
|
||||
BridgeForegroundService.grantMediaProjection(this, result.resultCode, data)
|
||||
} else {
|
||||
Log.i(TAG, "MediaProjection consent rejected (resultCode=${result.resultCode})")
|
||||
}
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
|
||||
@@ -41,8 +88,104 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// === PHASE3-bridge-ui-followup: install MediaProjection requester ===
|
||||
// Hand the launcher to the process-singleton rendezvous so
|
||||
// BridgeViewModel.requestScreenCapture() can fire the consent
|
||||
// dialog without holding an Activity reference.
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.install {
|
||||
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
try {
|
||||
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
|
||||
// === PHASE3-safety-rails-followup: deep-link nav route from external intents ===
|
||||
// Foreground services, broadcast receivers, and shortcut intents can
|
||||
// attach EXTRA_NAV_ROUTE to request that RelayApp navigate to a
|
||||
// specific Compose route on launch. The actual navigation happens
|
||||
// in RelayApp's NavRouteRequest collector — we just pump the request
|
||||
// into the SharedFlow here.
|
||||
consumeNavRouteIntent(intent)
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
setContent {
|
||||
RelayApp()
|
||||
}
|
||||
window.decorView.post {
|
||||
ComposeArrWorkaround.disableForViewTree(window.decorView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// === PHASE3-safety-rails-followup: deep-link nav route on re-launch ===
|
||||
// Same as onCreate but for the singleTask / FLAG_ACTIVITY_CLEAR_TOP
|
||||
// path: when the app is already running and the foreground service's
|
||||
// PendingIntent re-launches us, the new intent comes through here
|
||||
// instead of onCreate. RelayApp's collector handles both cases.
|
||||
setIntent(intent)
|
||||
consumeNavRouteIntent(intent)
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
}
|
||||
|
||||
private fun consumeNavRouteIntent(intent: Intent?) {
|
||||
val route = intent?.getStringExtra(EXTRA_NAV_ROUTE) ?: return
|
||||
if (route.isBlank()) return
|
||||
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.
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
}
|
||||
// Re-probe the credential-lock state on resume so the Bridge
|
||||
// tab badge updates immediately if the user just changed their
|
||||
// lock screen in system Settings between app sessions.
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// === PHASE3-bridge-ui-followup: clear MediaProjection requester ===
|
||||
// Drop the launcher closure so we don't hold a stale Activity ref
|
||||
// after destroy. ScreenCaptureRequester.request() will return false
|
||||
// until the next MainActivity instance reinstalls itself.
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.uninstall()
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
|
||||
/**
|
||||
* Intent extra carrying a Compose nav route. Set by foreground services
|
||||
* (and any other external launcher) on the `Intent(this, MainActivity::class.java)`
|
||||
* they fire to request RelayApp navigate to a specific destination on
|
||||
* launch / re-launch.
|
||||
*/
|
||||
const val EXTRA_NAV_ROUTE = "com.hermesandroid.relay.EXTRA_NAV_ROUTE"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
package com.hermesandroid.relay.accessibility
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
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
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Phase 3 — accessibility `accessibility-runtime`
|
||||
*
|
||||
* Coroutine-driven status reporter. Emits a `bridge.status` envelope every
|
||||
* [TICK_MS] (default 30 seconds) describing the phone's current state.
|
||||
*
|
||||
* ## Wire format (Phase 3 `phase3-status` expansion)
|
||||
*
|
||||
* The envelope payload is the full structured status contract that the
|
||||
* relay-side [plugin.relay.channels.bridge.BridgeHandler] caches and
|
||||
* serves via `GET /bridge/status`. It MUST match the JSON contract used
|
||||
* by `android_phone_status()`:
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "device": {
|
||||
* "name": "SM-S921U",
|
||||
* "battery_percent": 78,
|
||||
* "screen_on": true,
|
||||
* "current_app": "com.android.chrome"
|
||||
* },
|
||||
* "bridge": {
|
||||
* "master_enabled": true,
|
||||
* "accessibility_granted": true,
|
||||
* "screen_capture_granted": true,
|
||||
* "overlay_granted": true,
|
||||
* "notification_listener_granted": true
|
||||
* },
|
||||
* "safety": {
|
||||
* "blocklist_count": 30,
|
||||
* "destructive_verbs_count": 12,
|
||||
* "auto_disable_minutes": 30,
|
||||
* "auto_disable_at_ms": null
|
||||
* },
|
||||
* "unattended": {
|
||||
* "supported": true,
|
||||
* "enabled": false,
|
||||
* "credential_lock_detected": true
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `bridge.device_control_supported` and `unattended.supported` are false on
|
||||
* the googlePlay flavor — the Play APK ships Bridge Core without
|
||||
* AccessibilityService, wake locks, overlays, screenshots, or unattended
|
||||
* control — which lets the agent avoid attempting sideload-only tools.
|
||||
*
|
||||
* The legacy top-level keys (`screen_on`, `battery`, `current_app`,
|
||||
* `accessibility_enabled`, `ts`) are ALSO emitted for backwards
|
||||
* compatibility with any consumer that hasn't been updated to read the
|
||||
* nested `device` / `bridge` / `safety` groups yet. The fields are
|
||||
* cheap and additive, and the relay caches the whole payload verbatim.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* The reporter is a no-op until [start] is called, and [stop] is
|
||||
* idempotent. [com.hermesandroid.relay.viewmodel.ConnectionViewModel]
|
||||
* owns it and ties the lifecycle to the WSS connection — reporting
|
||||
* while disconnected is a waste of battery and the multiplexer would
|
||||
* drop the envelopes silently anyway.
|
||||
*
|
||||
* ## Out-of-band pushes
|
||||
*
|
||||
* Callers may invoke [pushNow] to force an immediate emission outside
|
||||
* the 30 s tick — used by `ConnectionViewModel` when the master toggle
|
||||
* flips, so the relay-side cache updates immediately instead of waiting
|
||||
* up to 30 s for the next periodic tick.
|
||||
*/
|
||||
class BridgeStatusReporter(
|
||||
private val context: Context,
|
||||
private val multiplexer: ChannelMultiplexer,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BridgeStatusReporter"
|
||||
private const val TICK_MS = 30_000L
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
/**
|
||||
* Start the reporter. Safe to call multiple times — if a job is
|
||||
* already running we log and return.
|
||||
*/
|
||||
fun start() {
|
||||
if (job?.isActive == true) {
|
||||
Log.v(TAG, "already running")
|
||||
return
|
||||
}
|
||||
job = scope.launch {
|
||||
// Send an immediate first tick so the agent sees fresh status
|
||||
// as soon as the WSS connection comes up, rather than waiting
|
||||
// up to 30s for the first periodic tick.
|
||||
while (isActive) {
|
||||
try {
|
||||
emitTick()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "status emit failed: ${t.message}")
|
||||
}
|
||||
delay(TICK_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an immediate status emission outside the [TICK_MS] cadence.
|
||||
* Useful when state changes that matter to the agent (master toggle
|
||||
* flip, accessibility service connected, etc.) — rather than waiting
|
||||
* up to 30 s for the relay cache to refresh.
|
||||
*
|
||||
* No-op if [start] hasn't been called yet — in that case the next
|
||||
* start() will fire a tick anyway.
|
||||
*/
|
||||
fun pushNow() {
|
||||
if (job?.isActive != true) {
|
||||
Log.v(TAG, "pushNow() called while stopped — no-op")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
emitTick()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "pushNow emit failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one status envelope from live phone state and push it through
|
||||
* the multiplexer. Exposed as internal-ish so unit tests can drive
|
||||
* a single tick without spinning the coroutine.
|
||||
*/
|
||||
internal fun emitTick() {
|
||||
val screenOn = try {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
pm.isInteractive
|
||||
} catch (t: Throwable) {
|
||||
Log.v(TAG, "screenOn probe failed: ${t.message}")
|
||||
false
|
||||
}
|
||||
|
||||
val battery = try {
|
||||
// Prefer the BatteryManager property API — it's the only
|
||||
// always-accurate source on modern Android. The legacy sticky
|
||||
// intent path is fine too but we'd have to parse two fields.
|
||||
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||
bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
} catch (t: Throwable) {
|
||||
Log.v(TAG, "battery probe failed: ${t.message}")
|
||||
-1
|
||||
}
|
||||
|
||||
// If the property API returns 0 (some OEM firmwares do) fall back
|
||||
// to the sticky intent read.
|
||||
val batteryFinal = if (battery <= 0) readBatteryViaIntent() else battery
|
||||
|
||||
val currentApp = HermesAccessibilityService.instance?.currentApp
|
||||
val accessibilityGranted = HermesAccessibilityService.instance != null
|
||||
val masterEnabled = HermesAccessibilityService.instance?.isMasterEnabled() ?: false
|
||||
val deviceControlSupported = BuildFlavor.isSideload
|
||||
|
||||
// Screen-capture grant — the process-singleton holder is non-null
|
||||
// iff the user granted MediaProjection consent this session.
|
||||
val screenCaptureGranted = try {
|
||||
MediaProjectionHolder.projection != null
|
||||
} catch (t: Throwable) {
|
||||
Log.v(TAG, "screen_capture probe failed: ${t.message}")
|
||||
false
|
||||
}
|
||||
|
||||
val overlayGranted = try {
|
||||
Settings.canDrawOverlays(context)
|
||||
} catch (t: Throwable) {
|
||||
Log.v(TAG, "overlay probe failed: ${t.message}")
|
||||
false
|
||||
}
|
||||
|
||||
val notificationListenerGranted = try {
|
||||
val enabled = Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
"enabled_notification_listeners"
|
||||
)
|
||||
enabled?.contains(context.packageName) ?: false
|
||||
} catch (t: Throwable) {
|
||||
Log.v(TAG, "notification_listener probe failed: ${t.message}")
|
||||
false
|
||||
}
|
||||
|
||||
// Safety snapshot — read lazily through the process-singleton
|
||||
// so we don't take a DI dep here. Falls back to zeros if the
|
||||
// safety manager hasn't been built yet (cold start).
|
||||
val safetyManager = BridgeSafetyManager.peek()
|
||||
val safetySnapshot = safetyManager?.settings?.value
|
||||
val blocklistCount = safetySnapshot?.blocklist?.size ?: 0
|
||||
val destructiveVerbsCount = safetySnapshot?.destructiveVerbs?.size ?: 0
|
||||
val autoDisableMinutes = safetySnapshot?.autoDisableMinutes ?: 0
|
||||
val autoDisableAtMs = safetyManager?.autoDisableAtMs?.value
|
||||
|
||||
val deviceName = Build.MODEL ?: "unknown"
|
||||
|
||||
val envelope = Envelope(
|
||||
channel = "bridge",
|
||||
type = "bridge.status",
|
||||
payload = buildJsonObject {
|
||||
// Nested structured groups matching the relay HTTP contract.
|
||||
put("device", buildJsonObject {
|
||||
put("name", deviceName)
|
||||
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("device_control_supported", deviceControlSupported)
|
||||
put("master_enabled", if (deviceControlSupported) masterEnabled else false)
|
||||
put(
|
||||
"accessibility_granted",
|
||||
if (deviceControlSupported) accessibilityGranted else false,
|
||||
)
|
||||
put(
|
||||
"screen_capture_granted",
|
||||
if (deviceControlSupported) screenCaptureGranted else false,
|
||||
)
|
||||
put("overlay_granted", if (deviceControlSupported) overlayGranted else false)
|
||||
put("notification_listener_granted", notificationListenerGranted)
|
||||
})
|
||||
put("safety", buildJsonObject {
|
||||
put("blocklist_count", blocklistCount)
|
||||
put("destructive_verbs_count", destructiveVerbsCount)
|
||||
put("auto_disable_minutes", autoDisableMinutes)
|
||||
if (autoDisableAtMs == null) {
|
||||
put("auto_disable_at_ms", JsonNull)
|
||||
} else {
|
||||
put("auto_disable_at_ms", autoDisableAtMs)
|
||||
}
|
||||
})
|
||||
|
||||
// v0.4.1: unattended-access state so the agent can decide
|
||||
// upfront whether commands will reach apps with the screen
|
||||
// off (instead of finding out reactively via the
|
||||
// keyguard_blocked error_code after a failed command).
|
||||
// `supported` is false on googlePlay — that flavor has no
|
||||
// wake-lock path and the user can't opt in even if they
|
||||
// wanted to. `credential_lock_detected` reflects whether
|
||||
// a PIN/pattern/biometric lock is currently configured;
|
||||
// when both `enabled=true` and this is true, commands
|
||||
// will wake the screen but stop at the lock screen.
|
||||
put("unattended", buildJsonObject {
|
||||
put("supported", deviceControlSupported)
|
||||
put(
|
||||
"enabled",
|
||||
if (deviceControlSupported) UnattendedAccessManager.enabled.value else false,
|
||||
)
|
||||
put(
|
||||
"credential_lock_detected",
|
||||
if (deviceControlSupported) {
|
||||
UnattendedAccessManager.credentialLockDetected.value
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// ── Legacy top-level fields (backwards compat) ────────
|
||||
// Kept so pre-phase3-status consumers still see the
|
||||
// same fields they're already parsing. New consumers
|
||||
// should read from the nested `device` / `bridge`
|
||||
// groups above.
|
||||
put("screen_on", screenOn)
|
||||
put("battery", batteryFinal)
|
||||
put("current_app", if (deviceControlSupported) currentApp ?: "unknown" else "unknown")
|
||||
put("accessibility_enabled", if (deviceControlSupported) accessibilityGranted else false)
|
||||
put("ts", System.currentTimeMillis())
|
||||
}
|
||||
)
|
||||
multiplexer.send(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy fallback for BatteryManager.getIntProperty returning 0 —
|
||||
* reads the sticky `ACTION_BATTERY_CHANGED` intent and computes
|
||||
* percentage manually.
|
||||
*/
|
||||
private fun readBatteryViaIntent(): Int = try {
|
||||
val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val placeholder: BroadcastReceiver? = null
|
||||
val battery = context.registerReceiver(null, filter)
|
||||
val level = battery?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val scale = battery?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||
if (level >= 0 && scale > 0) (level * 100 / scale) else -1
|
||||
} catch (t: Throwable) {
|
||||
Log.v(TAG, "battery intent fallback failed: ${t.message}")
|
||||
-1
|
||||
}
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
package com.hermesandroid.relay.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
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
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Phase 3 — accessibility `accessibility-runtime`
|
||||
*
|
||||
* Hermes's master `AccessibilityService` subclass. Provides the phone-side
|
||||
* execution layer for the bridge channel: the agent reads the screen,
|
||||
* taps/types/swipes, and captures screenshots through this service.
|
||||
*
|
||||
* # Lifecycle & singleton pattern
|
||||
*
|
||||
* Android instantiates `AccessibilityService` subclasses itself (through the
|
||||
* manifest declaration + user opt-in in Settings → Accessibility), so we
|
||||
* can't pass collaborators in via a constructor. The canonical workaround
|
||||
* is a weak-referenced singleton: on [onServiceConnected] the instance
|
||||
* registers itself with [Companion.instance], and on [onUnbind]/destroy it
|
||||
* clears itself. Any other code that needs to dispatch gestures (the
|
||||
* `BridgeCommandHandler`) asks for [instance] and bails out if it's null
|
||||
* (service not running / user hasn't granted permission).
|
||||
*
|
||||
* # Master enable / disable
|
||||
*
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes Relay` is
|
||||
* the hard switch — if it's off we never receive events. On top of that the
|
||||
* user can flip a soft master in Settings (`bridge_master_enabled`); when
|
||||
* that's false we still run (Android requires it to stay connected) but we
|
||||
* refuse to execute commands. [isMasterEnabled] is a StateFlow the UI
|
||||
* observes and the command handler checks before dispatching actions.
|
||||
*
|
||||
* # Event handling
|
||||
*
|
||||
* We subscribe to a minimal event set — `TYPE_WINDOW_STATE_CHANGED` to
|
||||
* track the foreground package (for [currentApp] status), and nothing else.
|
||||
* The XML resource (flavor-provided by Agent flavor-split) controls the exact flag
|
||||
* bitset. We deliberately do NOT process text / content-change events —
|
||||
* those fire thousands of times a minute and are pointless for our use
|
||||
* case (we read the UI tree on demand via `rootInActiveWindow`).
|
||||
*/
|
||||
class HermesAccessibilityService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HermesA11yService"
|
||||
|
||||
/** Master-enable DataStore key — read + toggled from Settings UI. */
|
||||
val KEY_BRIDGE_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
|
||||
|
||||
/**
|
||||
* Static reference to the live service instance, or null if the
|
||||
* service is not running. Written on [onServiceConnected],
|
||||
* cleared on [onUnbind] / [onDestroy].
|
||||
*
|
||||
* Read by [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
|
||||
* and by the Bridge UI screen (bridge-ui) to check live status.
|
||||
*/
|
||||
@Volatile
|
||||
var instance: HermesAccessibilityService? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Observe the DataStore-backed master enable flag for the bridge.
|
||||
* UI can collect this to drive the master-toggle switch; the service
|
||||
* itself also polls it via [isMasterEnabled] before executing commands.
|
||||
*/
|
||||
fun masterEnabledFlow(context: Context): Flow<Boolean> =
|
||||
context.applicationContext.relayDataStore.data
|
||||
.map { prefs -> prefs[KEY_BRIDGE_MASTER_ENABLED] ?: false }
|
||||
|
||||
/**
|
||||
* Persist a new master-enable value. Called from Settings UI when the
|
||||
* user flips the switch, and from [BridgeStatusReporter] / safety
|
||||
* rails when auto-disable timers fire.
|
||||
*/
|
||||
suspend fun setMasterEnabled(context: Context, enabled: Boolean) {
|
||||
context.applicationContext.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_BRIDGE_MASTER_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val screenReader = ScreenReader()
|
||||
private val screenHasher = ScreenHasher()
|
||||
private var _actionExecutor: ActionExecutor? = null
|
||||
|
||||
/**
|
||||
* Service-scoped coroutine context. Started fresh in
|
||||
* [onServiceConnected], cancelled in [onUnbind] / [onDestroy]. Used to
|
||||
* observe the DataStore-backed master toggle and pump it into
|
||||
* [cachedMasterEnabled] so [BridgeCommandHandler] can do a non-suspend
|
||||
* gate check on every inbound command.
|
||||
*
|
||||
* Without this collector the cache stays at its `false` default and
|
||||
* the gate refuses every command — that was the original Phase 3
|
||||
* "bridge always disabled" bug.
|
||||
*
|
||||
* Re-created on each connect because cancelled `SupervisorJob`s can't
|
||||
* be reused, and Android may rebind the same service instance after
|
||||
* an unbind on rare config changes.
|
||||
*/
|
||||
@Volatile
|
||||
private var serviceScope: CoroutineScope? = null
|
||||
|
||||
/**
|
||||
* Cached package name of the currently-foregrounded app. Updated on
|
||||
* every `TYPE_WINDOW_STATE_CHANGED` event. Read by the status reporter
|
||||
* for the `current_app` field in `bridge.status`.
|
||||
*/
|
||||
@Volatile
|
||||
var currentApp: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Public accessor for the lazily-constructed [ActionExecutor]. The
|
||||
* executor needs a back-reference to the service (for `dispatchGesture`),
|
||||
* so we can only build it after Android has fully constructed us.
|
||||
*/
|
||||
val actionExecutor: ActionExecutor
|
||||
get() = _actionExecutor ?: ActionExecutor(this).also { _actionExecutor = it }
|
||||
|
||||
/** 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
|
||||
Log.i(TAG, "HermesAccessibilityService connected")
|
||||
// Feed the master-toggle cache for the lifetime of this service
|
||||
// binding. Re-created on each connect — see [serviceScope] KDoc.
|
||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
serviceScope = scope
|
||||
scope.launch {
|
||||
masterEnabledFlow(this@HermesAccessibilityService).collect { enabled ->
|
||||
cachedMasterEnabled = enabled
|
||||
Log.d(TAG, "master toggle cached: $enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (event == null) return
|
||||
|
||||
when (event.eventType) {
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
|
||||
val pkg = event.packageName?.toString()
|
||||
if (!pkg.isNullOrBlank()) {
|
||||
currentApp = pkg
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Other event types are declared in the config XML for
|
||||
// future safety rails (blocklist enforcement via
|
||||
// content-change events) but we deliberately no-op here
|
||||
// today. Filtering happens inside the config flag bitset
|
||||
// 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() {
|
||||
// Called by the system when it wants us to drop any in-flight work.
|
||||
// We don't queue long-running operations — every bridge command is
|
||||
// fire-and-forget with its own callback — so there's nothing to
|
||||
// cancel here.
|
||||
Log.i(TAG, "onInterrupt — accessibility service asked to stop work")
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
Log.i(TAG, "HermesAccessibilityService unbinding")
|
||||
if (instance === this) instance = null
|
||||
serviceScope?.cancel()
|
||||
serviceScope = null
|
||||
cachedMasterEnabled = false
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (instance === this) instance = null
|
||||
serviceScope?.cancel()
|
||||
serviceScope = null
|
||||
cachedMasterEnabled = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft master-toggle cache. Fed by the [serviceScope] collector started
|
||||
* in [onServiceConnected] — DO NOT write directly. Read by
|
||||
* [BridgeCommandHandler] on every inbound command via [isMasterEnabled].
|
||||
*
|
||||
* Volatile because the writer runs on Dispatchers.Default and the
|
||||
* reader runs on whichever multiplexer thread the bridge envelope
|
||||
* arrives on.
|
||||
*/
|
||||
@Volatile
|
||||
private var cachedMasterEnabled: Boolean = false
|
||||
|
||||
fun isMasterEnabled(): Boolean = cachedMasterEnabled
|
||||
|
||||
/**
|
||||
* Snapshot the current root node of the active window. Returns null if
|
||||
* no window is focused or the system refuses access (e.g. IME window).
|
||||
*
|
||||
* Callers must `recycle()` the returned node when done.
|
||||
*
|
||||
* 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
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "rootInActiveWindow threw: ${t.message}")
|
||||
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`. The service is
|
||||
* declared only by the `sideload` manifest, and that sideload config sets
|
||||
* the flag. When `windows` is empty (or throws, or every window's root is
|
||||
* null) we fall back to a single-element list wrapping
|
||||
* [rootInActiveWindow], preserving pre-P1 behaviour for tests and
|
||||
* defensive runtime fallback.
|
||||
*
|
||||
* Returns an empty list only if the service cannot read any window
|
||||
* root at all (e.g. lock screen, master-off state). Callers should
|
||||
* 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
|
||||
* Bridge UI can render a compile-time capabilities badge without
|
||||
* reading BuildConfig directly.
|
||||
*/
|
||||
val supportsGestures: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package com.hermesandroid.relay.accessibility
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Phase 3 — accessibility `accessibility-runtime`
|
||||
*
|
||||
* Captures the phone's screen via the Android [MediaProjection] API, encodes
|
||||
* it as PNG, and publishes it to the relay so the agent can fetch it.
|
||||
*
|
||||
* ## Permission flow (blocker for Agent bridge-ui / UI to wire)
|
||||
*
|
||||
* `MediaProjection` cannot be granted by the app itself — it needs an
|
||||
* explicit user consent dialog per session, launched via
|
||||
* [MediaProjectionManager.createScreenCaptureIntent] from an `Activity`.
|
||||
* The resulting `Intent` is then passed to [MediaProjectionManager.getMediaProjection]
|
||||
* to build an actual projection.
|
||||
*
|
||||
* Because the grant lives on an `Activity` result, this class can only
|
||||
* provide the capture loop — **the consent flow must be wired by the
|
||||
* Bridge UI screen (Agent bridge-ui)**. Suggested contract:
|
||||
*
|
||||
* 1. `BridgeScreen` holds an `ActivityResultLauncher<Intent>` registered
|
||||
* with `ActivityResultContracts.StartActivityForResult()`.
|
||||
* 2. On "Enable screenshots" tap, bridge-ui calls
|
||||
* `MediaProjectionManager.createScreenCaptureIntent()` and launches it.
|
||||
* 3. On result, bridge-ui passes `(resultCode, data)` into a central holder
|
||||
* (e.g. a ViewModel singleton or [MediaProjectionHolder]).
|
||||
* 4. [ScreenCapture] reads from that holder on each capture call and
|
||||
* rebuilds a `MediaProjection` when needed. The projection will need
|
||||
* to be backed by a foreground service on Android 10+ — Agent safety-rails
|
||||
* owns the persistent-notification service declaration.
|
||||
*
|
||||
* Until that wiring lands, this class will fail gracefully with
|
||||
* `Result.failure(IllegalStateException("MediaProjection not granted"))`
|
||||
* and the agent will see the error text in the `bridge.response` body.
|
||||
*
|
||||
* ## Upload path — current limitation
|
||||
*
|
||||
* The relay's existing `/media/register` endpoint is **loopback-only and
|
||||
* path-based** (`plugin/relay/media.py`) — it registers a file path that
|
||||
* already exists on the relay host, with an optional content type and
|
||||
* filename. The phone is by definition not on the relay host, so it has no
|
||||
* usable path to register.
|
||||
*
|
||||
* Two options exist for bridging this gap:
|
||||
*
|
||||
* 1. **New relay endpoint** (preferred) — `POST /media/upload` accepts
|
||||
* `multipart/form-data` with the PNG bytes, writes to a sandboxed tmp
|
||||
* dir (`tempfile.gettempdir()`), then calls `MediaRegistry.register()`
|
||||
* on the resulting path. Wire shape mirrors `/voice/transcribe`. This
|
||||
* is a clean server-side change that Agent bridge-server could land in parallel.
|
||||
*
|
||||
* 2. **Local-only screenshots** (fallback) — the phone writes the PNG to
|
||||
* its own cache dir, emits `MEDIA:file://<cache_path>` in the response,
|
||||
* and the agent fetches it via an on-device tool (N/A — the agent runs
|
||||
* on the host, not the phone). So option 2 doesn't actually work.
|
||||
*
|
||||
* This class implements option 1 via [uploadViaMultipart]. If the endpoint
|
||||
* returns 404 (not yet deployed), we surface the error to the agent with a
|
||||
* clear message. **Agent bridge-server owns the `POST /media/upload` endpoint** — it's
|
||||
* the only remaining server-side work to complete Tier 1 screenshots.
|
||||
*
|
||||
* ## Thread model
|
||||
*
|
||||
* `ImageReader` delivers frames on a background `HandlerThread` we own.
|
||||
* The PNG encode runs on [Dispatchers.IO] via [withContext]. The HTTP
|
||||
* upload is also IO-dispatched. All three can be cancelled by the caller.
|
||||
*/
|
||||
class ScreenCapture(
|
||||
private val context: Context,
|
||||
private val httpClient: OkHttpClient,
|
||||
private val relayUrlProvider: () -> String?,
|
||||
private val sessionTokenProvider: suspend () -> String?,
|
||||
private val mediaProjectionProvider: () -> MediaProjection?,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScreenCapture"
|
||||
|
||||
/** PNG quality is a no-op for PNG, but Bitmap.compress expects the arg. */
|
||||
private const val PNG_QUALITY = 100
|
||||
|
||||
/**
|
||||
* 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 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)
|
||||
.createScreenCaptureIntent()
|
||||
|
||||
/**
|
||||
* Capture one frame and upload it to the relay. Returns the inbound
|
||||
* media marker (`MEDIA:hermes-relay://<token>`) on success so the
|
||||
* bridge command handler can embed it directly in the `bridge.response`
|
||||
* result.
|
||||
*
|
||||
* Fails fast and with clear messaging on every expected error path:
|
||||
*
|
||||
* - `MediaProjection not granted` → bridge-ui needs to run the consent flow
|
||||
* - `relay URL not configured` / `session token missing` → pair first
|
||||
* - `relay upload endpoint not found` → bridge-server needs to ship `/media/upload`
|
||||
* - `capture timeout` → the virtual display never emitted a frame
|
||||
*/
|
||||
suspend fun captureAndUpload(): Result<String> = withContext(Dispatchers.IO) {
|
||||
val projection = mediaProjectionProvider()
|
||||
?: return@withContext Result.failure(
|
||||
IllegalStateException(
|
||||
"MediaProjection not granted — enable Bridge screenshots in the app"
|
||||
)
|
||||
)
|
||||
|
||||
// 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 {
|
||||
captureMutex.withLock {
|
||||
captureFrame(projection)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "captureFrame failed: ${e.message}")
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
|
||||
uploadViaMultipart(pngBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
// Persistent listener — fires on every frame the VirtualDisplay
|
||||
// produces. If there's a pending capture request, we encode
|
||||
// the frame and complete it; otherwise we just drain the image
|
||||
// so the ImageReader buffer stays clear.
|
||||
reader.setOnImageAvailableListener({ r ->
|
||||
val waiter = pendingCaptureRef.get()
|
||||
if (waiter == null || !waiter.isActive) {
|
||||
// Drain-and-drop — nobody's asking for a screenshot
|
||||
// right now but frames are still arriving.
|
||||
runCatching { r.acquireLatestImage() }.getOrNull()?.close()
|
||||
return@setOnImageAvailableListener
|
||||
}
|
||||
var image: Image? = null
|
||||
try {
|
||||
image = r.acquireLatestImage()
|
||||
?: return@setOnImageAvailableListener
|
||||
val png = imageToPngBytes(image, width, height)
|
||||
// Only complete the EXACT deferred we latched onto,
|
||||
// so a stale listener firing after supersession doesn't
|
||||
// resolve a new request.
|
||||
if (pendingCaptureRef.compareAndSet(waiter, null)) {
|
||||
waiter.complete(png)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (pendingCaptureRef.compareAndSet(waiter, null)) {
|
||||
waiter.completeExceptionally(t)
|
||||
}
|
||||
} finally {
|
||||
runCatching { image?.close() }
|
||||
}
|
||||
}, handler)
|
||||
|
||||
val display = try {
|
||||
projection.createVirtualDisplay(
|
||||
"hermes-bridge-capture",
|
||||
width,
|
||||
height,
|
||||
densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
reader.surface,
|
||||
null,
|
||||
handler,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
// Build failed — roll back so the next attempt tries fresh.
|
||||
runCatching { reader.close() }
|
||||
runCatching { thread.quitSafely() }
|
||||
throw t
|
||||
}
|
||||
|
||||
// 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
|
||||
* plane's `rowStride` may be wider than `width * 4` — we must crop
|
||||
* the stride padding before [Bitmap.copyPixelsFromBuffer].
|
||||
*/
|
||||
private fun imageToPngBytes(image: Image, width: Int, height: Int): ByteArray {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val pixelStride = plane.pixelStride
|
||||
val rowStride = plane.rowStride
|
||||
val rowPadding = rowStride - pixelStride * width
|
||||
|
||||
val bitmapWidth = width + rowPadding / pixelStride
|
||||
val bitmap = Bitmap.createBitmap(bitmapWidth, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.copyPixelsFromBuffer(buffer)
|
||||
|
||||
// Crop to the exact screen width if rowStride padding widened us.
|
||||
val cropped = if (bitmapWidth != width) {
|
||||
Bitmap.createBitmap(bitmap, 0, 0, width, height).also {
|
||||
bitmap.recycle()
|
||||
}
|
||||
} else bitmap
|
||||
|
||||
val out = ByteArrayOutputStream(256 * 1024)
|
||||
cropped.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out)
|
||||
cropped.recycle()
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload PNG bytes to the relay via `POST /media/upload` (multipart).
|
||||
*
|
||||
* **Blocker:** this endpoint does not exist server-side yet (see class
|
||||
* docstring). When it ships, it should accept a single `file` part and
|
||||
* return `{"ok": true, "token": "..."}` — the same JSON shape as
|
||||
* `/media/register`, minus the loopback restriction and with the
|
||||
* server sandboxing the temp-file write internally.
|
||||
*/
|
||||
private suspend fun uploadViaMultipart(pngBytes: ByteArray): Result<String> {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return 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 = "$httpBase/media/upload"
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
"hermes-screenshot-${System.currentTimeMillis()}.png",
|
||||
pngBytes.toRequestBody("image/png".toMediaType())
|
||||
)
|
||||
.build()
|
||||
|
||||
val fastClient = httpClient.newBuilder()
|
||||
.callTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val raw = response.body?.string().orEmpty()
|
||||
val token = extractToken(raw)
|
||||
if (token.isNullOrBlank()) {
|
||||
Result.failure(
|
||||
IOException("relay returned success but no token")
|
||||
)
|
||||
} else {
|
||||
Result.success("MEDIA:hermes-relay://$token")
|
||||
}
|
||||
}
|
||||
404 -> Result.failure(
|
||||
IOException(
|
||||
"relay /media/upload endpoint not found — server needs " +
|
||||
"Phase 3 bridge-server migration"
|
||||
)
|
||||
)
|
||||
401, 403 -> Result.failure(
|
||||
IOException("unauthorized — re-pair with the relay")
|
||||
)
|
||||
413 -> Result.failure(
|
||||
IOException("screenshot too large for relay media cap")
|
||||
)
|
||||
in 500..599 -> Result.failure(
|
||||
IOException("relay error (HTTP ${response.code})")
|
||||
)
|
||||
else -> Result.failure(
|
||||
IOException("HTTP ${response.code}: ${response.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "uploadViaMultipart failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal JSON token extractor — the response body is small and has a
|
||||
* single interesting key. We avoid pulling in a full `Json` parse here
|
||||
* because `ScreenCapture` is already a heavy dependency graph (Android
|
||||
* media + OkHttp) and we don't want to add kotlinx.serialization
|
||||
* wiring for a 40-byte response.
|
||||
*/
|
||||
private fun extractToken(body: String): String? {
|
||||
val match = Regex("""\"token\"\s*:\s*\"([^\"]+)\"""").find(body)
|
||||
return match?.groupValues?.get(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private val _projectionFlow = kotlinx.coroutines.flow.MutableStateFlow<MediaProjection?>(null)
|
||||
|
||||
/**
|
||||
* Reactive view of the current projection. Emits a fresh value every
|
||||
* time the holder is populated or cleared; null means "no active grant."
|
||||
*/
|
||||
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)
|
||||
as MediaProjectionManager
|
||||
val newProjection = try {
|
||||
manager.getMediaProjection(resultCode, data)
|
||||
} catch (t: Throwable) {
|
||||
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() {
|
||||
_projectionFlow.value = null
|
||||
}
|
||||
}, Handler(android.os.Looper.getMainLooper()))
|
||||
|
||||
_projectionFlow.value = newProjection
|
||||
Log.i("MediaProjectionHolder", "MediaProjection grant accepted and stored")
|
||||
return true
|
||||
}
|
||||
|
||||
fun revoke() {
|
||||
try { _projectionFlow.value?.stop() } catch (_: Throwable) {}
|
||||
_projectionFlow.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.hermesandroid.relay.accessibility
|
||||
|
||||
/**
|
||||
* Phase 3 — bridge-ui follow-up
|
||||
*
|
||||
* Process-singleton bridge between non-Activity code (BridgeViewModel,
|
||||
* settings screens) and the Activity-scoped `ActivityResultLauncher` that
|
||||
* fires the system MediaProjection consent dialog.
|
||||
*
|
||||
* # Why a singleton
|
||||
*
|
||||
* `MediaProjectionManager.createScreenCaptureIntent()` must be launched via
|
||||
* an `ActivityResultLauncher` registered against a `ComponentActivity` —
|
||||
* there is no way to invoke it from a ViewModel directly. We can't pass
|
||||
* the launcher into the ViewModel either, because the ViewModel outlives
|
||||
* the Activity across configuration changes and we'd leak the old Activity.
|
||||
*
|
||||
* The singleton pattern: `MainActivity` registers a launcher in `onCreate`,
|
||||
* stores a closure that calls `launcher.launch(...)` here, and clears the
|
||||
* closure in `onDestroy`. Anything that wants to ask the user for the
|
||||
* MediaProjection grant calls [request] — if the Activity is alive, the
|
||||
* system dialog appears; if not, the call returns false and the caller
|
||||
* should surface "open the app first".
|
||||
*
|
||||
* # Why not just inject the launcher into the ViewModel
|
||||
*
|
||||
* `ActivityResultLauncher` is bound to the ViewModel store of the host
|
||||
* Activity, not the ViewModel. Crossing that boundary either leaks the
|
||||
* Activity (bad) or works only until the first rotation (worse). The
|
||||
* lifecycle-respecting way is to keep the launcher Activity-scoped and
|
||||
* route requests to it through a process-wide rendezvous like this.
|
||||
*/
|
||||
object ScreenCaptureRequester {
|
||||
|
||||
@Volatile
|
||||
private var launchAction: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Called by `MainActivity.onCreate` (or any ComponentActivity that
|
||||
* wants to host the consent flow) with a closure that launches its
|
||||
* pre-registered `ActivityResultLauncher` for the
|
||||
* `ACTION_MEDIA_PROJECTION` intent.
|
||||
*/
|
||||
fun install(launch: () -> Unit) {
|
||||
launchAction = launch
|
||||
}
|
||||
|
||||
/** Called by `MainActivity.onDestroy` so we don't hold a stale Activity ref. */
|
||||
fun uninstall() {
|
||||
launchAction = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the consent dialog. Returns `true` if a host Activity is
|
||||
* currently installed and the request was dispatched, `false` if no
|
||||
* 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`, 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
|
||||
return try {
|
||||
action.invoke()
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Quick poll for UI — true when a host Activity is currently installed. */
|
||||
val isAvailable: Boolean get() = launchAction != null
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,807 @@
|
||||
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 `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
|
||||
* hierarchy. We emit one [ScreenNode] per interesting node (anything with
|
||||
* non-blank text, content description, or a click / long-click action) and
|
||||
* 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
|
||||
* [ScreenContent.truncated] = true so the agent knows to scroll if it
|
||||
* needs more.
|
||||
*/
|
||||
class ScreenReader {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 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. With P1 the cap
|
||||
* now spans the *combined* tree across all live windows.
|
||||
*/
|
||||
const val MAX_NODES = 512
|
||||
|
||||
/** Hard cap on individual text-field length before truncation. */
|
||||
const val MAX_TEXT_LEN = 2000
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured representation of a screen, ready for JSON serialization.
|
||||
* The [rootBounds] are in absolute screen pixels (what `dispatchGesture`
|
||||
* uses). When P1 merges multiple windows, [rootBounds] is the union of
|
||||
* every window's root bounds.
|
||||
*/
|
||||
@Serializable
|
||||
data class ScreenContent(
|
||||
val packageName: String?,
|
||||
val rootBounds: Bounds,
|
||||
val nodes: List<ScreenNode>,
|
||||
val truncated: Boolean = false,
|
||||
val nodeCount: Int = nodes.size,
|
||||
)
|
||||
|
||||
@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,
|
||||
val viewId: String? = null,
|
||||
val bounds: Bounds,
|
||||
val clickable: Boolean = false,
|
||||
val longClickable: Boolean = false,
|
||||
val scrollable: Boolean = false,
|
||||
val editable: Boolean = false,
|
||||
val focused: Boolean = false,
|
||||
val selected: Boolean = false,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Bounds(
|
||||
val left: Int,
|
||||
val top: Int,
|
||||
val right: Int,
|
||||
val bottom: Int,
|
||||
) {
|
||||
val centerX: Int get() = (left + right) / 2
|
||||
val centerY: Int get() = (top + bottom) / 2
|
||||
val width: Int get() = right - left
|
||||
val height: Int get() = bottom - top
|
||||
val isEmpty: Boolean get() = width <= 0 || height <= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param includeBounds when false, we still collect bounds for
|
||||
* traversal decisions but zero them in the output to shrink the
|
||||
* payload. The agent almost always wants bounds so this defaults
|
||||
* true.
|
||||
*/
|
||||
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)
|
||||
var truncated = false
|
||||
|
||||
// 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 = packageName,
|
||||
rootBounds = rootBounds,
|
||||
nodes = collected,
|
||||
truncated = truncated,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (node == null) return false
|
||||
if (out.size >= MAX_NODES) return true
|
||||
|
||||
// Skip invisible nodes early — no visual, no interaction target.
|
||||
if (!node.isVisibleToUser) {
|
||||
// still walk children, some containers aren't marked visible
|
||||
// but host visible descendants
|
||||
}
|
||||
|
||||
val rect = Rect()
|
||||
node.getBoundsInScreen(rect)
|
||||
val bounds = if (includeBounds) rect.toBoundsOrZero() else ZERO_BOUNDS
|
||||
|
||||
val text = node.text?.toString()?.takeIf { it.isNotBlank() }?.take(MAX_TEXT_LEN)
|
||||
val contentDesc = node.contentDescription?.toString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.take(MAX_TEXT_LEN)
|
||||
val clickable = node.isClickable
|
||||
val longClickable = node.isLongClickable
|
||||
val scrollable = node.isScrollable
|
||||
|
||||
val interesting = (text != null || contentDesc != null ||
|
||||
clickable || longClickable || scrollable || node.isEditable) &&
|
||||
!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(),
|
||||
viewId = node.viewIdResourceName,
|
||||
bounds = bounds,
|
||||
clickable = clickable,
|
||||
longClickable = longClickable,
|
||||
scrollable = scrollable,
|
||||
editable = node.isEditable,
|
||||
focused = node.isFocused,
|
||||
selected = node.isSelected,
|
||||
enabled = node.isEnabled,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
if (out.size >= MAX_NODES) return true
|
||||
val child = node.getChild(i) ?: continue
|
||||
try {
|
||||
val hit = walk(child, windowIndex, out, includeBounds)
|
||||
if (hit) return true
|
||||
} finally {
|
||||
@Suppress("DEPRECATION")
|
||||
try { child.recycle() } catch (_: Throwable) { }
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
windowRoots: List<AccessibilityNodeInfo>,
|
||||
needle: String,
|
||||
): Bounds? {
|
||||
if (needle.isBlank()) return null
|
||||
val lowered = needle.lowercase()
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat single-root overload. Delegates to the multi-window
|
||||
* form so tests and any legacy call site still compile unchanged.
|
||||
*/
|
||||
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,
|
||||
): AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
if (predicate(node)) return node
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
val hit = findFirst(child, predicate)
|
||||
if (hit != null) return hit
|
||||
@Suppress("DEPRECATION")
|
||||
try { child.recycle() } catch (_: Throwable) { }
|
||||
}
|
||||
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)
|
||||
|
||||
private val ZERO_BOUNDS = Bounds(0, 0, 0, 0)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Duplex audio capture for voice barge-in (plan unit B3).
|
||||
*
|
||||
* While TTS is playing, this listener continuously pulls 32 ms / 512-sample
|
||||
* PCM frames off the microphone and feeds them to [VadEngine]. It emits two
|
||||
* SharedFlows that B4 will wire into the voice state machine:
|
||||
*
|
||||
* - [maybeSpeech] fires on the **first** positive raw-VAD frame — before the
|
||||
* second-layer debouncer latches. B4 uses this to softly [VoicePlayer.duck]
|
||||
* the TTS so the user's voice has acoustic headroom while we decide whether
|
||||
* to cut off.
|
||||
*
|
||||
* - [bargeInDetected] fires when [VadEngine] confirms speech post-hysteresis.
|
||||
* B4 uses this to call `interruptSpeaking()` and flip state to Listening.
|
||||
*
|
||||
* ### Acoustic echo cancellation
|
||||
*
|
||||
* We configure [AudioRecord] with [MediaRecorder.AudioSource.VOICE_COMMUNICATION]
|
||||
* so the platform's voice-call AEC pipeline is in play, and additionally try
|
||||
* to attach [AcousticEchoCanceler] + [NoiseSuppressor] keyed to the ExoPlayer
|
||||
* audio session id so TTS audio is cancelled from the mic stream specifically.
|
||||
* Without AEC, the device's own speaker output would trip the VAD the moment
|
||||
* TTS started and we'd interrupt ourselves.
|
||||
*
|
||||
* The ExoPlayer audio session id is not stable at the moment we want to start
|
||||
* listening — Media3 allocates the underlying AudioTrack lazily on first
|
||||
* playback, and callers may hit [start] before that's happened (e.g. the very
|
||||
* first sentence of a turn). We poll [audioSessionIdProvider] for up to 1 s
|
||||
* before giving up on AEC and proceeding with the mic-hardware AEC alone.
|
||||
* See the `AEC_SESSION_POLL_*` constants below.
|
||||
*
|
||||
* ### Graceful degradation
|
||||
*
|
||||
* - `AudioRecord.getState() != STATE_INITIALIZED` → log WARN, emit nothing,
|
||||
* [stop] remains safe to call. Typical cause: RECORD_AUDIO denied at runtime
|
||||
* or another app holding the mic.
|
||||
* - `AcousticEchoCanceler.isAvailable() == false` → log INFO, proceed without.
|
||||
* Many mid-range and older devices lack the effect; the VAD still works with
|
||||
* the mic-hardware AEC from VOICE_COMMUNICATION alone (at the cost of some
|
||||
* false positives during loud TTS).
|
||||
*
|
||||
* ### Testability seam
|
||||
*
|
||||
* The hot path is abstracted behind [AudioFrameSource]. Production code uses
|
||||
* [AudioRecordSource]; unit tests inject a deterministic fake. This keeps the
|
||||
* test on the JVM unit test path with no Robolectric or `android.jar` shim,
|
||||
* matching the [VadEngine] test pattern.
|
||||
*
|
||||
* ### Thread model
|
||||
*
|
||||
* [start] launches a single reader coroutine on [Dispatchers.IO]. The reader
|
||||
* blocks on [AudioFrameSource.read], then synchronously invokes
|
||||
* [VadEngine.analyze] on the same dispatcher — VadEngine is synchronous,
|
||||
* allocation-free, and callers promise single-threaded access. Flow emissions
|
||||
* use [MutableSharedFlow] with `extraBufferCapacity = 1` so slow subscribers
|
||||
* drop events instead of backpressuring the audio loop.
|
||||
*/
|
||||
class BargeInListener internal constructor(
|
||||
private val audioSource: AudioFrameSource,
|
||||
private val vadEngine: VadEngine,
|
||||
private val audioSessionIdProvider: () -> Int,
|
||||
private val readerDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BargeInListener"
|
||||
|
||||
/** Bytes per PCM sample at [AudioFormat.ENCODING_PCM_16BIT]. */
|
||||
private const val BYTES_PER_SAMPLE = 2
|
||||
|
||||
/** Frames of buffering on the [AudioRecord] side. 4× keeps read() from
|
||||
* ever racing the DMA when the reader coroutine is scheduled with a
|
||||
* brief delay (GC pause, dispatcher contention). */
|
||||
private const val AUDIO_BUFFER_FRAMES = 4
|
||||
|
||||
/** ExoPlayer may return `0` for its audio session id until its
|
||||
* AudioTrack is first allocated (on playback start). Poll the
|
||||
* provider briefly before giving up on AEC and proceeding without. */
|
||||
private const val AEC_SESSION_POLL_INTERVAL_MS = 50L
|
||||
private const val AEC_SESSION_POLL_TIMEOUT_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Factory for the production path. Builds an [AudioRecordSource] from
|
||||
* a `Context` and wires it to the listener. The returned listener has
|
||||
* no allocated `AudioRecord` yet — that happens inside [start].
|
||||
*/
|
||||
fun create(
|
||||
context: Context,
|
||||
vadEngine: VadEngine,
|
||||
audioSessionIdProvider: () -> Int,
|
||||
): BargeInListener = BargeInListener(
|
||||
audioSource = AudioRecordSource(context.applicationContext),
|
||||
vadEngine = vadEngine,
|
||||
audioSessionIdProvider = audioSessionIdProvider,
|
||||
)
|
||||
}
|
||||
|
||||
private val _bargeInDetected = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
/** Fires post-hysteresis when [VadEngine] confirms the user is speaking. */
|
||||
val bargeInDetected: SharedFlow<Unit> = _bargeInDetected.asSharedFlow()
|
||||
|
||||
private val _maybeSpeech = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
/** Fires on the first positive raw VAD frame, before hysteresis latches. */
|
||||
val maybeSpeech: SharedFlow<Unit> = _maybeSpeech.asSharedFlow()
|
||||
|
||||
private val _aecAttached = MutableStateFlow(false)
|
||||
/** True once [AcousticEchoCanceler] has been attached for the current
|
||||
* listen session. Exposed for observability and B5's compatibility hint. */
|
||||
val aecAttached: StateFlow<Boolean> = _aecAttached.asStateFlow()
|
||||
|
||||
// Reused across every read() call so the hot loop never allocates a
|
||||
// fresh buffer. Length matches the VAD engine's contract (512 samples).
|
||||
private val frameBuffer: ShortArray = ShortArray(VadEngine.FRAME_SIZE_SAMPLES)
|
||||
|
||||
@Volatile private var readerJob: Job? = null
|
||||
@Volatile private var aec: AcousticEchoCanceler? = null
|
||||
@Volatile private var noiseSuppressor: NoiseSuppressor? = null
|
||||
|
||||
/**
|
||||
* Allocate the audio pipeline and begin reading frames into [vadEngine].
|
||||
*
|
||||
* Launches on the supplied [scope] so the reader coroutine dies with its
|
||||
* owner (the ViewModel scope in B4). [start] is not suspend in the usual
|
||||
* "blocks until ready" sense — it returns as soon as the reader job is
|
||||
* launched; the AEC attach happens lazily inside the coroutine so a
|
||||
* caller waiting on the first [maybeSpeech] / [bargeInDetected] emission
|
||||
* is not gated on an AudioTrack that hasn't been allocated yet.
|
||||
*
|
||||
* Idempotent: calling [start] again while a previous session is still
|
||||
* active is a no-op with a WARN log — B4 is expected to bracket each
|
||||
* listen session with a matching [stop].
|
||||
*/
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (readerJob?.isActive == true) {
|
||||
Log.w(TAG, "start() called while a reader is already active — ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioSource.initialize()) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"AudioFrameSource failed to initialize " +
|
||||
"(missing RECORD_AUDIO permission or mic busy) — listener inactive",
|
||||
)
|
||||
_aecAttached.value = false
|
||||
return
|
||||
}
|
||||
|
||||
_aecAttached.value = false
|
||||
readerJob = scope.launch(readerDispatcher) {
|
||||
try {
|
||||
try {
|
||||
audioSource.start()
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.start failed: ${t.message}")
|
||||
return@launch
|
||||
}
|
||||
Log.i(TAG, "Barge-in AudioRecord reader started")
|
||||
maybeAttachEffects()
|
||||
|
||||
while (isActive) {
|
||||
val read = try {
|
||||
audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.read failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (read <= 0) {
|
||||
// Negative values are AudioRecord error codes; 0 means
|
||||
// no data yet. Either way, yield briefly and retry
|
||||
// rather than spinning — but don't swallow the
|
||||
// cancellation check for too long.
|
||||
delay(5)
|
||||
continue
|
||||
}
|
||||
if (read < VadEngine.FRAME_SIZE_SAMPLES) {
|
||||
// Short read — skip this frame rather than feeding
|
||||
// the VAD a partially-populated buffer. This is rare;
|
||||
// AudioRecord.read(…, SIZE_IN_SHORTS) normally fills
|
||||
// the requested length when state is correct.
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isActive) break
|
||||
|
||||
val result = try {
|
||||
vadEngine.analyze(frameBuffer)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "VadEngine.analyze failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (result.probability > 0f) {
|
||||
_maybeSpeech.tryEmit(Unit)
|
||||
}
|
||||
if (result.isSpeech) {
|
||||
_bargeInDetected.tryEmit(Unit)
|
||||
}
|
||||
// Give the dispatcher a chance to observe cancellation
|
||||
// between frames. Production-side the AudioRecord.read
|
||||
// call already blocks until a frame is available, so
|
||||
// this is effectively free; test-side it prevents the
|
||||
// reader from monopolizing the test scheduler on fakes
|
||||
// that return data synchronously.
|
||||
yield()
|
||||
}
|
||||
} finally {
|
||||
// Release effects + AudioRecord in the reverse of attach order
|
||||
// so the AudioSessionId is still valid when AEC teardown runs.
|
||||
releaseEffects()
|
||||
runCatching { audioSource.stop() }
|
||||
runCatching { audioSource.release() }
|
||||
_aecAttached.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the reader loop and release the mic + effects. Safe to call
|
||||
* repeatedly and safe to call before [start]. Returns immediately; the
|
||||
* actual release happens in the reader coroutine's `finally` block, which
|
||||
* is typically a single frame later.
|
||||
*/
|
||||
fun stop(): Job? {
|
||||
val job = readerJob
|
||||
if (job?.isActive == true) {
|
||||
Log.i(TAG, "Stopping barge-in AudioRecord reader")
|
||||
}
|
||||
job?.cancel()
|
||||
readerJob = null
|
||||
return job
|
||||
}
|
||||
|
||||
private suspend fun maybeAttachEffects() {
|
||||
val sessionId = awaitNonZeroSessionId()
|
||||
if (sessionId == 0) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"AEC not attached — ExoPlayer audio session id was still 0 " +
|
||||
"after ${AEC_SESSION_POLL_TIMEOUT_MS}ms poll; continuing " +
|
||||
"without effects (mic-hardware AEC from VOICE_COMMUNICATION " +
|
||||
"still in play)",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
try {
|
||||
val created = AcousticEchoCanceler.create(sessionId)
|
||||
if (created != null) {
|
||||
created.enabled = true
|
||||
aec = created
|
||||
_aecAttached.value = true
|
||||
Log.i(TAG, "AcousticEchoCanceler attached to session=$sessionId")
|
||||
} else {
|
||||
Log.i(TAG, "AcousticEchoCanceler.create returned null; continuing without")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AcousticEchoCanceler attach failed: ${t.message}")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "AEC unavailable on this device; continuing without echo cancellation")
|
||||
}
|
||||
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
try {
|
||||
val ns = NoiseSuppressor.create(sessionId)
|
||||
if (ns != null) {
|
||||
ns.enabled = true
|
||||
noiseSuppressor = ns
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "NoiseSuppressor attach failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitNonZeroSessionId(): Int {
|
||||
val immediate = audioSessionIdProvider()
|
||||
if (immediate != 0) return immediate
|
||||
|
||||
var waited = 0L
|
||||
while (waited < AEC_SESSION_POLL_TIMEOUT_MS) {
|
||||
delay(AEC_SESSION_POLL_INTERVAL_MS)
|
||||
waited += AEC_SESSION_POLL_INTERVAL_MS
|
||||
val id = audioSessionIdProvider()
|
||||
if (id != 0) return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun releaseEffects() {
|
||||
aec?.let {
|
||||
runCatching { it.enabled = false }
|
||||
runCatching { it.release() }
|
||||
}
|
||||
aec = null
|
||||
noiseSuppressor?.let {
|
||||
runCatching { it.enabled = false }
|
||||
runCatching { it.release() }
|
||||
}
|
||||
noiseSuppressor = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal seam over [AudioRecord] so the audio-source pipeline can be
|
||||
* replaced with a deterministic fake in unit tests. Implementations are
|
||||
* not thread-safe — callers promise single-threaded access from the
|
||||
* reader coroutine.
|
||||
*/
|
||||
internal interface AudioFrameSource {
|
||||
/**
|
||||
* Allocate underlying native resources. Returns true on success.
|
||||
* Returning false from here short-circuits the listener without any
|
||||
* downstream state flapping.
|
||||
*/
|
||||
fun initialize(): Boolean
|
||||
|
||||
/** Begin streaming frames. Must be preceded by a successful [initialize]. */
|
||||
fun start()
|
||||
|
||||
/** Read up to [sizeInShorts] samples into [buffer]; returns the number
|
||||
* of samples actually read (possibly 0 or negative for error states). */
|
||||
fun read(buffer: ShortArray, sizeInShorts: Int): Int
|
||||
|
||||
/** Stop streaming. May be called multiple times. */
|
||||
fun stop()
|
||||
|
||||
/** Release native resources. After this, the source is dead. */
|
||||
fun release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Real [AudioRecord]-backed frame source. Configures 16 kHz mono 16-bit
|
||||
* PCM with [MediaRecorder.AudioSource.VOICE_COMMUNICATION] so the mic
|
||||
* hardware AEC is engaged.
|
||||
*
|
||||
* The [Context] parameter is currently unused — [AudioRecord] doesn't
|
||||
* need one — but we take it to keep the production factory signature
|
||||
* symmetric with the rest of the audio stack (e.g. [VoiceRecorder])
|
||||
* and to leave room for future permission-probe / audio-focus hooks
|
||||
* without a constructor signature change.
|
||||
*/
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
private class AudioRecordSource(context: Context) : AudioFrameSource {
|
||||
private var record: AudioRecord? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun initialize(): Boolean {
|
||||
val sampleRate = 16_000
|
||||
val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
|
||||
val minBytes = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding)
|
||||
if (minBytes <= 0) {
|
||||
Log.w(TAG, "AudioRecord.getMinBufferSize returned $minBytes — aborting")
|
||||
return false
|
||||
}
|
||||
val ourBytes =
|
||||
VadEngine.FRAME_SIZE_SAMPLES * BYTES_PER_SAMPLE * AUDIO_BUFFER_FRAMES
|
||||
val bufferBytes = max(minBytes, ourBytes)
|
||||
|
||||
val r = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
encoding,
|
||||
bufferBytes,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioRecord constructor threw: ${t.message}")
|
||||
return false
|
||||
}
|
||||
|
||||
if (r.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.w(TAG, "AudioRecord state=${r.state} (expected STATE_INITIALIZED)")
|
||||
runCatching { r.release() }
|
||||
return false
|
||||
}
|
||||
|
||||
record = r
|
||||
return true
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
record?.startRecording()
|
||||
}
|
||||
|
||||
override fun read(buffer: ShortArray, sizeInShorts: Int): Int {
|
||||
val r = record ?: return -1
|
||||
return r.read(buffer, 0, sizeInShorts)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
runCatching { record?.stop() }
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
runCatching { record?.release() }
|
||||
record = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Small streaming PCM sink for the realtime voice dev testbench.
|
||||
*
|
||||
* The relay sends mono 16-bit little-endian PCM chunks over the websocket. This
|
||||
* writes them directly to an AudioTrack so the Android Studio dev build can
|
||||
* hear provider output without waiting for an encoded file.
|
||||
*/
|
||||
class RealtimePcmPlayer(context: Context? = null) {
|
||||
private val trackLock = Any()
|
||||
private val writeLock = Any()
|
||||
private val audioManager =
|
||||
context?.applicationContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
private val realtimeAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
|
||||
Log.i(TAG, "Realtime PCM audio focus change=$change")
|
||||
}
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var audioFocusHeld: Boolean = false
|
||||
private var currentSampleRate: Int = 0
|
||||
private var currentVolume: Float = 1f
|
||||
private var estimatedPlaybackEndAtMs: Long = 0L
|
||||
private var playbackStarted: Boolean = false
|
||||
private var pendingStartBytes: Int = 0
|
||||
private var firstBufferedAtMs: Long = 0L
|
||||
private var lastUnderrunCount: Int = 0
|
||||
private var lastHeadPositionLogAtMs: Long = 0L
|
||||
private var lastLoggedHeadFrames: Int = 0
|
||||
private var headAdvanceConfirmed: Boolean = false
|
||||
private var playbackStartedAtMs: Long = 0L
|
||||
private var totalFramesWritten: Long = 0L
|
||||
// (endFrame, rms) per written chunk — lets [playbackAmplitude] report the
|
||||
// amplitude of the audio actually at the hardware cursor right now, instead
|
||||
// of the chunk that most recently *arrived* over the socket.
|
||||
private val playbackAmpQueue = ArrayDeque<FrameAmp>()
|
||||
private var lastPlaybackGapDiagnosticAtMs: Long = 0L
|
||||
private var lastMutedVolumeDiagnosticAtMs: Long = 0L
|
||||
private var adaptiveStartPrebufferMs: Long = RealtimePcmBufferPolicy.START_PREBUFFER_MS
|
||||
private var playbackGapSeenThisTrack: Boolean = false
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
val isActive: Boolean
|
||||
get() = synchronized(trackLock) { audioTrack != null }
|
||||
|
||||
val audioSessionId: Int
|
||||
get() = synchronized(trackLock) { audioTrack?.audioSessionId ?: 0 }
|
||||
|
||||
fun write(pcm: ByteArray, sampleRate: Int): Float {
|
||||
if (pcm.isEmpty()) return 0f
|
||||
val level = computePcm16LeRms(pcm)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val written = synchronized(writeLock) {
|
||||
val track = try {
|
||||
synchronized(trackLock) {
|
||||
val currentTrack = ensureTrackLocked(sampleRate)
|
||||
notePlaybackGapLocked(currentTrack, now)
|
||||
currentTrack
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM track preparation failed: ${e.message}")
|
||||
synchronized(trackLock) { releaseTrackLocked(reason = "PCM track preparation failure") }
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
try {
|
||||
val prerollWritten = maybeWriteStartupPreroll(track, sampleRate)
|
||||
if (prerollWritten < 0) {
|
||||
Log.w(TAG, "PCM preroll write returned $prerollWritten; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM preroll write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
// Intentionally do NOT start playback on the bare silent preroll.
|
||||
// Starting here would begin draining ~120ms of silence with zero
|
||||
// real audio queued, guaranteeing an immediate underrun on the
|
||||
// first speech chunk. The real audio written just below feeds the
|
||||
// normal start decision, and the end-of-turn flush
|
||||
// (voice.output_audio.done) force-starts anything still buffered.
|
||||
|
||||
val writtenBytes = writeBlocking(track, pcm)
|
||||
if (writtenBytes < 0) {
|
||||
Log.w(TAG, "PCM write returned $writtenBytes; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
var accepted = 0
|
||||
if (writtenBytes > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(writtenBytes, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(level)
|
||||
maybeStartPlaybackLocked(track, sampleRate, force = false)
|
||||
updateUnderrunCursorLocked(track)
|
||||
logPlaybackHealthLocked(track, now)
|
||||
accepted = writtenBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM write failed: ${e.message}")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write failure")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
}
|
||||
if (written > 0) {
|
||||
_amplitude.value = level
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
private fun writeBlocking(track: AudioTrack, pcm: ByteArray): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
track.write(pcm, 0, pcm.size)
|
||||
}
|
||||
|
||||
fun flushBufferedPlayback(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0L
|
||||
maybeStartPlaybackLocked(track, currentSampleRate, force = true)
|
||||
val remaining = remainingPlaybackMsLocked(now, cushionMs)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM flush playState=${readPlayState(track)} " +
|
||||
"headFrames=${readHeadFrames(track)} remainingMs=$remaining " +
|
||||
"underruns=${readUnderrunCount(track)}",
|
||||
)
|
||||
remaining
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(writeLock) {
|
||||
synchronized(trackLock) {
|
||||
releaseTrackLocked(reason = "stop")
|
||||
currentSampleRate = 0
|
||||
estimatedPlaybackEndAtMs = 0L
|
||||
}
|
||||
}
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
fun estimatedRemainingPlaybackMs(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
remainingPlaybackMsLocked(now, cushionMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
synchronized(trackLock) {
|
||||
currentVolume = clamped
|
||||
try { audioTrack?.setVolume(clamped) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun duck() {
|
||||
setVolume(0.3f)
|
||||
}
|
||||
|
||||
fun unduck() {
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
private fun releaseTrackLocked(reason: String) {
|
||||
audioTrack?.let { track ->
|
||||
Log.i(TAG, "Stopping streaming PCM playback ($reason)")
|
||||
try { track.pause() } catch (_: Exception) { }
|
||||
try { track.flush() } catch (_: Exception) { }
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
}
|
||||
abandonAudioFocusLocked()
|
||||
settleAdaptivePrebufferLocked()
|
||||
audioTrack = null
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = 0
|
||||
lastHeadPositionLogAtMs = 0L
|
||||
lastLoggedHeadFrames = 0
|
||||
headAdvanceConfirmed = false
|
||||
playbackStartedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
playbackAmpQueue.clear()
|
||||
playbackGapSeenThisTrack = false
|
||||
}
|
||||
|
||||
private fun enqueuePlaybackAmplitudeLocked(rms: Float) {
|
||||
// [totalFramesWritten] has already been advanced past this chunk, so it
|
||||
// is the chunk's end frame. The cursor reaches this amplitude once
|
||||
// playbackHeadPosition passes the previous end frame.
|
||||
playbackAmpQueue.addLast(FrameAmp(endFrame = totalFramesWritten, rms = rms))
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeFirst()
|
||||
}
|
||||
|
||||
/**
|
||||
* Amplitude of the audio currently at the hardware cursor (0 if not playing
|
||||
* or drained). This is the playback-synced signal a UI waveform should draw:
|
||||
* it advances with [AudioTrack.getPlaybackHeadPosition], so it matches what
|
||||
* the user hears rather than what most recently arrived over the socket.
|
||||
*/
|
||||
fun playbackAmplitude(): Float = synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0f
|
||||
if (!playbackStarted) return@synchronized 0f
|
||||
val head = readHeadFrames(track).toLong()
|
||||
// Drop fully-played chunks so the head of the queue is the one playing now.
|
||||
while (playbackAmpQueue.size > 1 && playbackAmpQueue.first().endFrame <= head) {
|
||||
playbackAmpQueue.removeFirst()
|
||||
}
|
||||
amplitudeAtHead(playbackAmpQueue, head)
|
||||
}
|
||||
|
||||
private fun ensureTrackLocked(sampleRate: Int): AudioTrack {
|
||||
val existing = audioTrack
|
||||
if (existing != null && currentSampleRate == sampleRate) {
|
||||
return existing
|
||||
}
|
||||
releaseTrackLocked(reason = "sample rate changed")
|
||||
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val bufferSize = RealtimePcmBufferPolicy.streamBufferSize(
|
||||
minBufferBytes = minBuffer,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
val format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build()
|
||||
|
||||
val track = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAudioFormat(format)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
AudioTrack(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
)
|
||||
}
|
||||
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
throw IllegalStateException("AudioTrack failed to initialize")
|
||||
}
|
||||
|
||||
requestAudioFocusLocked()
|
||||
audioTrack = track
|
||||
currentSampleRate = sampleRate
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
// Log requested vs. actual allocated frames. If a device coerces our
|
||||
// sub-second request back up to a multi-second allocation, that's the
|
||||
// tell-tale of deep-buffer routing (the cold-start parking class) and
|
||||
// explains a regression of the silent-first-turn bug on new hardware.
|
||||
val requestedFrames = bufferSize / BYTES_PER_FRAME
|
||||
val actualFrames = try { track.bufferSizeInFrames } catch (_: Exception) { -1 }
|
||||
Log.i(
|
||||
TAG,
|
||||
"Initialized streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} buffer=${bufferSize}B " +
|
||||
"requestedFrames=$requestedFrames actualFrames=$actualFrames " +
|
||||
"(${frameMs(actualFrames, sampleRate)}ms)",
|
||||
)
|
||||
return track
|
||||
}
|
||||
|
||||
private fun frameMs(frames: Int, sampleRate: Int): Long {
|
||||
if (frames <= 0 || sampleRate <= 0) return 0L
|
||||
return (frames * 1000L / sampleRate)
|
||||
}
|
||||
|
||||
private fun noteWrittenBytesLocked(writtenBytes: Int, sampleRate: Int) {
|
||||
if (writtenBytes <= 0 || sampleRate <= 0) return
|
||||
totalFramesWritten += (writtenBytes / BYTES_PER_FRAME).toLong()
|
||||
val durationMs = ((writtenBytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!playbackStarted) {
|
||||
if (firstBufferedAtMs == 0L) firstBufferedAtMs = now
|
||||
pendingStartBytes += writtenBytes
|
||||
return
|
||||
}
|
||||
val base = max(now, estimatedPlaybackEndAtMs)
|
||||
estimatedPlaybackEndAtMs = base + durationMs
|
||||
}
|
||||
|
||||
private fun maybeWriteStartupPreroll(track: AudioTrack, sampleRate: Int): Int {
|
||||
if (
|
||||
synchronized(trackLock) {
|
||||
playbackStarted ||
|
||||
pendingStartBytes > 0 ||
|
||||
firstBufferedAtMs > 0L ||
|
||||
sampleRate <= 0 ||
|
||||
audioTrack !== track
|
||||
}
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val prerollMs = startupPrerollMsLocked()
|
||||
val silenceBytes = RealtimePcmBufferPolicy.bytesForDurationMs(sampleRate, prerollMs)
|
||||
if (silenceBytes <= 0) return 0
|
||||
|
||||
val written = writeBlocking(track, ByteArray(silenceBytes))
|
||||
if (written > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(written, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(0f) // preroll is silence
|
||||
Log.i(
|
||||
TAG,
|
||||
"Primed realtime PCM playback with " +
|
||||
"${RealtimePcmBufferPolicy.durationMsForBytes(written, sampleRate)}ms " +
|
||||
"silent preroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
private fun startupPrerollMsLocked(): Long {
|
||||
return RealtimePcmBufferPolicy.STARTUP_PREROLL_MS
|
||||
}
|
||||
|
||||
private fun maybeStartPlaybackLocked(
|
||||
track: AudioTrack,
|
||||
sampleRate: Int,
|
||||
force: Boolean,
|
||||
) {
|
||||
if (playbackStarted || pendingStartBytes <= 0 || sampleRate <= 0) return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val waitedMs = if (firstBufferedAtMs > 0L) now - firstBufferedAtMs else 0L
|
||||
val decision = RealtimePcmBufferPolicy.startDecision(
|
||||
pendingBytes = pendingStartBytes,
|
||||
sampleRate = sampleRate,
|
||||
waitedMs = waitedMs,
|
||||
force = force,
|
||||
startPrebufferMs = adaptiveStartPrebufferMs,
|
||||
)
|
||||
if (!decision.shouldStart) return
|
||||
|
||||
try {
|
||||
requestAudioFocusLocked()
|
||||
track.play()
|
||||
try { track.setVolume(currentVolume) } catch (_: Exception) { }
|
||||
} catch (e: Exception) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
audioTrack = null
|
||||
throw e
|
||||
}
|
||||
|
||||
playbackStarted = true
|
||||
estimatedPlaybackEndAtMs = now + decision.bufferedMs
|
||||
playbackStartedAtMs = now
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = readHeadFrames(track)
|
||||
headAdvanceConfirmed = false
|
||||
Log.i(
|
||||
TAG,
|
||||
"Started streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} prebuffer=${decision.bufferedMs}ms " +
|
||||
"waited=${waitedMs}ms target=${adaptiveStartPrebufferMs}ms " +
|
||||
"reason=${decision.reason} playState=${readPlayState(track)} " +
|
||||
"headFrames=$lastLoggedHeadFrames ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically logs whether the AudioTrack hardware cursor is actually
|
||||
* advancing. This is the decisive signal for the "speaking animation + valid
|
||||
* PCM logs but no sound" class of bug:
|
||||
*
|
||||
* - head frames advancing + still no sound → output route / volume problem
|
||||
* (e.g. the Samsung HAL not opening the path until a volume key nudges it).
|
||||
* - head frames pinned at the start value → the track was play()'d but the
|
||||
* mixer never pulled from it (focus / state problem on this device).
|
||||
*/
|
||||
private fun logPlaybackHealthLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val headFrames = readHeadFrames(track)
|
||||
// First-frame detection runs on EVERY write until confirmed (not gated by
|
||||
// the throttle) and uses a fresh timestamp, so time-to-first-audio is
|
||||
// accurate to write cadence rather than the 1s health-log window — the
|
||||
// throttle/stale-`now` combination otherwise inflates it by ~1.5s.
|
||||
if (!headAdvanceConfirmed && headFrames > 0) {
|
||||
headAdvanceConfirmed = true
|
||||
val freshNow = SystemClock.elapsedRealtime()
|
||||
val ttfaMs = if (playbackStartedAtMs > 0L) freshNow - playbackStartedAtMs else -1L
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM time-to-first-audio=${ttfaMs}ms (headFrames=$headFrames)",
|
||||
)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Realtime audio started",
|
||||
detail = "First sample reached the speaker after ${ttfaMs}ms.",
|
||||
)
|
||||
}
|
||||
if (now - lastHeadPositionLogAtMs < HEAD_POSITION_LOG_THROTTLE_MS) return
|
||||
val advancedFrames = headFrames - lastLoggedHeadFrames
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM playback health playState=${readPlayState(track)} " +
|
||||
"headFrames=$headFrames advanced=$advancedFrames " +
|
||||
"underruns=${readUnderrunCount(track)} ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
if (advancedFrames <= 0) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Realtime PCM hardware cursor not advancing (headFrames=$headFrames " +
|
||||
"playState=${readPlayState(track)}); audio queued but mixer is not pulling",
|
||||
)
|
||||
maybeRecordStuckCursorDiagnosticLocked(track, now)
|
||||
}
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = headFrames
|
||||
}
|
||||
|
||||
/**
|
||||
* If the hardware cursor never started after [STUCK_CURSOR_DIAGNOSTIC_MS] of
|
||||
* "playing", surface it to the in-app Diagnostics screen once per track —
|
||||
* this is the field-visible signal for the cold-start parking class when no
|
||||
* logcat cable is attached. Write-sampled here; the [VoiceViewModel] watchdog
|
||||
* provides the timer-driven guarantee when writes stall.
|
||||
*/
|
||||
private fun maybeRecordStuckCursorDiagnosticLocked(track: AudioTrack, now: Long) {
|
||||
if (headAdvanceConfirmed || playbackStartedAtMs <= 0L) return
|
||||
val stuckMs = now - playbackStartedAtMs
|
||||
if (stuckMs < STUCK_CURSOR_DIAGNOSTIC_MS) return
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio not starting",
|
||||
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
|
||||
"(${mediaVolumeSummaryLocked()}).",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot of playback progress for the [VoiceViewModel] watchdog
|
||||
* and drain cross-check. Reads are cheap and lock-guarded.
|
||||
*/
|
||||
fun snapshot(): RealtimePlaybackSnapshot = synchronized(trackLock) {
|
||||
val track = audioTrack
|
||||
RealtimePlaybackSnapshot(
|
||||
active = track != null,
|
||||
playbackStarted = playbackStarted,
|
||||
headFrames = track?.let { readHeadFrames(it) } ?: 0,
|
||||
framesWritten = totalFramesWritten,
|
||||
sampleRate = currentSampleRate,
|
||||
playStatePlaying = track != null && readPlayState(track) == "playing",
|
||||
startedAtElapsedMs = playbackStartedAtMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readHeadFrames(track: AudioTrack): Int =
|
||||
try { track.playbackHeadPosition } catch (_: Exception) { lastLoggedHeadFrames }
|
||||
|
||||
private fun readPlayState(track: AudioTrack): String =
|
||||
try {
|
||||
when (track.playState) {
|
||||
AudioTrack.PLAYSTATE_PLAYING -> "playing"
|
||||
AudioTrack.PLAYSTATE_PAUSED -> "paused"
|
||||
AudioTrack.PLAYSTATE_STOPPED -> "stopped"
|
||||
else -> "unknown"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"error"
|
||||
}
|
||||
|
||||
private fun notePlaybackGapLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
val platformUnderrun = underrunCount > lastUnderrunCount
|
||||
val estimatedDrained = estimatedPlaybackEndAtMs > 0L &&
|
||||
now > estimatedPlaybackEndAtMs + RealtimePcmBufferPolicy.UNDERFLOW_GRACE_MS
|
||||
if (!platformUnderrun && !estimatedDrained) return
|
||||
|
||||
val reason = if (platformUnderrun) {
|
||||
"platform underrun ${lastUnderrunCount}→$underrunCount"
|
||||
} else {
|
||||
"stream gap ${now - estimatedPlaybackEndAtMs}ms"
|
||||
}
|
||||
Log.w(TAG, "Realtime PCM continuing after $reason")
|
||||
recordPlaybackGapDiagnosticLocked(now, reason)
|
||||
playbackGapSeenThisTrack = true
|
||||
increaseAdaptivePrebufferLocked(reason)
|
||||
|
||||
// Provider-native realtime streams can legitimately arrive in uneven
|
||||
// bursts while the model decides to call tools. Keep the AudioTrack
|
||||
// alive so already queued speech is not flushed and the next chunk can
|
||||
// resume naturally after Android's underrun recovery.
|
||||
if (estimatedDrained) {
|
||||
estimatedPlaybackEndAtMs = now
|
||||
}
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
|
||||
private fun increaseAdaptivePrebufferLocked(reason: String) {
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs + ADAPTIVE_PREBUFFER_STEP_MS)
|
||||
.coerceAtMost(RealtimePcmBufferPolicy.MAX_ADAPTIVE_START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer increased to ${adaptiveStartPrebufferMs}ms " +
|
||||
"after $reason",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleAdaptivePrebufferLocked() {
|
||||
if (playbackGapSeenThisTrack) return
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs - ADAPTIVE_PREBUFFER_DECAY_MS)
|
||||
.coerceAtLeast(RealtimePcmBufferPolicy.START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer relaxed to ${adaptiveStartPrebufferMs}ms",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordPlaybackGapDiagnosticLocked(now: Long, reason: String) {
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio stream gap",
|
||||
detail = reason,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val mediaVolume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
if (mediaVolume == 0 && now - lastMutedVolumeDiagnosticAtMs > MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS) {
|
||||
lastMutedVolumeDiagnosticAtMs = now
|
||||
Log.w(TAG, "Realtime PCM playback is starting while media volume is muted")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime voice volume muted",
|
||||
detail = "Media volume is 0/${maxVolume ?: "?"}.",
|
||||
)
|
||||
}
|
||||
if (audioFocusHeld) {
|
||||
Log.i(TAG, "Realtime PCM audio focus already held ${mediaVolumeSummaryLocked()}")
|
||||
return
|
||||
}
|
||||
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val request = audioFocusRequest ?: AudioFocusRequest.Builder(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAcceptsDelayedFocusGain(false)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
.build()
|
||||
.also { audioFocusRequest = it }
|
||||
manager.requestAudioFocus(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
}
|
||||
audioFocusHeld = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM audio focus result=$result held=$audioFocusHeld ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun abandonAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
if (!audioFocusHeld) return
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Realtime PCM audio focus abandon failed: ${it.message}")
|
||||
}
|
||||
audioFocusHeld = false
|
||||
}
|
||||
|
||||
private fun mediaVolumeSummaryLocked(): String {
|
||||
val manager = audioManager ?: return "mediaVolume=unknown"
|
||||
val volume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val musicActive = runCatching { manager.isMusicActive }.getOrNull()
|
||||
return "mediaVolume=${volume ?: "?"}/${maxVolume ?: "?"} musicActive=${musicActive ?: "?"}"
|
||||
}
|
||||
|
||||
private fun updateUnderrunCursorLocked(track: AudioTrack) {
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
if (underrunCount > lastUnderrunCount) {
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUnderrunCount(track: AudioTrack): Int =
|
||||
try { track.underrunCount } catch (_: Exception) { lastUnderrunCount }
|
||||
|
||||
private fun remainingPlaybackMsLocked(now: Long, cushionMs: Long): Long {
|
||||
if (audioTrack == null) return 0L
|
||||
if (!playbackStarted) {
|
||||
return RealtimePcmBufferPolicy.durationMsForBytes(
|
||||
bytes = pendingStartBytes,
|
||||
sampleRate = currentSampleRate,
|
||||
) + cushionMs.coerceAtLeast(0L)
|
||||
}
|
||||
return (estimatedPlaybackEndAtMs - now + cushionMs).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun computePcm16LeRms(pcm: ByteArray): Float {
|
||||
val usable = pcm.size - (pcm.size % 2)
|
||||
if (usable <= 0) return 0f
|
||||
|
||||
var sumSquares = 0.0
|
||||
var samples = 0
|
||||
var index = 0
|
||||
while (index < usable) {
|
||||
val low = pcm[index].toInt() and 0xff
|
||||
val high = pcm[index + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt()
|
||||
val normalized = sample / Short.MAX_VALUE.toDouble()
|
||||
sumSquares += normalized * normalized
|
||||
samples++
|
||||
index += 2
|
||||
}
|
||||
if (samples == 0) return 0f
|
||||
|
||||
val rms = sqrt(sumSquares / samples)
|
||||
val lifted = sqrt((rms / 0.28).coerceIn(0.0, 1.0))
|
||||
return if (lifted.isNaN() || lifted.isInfinite()) 0f else lifted.toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RealtimePcmPlayer"
|
||||
private const val DEFAULT_DRAIN_CUSHION_MS = 250L
|
||||
private const val PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS = 5_000L
|
||||
private const val MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS = 10_000L
|
||||
private const val ADAPTIVE_PREBUFFER_STEP_MS = 240L
|
||||
private const val ADAPTIVE_PREBUFFER_DECAY_MS = 120L
|
||||
private const val HEAD_POSITION_LOG_THROTTLE_MS = 1_000L
|
||||
private const val STUCK_CURSOR_DIAGNOSTIC_MS = 1_200L
|
||||
private const val BYTES_PER_FRAME = 2 // mono 16-bit PCM
|
||||
private const val MAX_AMP_QUEUE = 1_024
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-free value snapshot of realtime playback progress, consumed by the
|
||||
* [com.hermesandroid.relay.viewmodel.VoiceViewModel] first-frame watchdog and
|
||||
* drain cross-check.
|
||||
*/
|
||||
data class RealtimePlaybackSnapshot(
|
||||
val active: Boolean,
|
||||
val playbackStarted: Boolean,
|
||||
val headFrames: Int,
|
||||
val framesWritten: Long,
|
||||
val sampleRate: Int,
|
||||
val playStatePlaying: Boolean,
|
||||
val startedAtElapsedMs: Long,
|
||||
)
|
||||
|
||||
/** A written PCM chunk's RMS amplitude tagged with the frame it finishes at. */
|
||||
internal data class FrameAmp(val endFrame: Long, val rms: Float)
|
||||
|
||||
/**
|
||||
* Returns the amplitude of the first chunk that has not finished playing
|
||||
* ([FrameAmp.endFrame] > [headFrames]) — i.e. the audio at the cursor right now.
|
||||
* 0 when the queue is empty or fully drained. Pure for unit testing.
|
||||
*/
|
||||
internal fun amplitudeAtHead(queue: List<FrameAmp>, headFrames: Long): Float {
|
||||
for (entry in queue) {
|
||||
if (entry.endFrame > headFrames) return entry.rms
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
internal data class RealtimePcmStartDecision(
|
||||
val shouldStart: Boolean,
|
||||
val bufferedMs: Long,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
internal object RealtimePcmBufferPolicy {
|
||||
// Realtime voice is latency-sensitive: the provider streams PCM at (or faster
|
||||
// than) realtime, so the start prebuffer only needs to cover network jitter,
|
||||
// not the whole turn. The large [STREAM_BUFFER_MS] AudioTrack buffer absorbs
|
||||
// bursts *after* playback starts; the start thresholds just decide when the
|
||||
// very first sample is allowed to leave the queue.
|
||||
//
|
||||
// A short turn whose audio arrives faster than realtime used to satisfy
|
||||
// neither the (2.4s) prebuffer nor the (1.2s) max-wait, so it never started
|
||||
// mid-stream and depended entirely on the end-of-turn flush. Lowering these
|
||||
// lets streaming start on the first few chunks while keeping enough cushion
|
||||
// to ride out jitter.
|
||||
const val STARTUP_PREROLL_MS = 120L
|
||||
const val START_PREBUFFER_MS = 320L
|
||||
const val MIN_PREBUFFER_MS = 160L
|
||||
const val MAX_PREBUFFER_WAIT_MS = 280L
|
||||
const val MAX_ADAPTIVE_START_PREBUFFER_MS = 1_200L
|
||||
// Keep the AudioTrack buffer modest. A multi-second buffer gets routed to
|
||||
// Samsung's "deep buffer" output mixer, whose thread is suspended at rest and
|
||||
// cold-starts very slowly — the hardware cursor (playbackHeadPosition) stays
|
||||
// pinned at 0 for ~2-5s after play() even though playState=PLAYING, focus is
|
||||
// held and volume is up. That parked window is the inaudible first/short
|
||||
// turn. A sub-second buffer keeps playback on the primary (fast) mixer path,
|
||||
// which begins pulling immediately. The ~700ms still absorbs normal network
|
||||
// jitter; longer provider gaps (tool calls) underrun-and-resume regardless of
|
||||
// buffer size and are handled by notePlaybackGapLocked.
|
||||
const val STREAM_BUFFER_MS = 700L
|
||||
const val UNDERFLOW_GRACE_MS = 180L
|
||||
|
||||
fun streamBufferSize(minBufferBytes: Int, sampleRate: Int): Int {
|
||||
val target = bytesForDurationMs(sampleRate, STREAM_BUFFER_MS)
|
||||
return max(minBufferBytes, target)
|
||||
}
|
||||
|
||||
fun startDecision(
|
||||
pendingBytes: Int,
|
||||
sampleRate: Int,
|
||||
waitedMs: Long,
|
||||
force: Boolean,
|
||||
startPrebufferMs: Long = START_PREBUFFER_MS,
|
||||
): RealtimePcmStartDecision {
|
||||
val bufferedMs = durationMsForBytes(pendingBytes, sampleRate)
|
||||
val targetPrebufferMs = startPrebufferMs.coerceIn(
|
||||
START_PREBUFFER_MS,
|
||||
MAX_ADAPTIVE_START_PREBUFFER_MS,
|
||||
)
|
||||
val reason = when {
|
||||
force && pendingBytes > 0 -> "flush"
|
||||
bufferedMs >= targetPrebufferMs -> "prebuffer"
|
||||
bufferedMs >= MIN_PREBUFFER_MS && waitedMs >= MAX_PREBUFFER_WAIT_MS -> "max-wait"
|
||||
else -> "buffering"
|
||||
}
|
||||
return RealtimePcmStartDecision(
|
||||
shouldStart = reason != "buffering",
|
||||
bufferedMs = bufferedMs,
|
||||
reason = reason,
|
||||
)
|
||||
}
|
||||
|
||||
fun durationMsForBytes(bytes: Int, sampleRate: Int): Long {
|
||||
if (bytes <= 0 || sampleRate <= 0) return 0L
|
||||
return ((bytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
}
|
||||
|
||||
fun bytesForDurationMs(sampleRate: Int, durationMs: Long): Int {
|
||||
if (sampleRate <= 0 || durationMs <= 0L) return 0
|
||||
return (sampleRate * 2L * durationMs / 1000L).toInt()
|
||||
}
|
||||
|
||||
fun startupPrerollBytes(sampleRate: Int): Int =
|
||||
bytesForDurationMs(sampleRate, STARTUP_PREROLL_MS)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures mono 16-bit PCM for realtime voice test runs.
|
||||
*
|
||||
* [capture] grabs a fixed short window (legacy/back-compat). [captureUntilStopped]
|
||||
* records open-endedly until [requestStop] is called (tap-to-stop), which is what
|
||||
* the Mic demo needs to capture a full spoken sentence.
|
||||
*/
|
||||
class RealtimePcmRecorder(
|
||||
private val sampleRate: Int = 16_000,
|
||||
) {
|
||||
@Volatile
|
||||
private var capturing = false
|
||||
|
||||
/** Signals an in-flight [captureUntilStopped] to finish and return. */
|
||||
fun requestStop() {
|
||||
capturing = false
|
||||
}
|
||||
|
||||
val isCapturing: Boolean
|
||||
get() = capturing
|
||||
|
||||
/**
|
||||
* Records until [requestStop] is called or [maxDurationMs] elapses, invoking
|
||||
* [onLevel] (0..1 RMS) per read so the UI can show a live input waveform.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun captureUntilStopped(
|
||||
maxDurationMs: Long = 15_000,
|
||||
onLevel: ((Float) -> Unit)? = null,
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val maxBytes = ((sampleRate * maxDurationMs) / 1000L * 2L).toInt()
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
|
||||
val out = ByteArrayOutputStream(minBuffer * 4)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
capturing = true
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (capturing && out.size() < maxBytes) {
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
onLevel?.invoke(rms16Le(buffer, read))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
capturing = false
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun capture(durationMs: Long = 800): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val targetBytes = ((sampleRate * durationMs) / 1000L * 2L)
|
||||
.toInt()
|
||||
.coerceAtLeast(minBuffer)
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
|
||||
val out = ByteArrayOutputStream(targetBytes)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (out.size() < targetBytes) {
|
||||
val read = recorder.read(
|
||||
buffer,
|
||||
0,
|
||||
min(buffer.size, targetBytes - out.size()),
|
||||
)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
private fun rms16Le(buffer: ByteArray, length: Int): Float {
|
||||
val usable = length - (length % 2)
|
||||
if (usable <= 0) return 0f
|
||||
var sum = 0.0
|
||||
var i = 0
|
||||
while (i < usable) {
|
||||
val low = buffer[i].toInt() and 0xff
|
||||
val high = buffer[i + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt() / 32768.0
|
||||
sum += sample * sample
|
||||
i += 2
|
||||
}
|
||||
val rms = sqrt(sum / (usable / 2))
|
||||
return sqrt((rms / 0.28).coerceIn(0.0, 1.0)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.konovalov.vad.silero.VadSilero
|
||||
import com.konovalov.vad.silero.config.FrameSize
|
||||
import com.konovalov.vad.silero.config.Mode
|
||||
import com.konovalov.vad.silero.config.SampleRate
|
||||
|
||||
/**
|
||||
* Voice activity detection engine for barge-in (plan unit B2).
|
||||
*
|
||||
* Wraps the upstream `com.github.gkonovalov.android-vad:silero` Silero VAD
|
||||
* behind a project-internal interface so callers never see the library type —
|
||||
* swapping to `vad-webrtc` or another backend later is a single-file change
|
||||
* here, not a fan-out edit across [com.hermesandroid.relay.audio.BargeInListener]
|
||||
* (B3) and [com.hermesandroid.relay.viewmodel.VoiceViewModel] (B4).
|
||||
*
|
||||
* ### Frame contract
|
||||
*
|
||||
* - 16 kHz mono 16-bit PCM.
|
||||
* - Exactly **512 samples** per call (32 ms at 16 kHz). This is the smallest
|
||||
* Silero-supported frame size at 16 kHz per the library's public
|
||||
* `supportedParameters` map (512, 1024, 1536); 512 keeps latency tight.
|
||||
* The plan document references 640 samples — that was the previous library
|
||||
* constraint before the Silero 2.0 series dropped 160/320/640/1024 in
|
||||
* favour of 512/1024/1536. Use 512.
|
||||
* - [analyze] is synchronous. Cost is one ONNX forward pass + a couple of
|
||||
* counter increments. Callers (B3) feed frames in a tight loop; any heap
|
||||
* allocation beyond the returned [VadResult] is avoided.
|
||||
*
|
||||
* ### Two-layer hysteresis
|
||||
*
|
||||
* 1. **Library layer** — Silero's own `isSpeech()` already applies an
|
||||
* attack/release window driven by `speechDurationMs`/`silenceDurationMs`
|
||||
* ([SENSITIVITY_PROFILES]). This handles per-frame wobble from the DNN.
|
||||
* 2. **Our layer** — on top, we require `consecutiveSpeechFrames` successive
|
||||
* post-library `true` returns before [VadResult.isSpeech] flips to `true`.
|
||||
* This is the "2–3 consecutive speech frames" debouncer from the plan.
|
||||
*
|
||||
* The two layers compose: library filters per-frame noise, ours defends
|
||||
* against short false-positive bursts (~40 ms) that slip through.
|
||||
*
|
||||
* ### Sensitivity semantics
|
||||
*
|
||||
* [BargeInSensitivity.Off] short-circuits: [analyze] always returns
|
||||
* `isSpeech=false` without touching the model. Useful as a "disable without
|
||||
* flipping the master enabled toggle" UI affordance.
|
||||
*/
|
||||
class VadEngine @VisibleForTesting internal constructor(
|
||||
private val client: VadClient,
|
||||
sampleRate: Int,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Production constructor. Builds a real Silero-backed [VadClient].
|
||||
*
|
||||
* @param sampleRate currently pinned to 16000 — other rates are not
|
||||
* supported by this engine (and the plan standardises on 16 kHz mic
|
||||
* capture in B3).
|
||||
*/
|
||||
constructor(context: Context, sampleRate: Int = 16_000) : this(
|
||||
client = SileroVadClient(context.applicationContext, sampleRate.toSampleRate()),
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
|
||||
init {
|
||||
require(sampleRate == 16_000) {
|
||||
"VadEngine currently supports only 16 kHz sample rate; got $sampleRate"
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var sensitivity: BargeInSensitivity = BargeInSensitivity.Default
|
||||
|
||||
@Volatile
|
||||
private var profile: SensitivityProfile = SENSITIVITY_PROFILES.getValue(BargeInSensitivity.Default)
|
||||
.also { client.applyProfile(it) }
|
||||
|
||||
// Rolling counters for the second-layer "N consecutive speech frames"
|
||||
// hysteresis. These are touched only from [analyze], which callers drive
|
||||
// single-threaded from B3's audio-read loop, so no synchronization is
|
||||
// required beyond reading the latest [profile] volatile.
|
||||
private var consecutiveSpeechCount: Int = 0
|
||||
private var debounced: Boolean = false
|
||||
|
||||
/**
|
||||
* Analyze one frame of 16-bit PCM audio.
|
||||
*
|
||||
* @param frame exactly [FRAME_SIZE_SAMPLES] (512) samples at 16 kHz. Any
|
||||
* other size is rejected by the Silero backend.
|
||||
* @return a [VadResult] whose [VadResult.isSpeech] incorporates both the
|
||||
* library's internal attack/release and our N-consecutive debouncer;
|
||||
* [VadResult.probability] is a coarse 0f/1f signal derived from the
|
||||
* pre-debounce library decision (Silero's public API exposes only the
|
||||
* boolean, not the raw confidence).
|
||||
*/
|
||||
fun analyze(frame: ShortArray): VadResult {
|
||||
if (sensitivity == BargeInSensitivity.Off) {
|
||||
return VadResult.NOT_SPEECH
|
||||
}
|
||||
|
||||
val rawSpeech = client.isSpeech(frame)
|
||||
|
||||
if (rawSpeech) {
|
||||
if (consecutiveSpeechCount < profile.consecutiveSpeechFrames) {
|
||||
consecutiveSpeechCount++
|
||||
}
|
||||
if (consecutiveSpeechCount >= profile.consecutiveSpeechFrames) {
|
||||
debounced = true
|
||||
}
|
||||
} else {
|
||||
consecutiveSpeechCount = 0
|
||||
debounced = false
|
||||
}
|
||||
|
||||
return if (debounced) {
|
||||
VadResult(isSpeech = true, probability = 1f)
|
||||
} else {
|
||||
VadResult(isSpeech = false, probability = if (rawSpeech) 1f else 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a sensitivity preset. Updates the library's attack/release
|
||||
* durations and our debouncer's `consecutive` count. Safe to call from
|
||||
* the UI thread; takes effect on the next [analyze].
|
||||
*/
|
||||
fun setSensitivity(sensitivity: BargeInSensitivity) {
|
||||
this.sensitivity = sensitivity
|
||||
val newProfile = SENSITIVITY_PROFILES.getValue(sensitivity)
|
||||
profile = newProfile
|
||||
client.applyProfile(newProfile)
|
||||
// Reset the second-layer debouncer so a sensitivity change doesn't
|
||||
// latch a stale speech count from the previous profile.
|
||||
consecutiveSpeechCount = 0
|
||||
debounced = false
|
||||
}
|
||||
|
||||
/** Release the underlying ONNX session and native resources. */
|
||||
fun close() {
|
||||
client.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Samples per analyze-call at 16 kHz (32 ms). */
|
||||
const val FRAME_SIZE_SAMPLES: Int = 512
|
||||
|
||||
/**
|
||||
* Sensitivity → `(libraryMode, speechDurationMs, silenceDurationMs,
|
||||
* consecutiveSpeechFrames)` map. Tunings come from the B2 unit spec
|
||||
* in `docs/plans/2026-04-17-voice-barge-in.md`.
|
||||
*
|
||||
* The Silero library does not accept an arbitrary threshold float
|
||||
* — it hardcodes one per [Mode]. So we lean on [Mode] for threshold
|
||||
* and use `speech/silenceDurationMs` for the library-layer
|
||||
* attack/release, with our own `consecutiveSpeechFrames` for the
|
||||
* second-layer debouncer.
|
||||
*
|
||||
* Mode mapping (more aggressive = lower threshold = more sensitive):
|
||||
* - [BargeInSensitivity.Low] → [Mode.VERY_AGGRESSIVE] (high thr)
|
||||
* - [BargeInSensitivity.Default] → [Mode.AGGRESSIVE]
|
||||
* - [BargeInSensitivity.High] → [Mode.NORMAL] (lowest thr)
|
||||
*
|
||||
* NOTE: "aggressive" in the Silero library refers to how aggressively
|
||||
* it rejects non-speech (higher threshold), so Low-sensitivity UX
|
||||
* maps to the MORE aggressive library mode.
|
||||
*/
|
||||
internal val SENSITIVITY_PROFILES: Map<BargeInSensitivity, SensitivityProfile> = mapOf(
|
||||
BargeInSensitivity.Off to SensitivityProfile(
|
||||
mode = Mode.VERY_AGGRESSIVE,
|
||||
attackMs = 0,
|
||||
releaseMs = 0,
|
||||
consecutiveSpeechFrames = Int.MAX_VALUE,
|
||||
),
|
||||
BargeInSensitivity.Low to SensitivityProfile(
|
||||
mode = Mode.VERY_AGGRESSIVE,
|
||||
attackMs = 80,
|
||||
releaseMs = 300,
|
||||
consecutiveSpeechFrames = 3,
|
||||
),
|
||||
BargeInSensitivity.Default to SensitivityProfile(
|
||||
mode = Mode.AGGRESSIVE,
|
||||
attackMs = 50,
|
||||
releaseMs = 250,
|
||||
consecutiveSpeechFrames = 2,
|
||||
),
|
||||
BargeInSensitivity.High to SensitivityProfile(
|
||||
mode = Mode.NORMAL,
|
||||
attackMs = 30,
|
||||
releaseMs = 200,
|
||||
consecutiveSpeechFrames = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal seam over the Silero library so unit tests can replace the
|
||||
* native ONNX-backed client with a deterministic fake. Not exposed
|
||||
* publicly — callers always go through [VadEngine].
|
||||
*/
|
||||
internal interface VadClient {
|
||||
fun isSpeech(frame: ShortArray): Boolean
|
||||
fun applyProfile(profile: SensitivityProfile)
|
||||
fun close()
|
||||
}
|
||||
|
||||
internal data class SensitivityProfile(
|
||||
val mode: Mode,
|
||||
val attackMs: Int,
|
||||
val releaseMs: Int,
|
||||
val consecutiveSpeechFrames: Int,
|
||||
)
|
||||
|
||||
private class SileroVadClient(
|
||||
context: Context,
|
||||
sampleRate: SampleRate,
|
||||
) : VadClient {
|
||||
// Built lazily with a default profile so construction doesn't race
|
||||
// with an initial [applyProfile] call from [VadEngine.init].
|
||||
private val vad: VadSilero = VadSilero(
|
||||
context = context,
|
||||
sampleRate = sampleRate,
|
||||
frameSize = FrameSize.FRAME_SIZE_512,
|
||||
mode = Mode.AGGRESSIVE,
|
||||
speechDurationMs = 50,
|
||||
silenceDurationMs = 250,
|
||||
)
|
||||
|
||||
override fun isSpeech(frame: ShortArray): Boolean = vad.isSpeech(frame)
|
||||
|
||||
override fun applyProfile(profile: SensitivityProfile) {
|
||||
vad.mode = profile.mode
|
||||
vad.speechDurationMs = profile.attackMs
|
||||
vad.silenceDurationMs = profile.releaseMs
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
vad.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a single [VadEngine.analyze] call.
|
||||
*
|
||||
* [isSpeech] is the post-debounce decision callers should act on.
|
||||
* [probability] is a best-effort confidence hint — Silero's public API
|
||||
* exposes only a boolean, so we surface a coarse 0f/1f until we swap to a
|
||||
* backend that gives us the raw score.
|
||||
*/
|
||||
data class VadResult(
|
||||
val isSpeech: Boolean,
|
||||
val probability: Float,
|
||||
) {
|
||||
companion object {
|
||||
internal val NOT_SPEECH = VadResult(isSpeech = false, probability = 0f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toSampleRate(): SampleRate = when (this) {
|
||||
8_000 -> SampleRate.SAMPLE_RATE_8K
|
||||
16_000 -> SampleRate.SAMPLE_RATE_16K
|
||||
else -> error("Unsupported sample rate: $this")
|
||||
}
|
||||
@@ -1,30 +1,59 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import android.content.Context
|
||||
import android.media.audiofx.Visualizer
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Plays a TTS audio file emitted by the relay's `/voice/synthesize` endpoint
|
||||
* Plays TTS audio files emitted by the relay's `/voice/synthesize` endpoint
|
||||
* and exposes a live [amplitude] flow for the MorphingSphere / UI meter.
|
||||
*
|
||||
* Backed by a single Media3 [ExoPlayer] that lives for the lifetime of this
|
||||
* [VoicePlayer] instance. [play] appends a new [MediaItem] to the player's
|
||||
* queue so adjacent TTS sentences play back-to-back without the per-file
|
||||
* codec re-init seam that the old `MediaPlayer` implementation produced.
|
||||
* This is the foundation of the Wave 1 gapless-playback work in the voice
|
||||
* quality pass plan — V5 in `docs/plans/2026-04-16-voice-quality-pass.md`.
|
||||
*
|
||||
* Amplitude is computed from [Visualizer] PCM waveform RMS — one waveform
|
||||
* snapshot is captured every ~40 ms (the visualizer's default capture rate)
|
||||
* and reduced to a 0..1 float. On devices that don't allow Visualizer
|
||||
* construction (missing MODIFY_AUDIO_SETTINGS or OEM quirks) we log and
|
||||
* continue with amplitude pinned at 0 rather than crashing the voice session.
|
||||
*
|
||||
* One player instance owns at most one active playback. Calling [play] again
|
||||
* while something is playing stops the previous file first.
|
||||
* The Visualizer is attached exactly once against the ExoPlayer's
|
||||
* [ExoPlayer.getAudioSessionId]. There is a known gotcha where re-attaching
|
||||
* the Visualizer on every track transition invalidates the session id — the
|
||||
* single-attach lifecycle here sidesteps it entirely.
|
||||
*
|
||||
* @param context used for [ExoPlayer.Builder]. Application context is fine;
|
||||
* the player holds no view references.
|
||||
* @param exoPlayerFactory seam for unit tests — production defaults to a
|
||||
* real Media3 `ExoPlayer.Builder` with `setHandleAudioBecomingNoisy`.
|
||||
* Tests inject a MockK mock directly to avoid
|
||||
* `mockkConstructor(ExoPlayer.Builder::class)`, which fails on the
|
||||
* JVM unit test classpath because Media3's `Builder` static init
|
||||
* chain pulls in android.os.Looper etc. that aren't shadowed there.
|
||||
*/
|
||||
class VoicePlayer {
|
||||
@OptIn(UnstableApi::class)
|
||||
class VoicePlayer(
|
||||
context: Context,
|
||||
exoPlayerFactory: (Context) -> ExoPlayer = ::defaultExoPlayer,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoicePlayer"
|
||||
@@ -34,99 +63,259 @@ class VoicePlayer {
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
// Mirrors the most recent value passed to [setVolume] / [duck] / [unduck].
|
||||
// ExoPlayer's own `volume` getter is the source of truth for the audio
|
||||
// pipeline, but we keep a local copy so callers can introspect current
|
||||
// ducking state without racing the underlying ExoPlayer thread, and so
|
||||
// future reconfig paths (reconstruct ExoPlayer, swap sink, etc.) can
|
||||
// re-apply the same volume without losing the caller's intent.
|
||||
@Volatile private var currentVolume: Float = 1f
|
||||
|
||||
// Tracked via Player.Listener.onIsPlayingChanged so awaitCompletion can
|
||||
// suspend on the combined (isPlaying, mediaItemCount) signal without
|
||||
// polling the player from arbitrary threads.
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
|
||||
// Logical count of media items still owned by this playback turn. ExoPlayer
|
||||
// retains played playlist items after STATE_ENDED, so this cannot mirror
|
||||
// mediaItemCount blindly at end-of-queue.
|
||||
private val _queueCount = MutableStateFlow(0)
|
||||
|
||||
private var visualizer: Visualizer? = null
|
||||
private var completionListener: (() -> Unit)? = null
|
||||
private var visualizerAttached = false
|
||||
|
||||
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
|
||||
// thread-confined — every accessor (the audioSessionId getter included)
|
||||
// calls verifyApplicationThread() and throws "Player is accessed on the
|
||||
// wrong thread" if touched off the player's construction thread. The
|
||||
// barge-in pipeline reads [audioSessionId] from BargeInListener's
|
||||
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
|
||||
// can't expose the raw getter. Instead we cache the id from the
|
||||
// main-thread Media3 callbacks below and serve the getter from this
|
||||
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
|
||||
// first sentence played for ~2 syllables before the IO read threw.)
|
||||
@Volatile private var cachedAudioSessionId: Int = 0
|
||||
|
||||
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
|
||||
|
||||
init {
|
||||
// AnalyticsListener callbacks are delivered on the player's
|
||||
// application (main) thread, so caching the id here is the
|
||||
// authoritative, thread-correct way to track it as Media3 allocates
|
||||
// and reallocates the underlying AudioTrack.
|
||||
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioSessionIdChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioSessionId: Int,
|
||||
) {
|
||||
cachedAudioSessionId = audioSessionId
|
||||
}
|
||||
})
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
if (!isPlaying) _amplitude.value = 0f
|
||||
// Lazily attach the Visualizer the first time playback
|
||||
// actually begins — the audio session id is stable from
|
||||
// player construction on Media3 1.x but some OEM pipelines
|
||||
// don't allocate the track until playback starts.
|
||||
if (isPlaying) {
|
||||
// Belt-and-braces with the analytics listener above: this
|
||||
// runs on the main thread too, so reading the getter here
|
||||
// is safe and guarantees the cache is warm by the time
|
||||
// playback is audible (and thus by the time barge-in
|
||||
// starts its IO reader).
|
||||
cachedAudioSessionId = exoPlayer.audioSessionId
|
||||
if (!visualizerAttached) {
|
||||
attachVisualizer(cachedAudioSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
// Refresh on every transition — covers auto-advance drain
|
||||
// at end-of-queue and explicit seekToNext paths.
|
||||
_queueCount.value = exoPlayer.mediaItemCount
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
when (state) {
|
||||
Player.STATE_ENDED -> {
|
||||
// Media3 keeps consumed playlist entries around. Clear
|
||||
// them here so awaitCompletion observes a true drain and
|
||||
// voice mode can leave Speaking when the last TTS chunk ends.
|
||||
exoPlayer.clearMediaItems()
|
||||
_queueCount.value = 0
|
||||
_isPlaying.value = false
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
if (exoPlayer.mediaItemCount == 0) {
|
||||
_queueCount.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback of [audioFile]. Returns immediately; completion is
|
||||
* delivered via [awaitCompletion]. If another file is already playing,
|
||||
* [stop]s it first.
|
||||
* Append [audioFile] to the ExoPlayer queue. If the player is idle, also
|
||||
* [ExoPlayer.prepare] and [ExoPlayer.play]. Non-blocking — completion is
|
||||
* delivered via [awaitCompletion], which now observes the entire queue
|
||||
* rather than a single file.
|
||||
*/
|
||||
fun play(audioFile: File) {
|
||||
if (mediaPlayer != null) {
|
||||
Log.w(TAG, "play called while another file is playing — stopping first")
|
||||
stop()
|
||||
val wasIdle = exoPlayer.mediaItemCount == 0 &&
|
||||
exoPlayer.playbackState != Player.STATE_READY &&
|
||||
exoPlayer.playbackState != Player.STATE_BUFFERING
|
||||
exoPlayer.addMediaItem(MediaItem.fromUri(audioFile.toUri()))
|
||||
_queueCount.value = exoPlayer.mediaItemCount
|
||||
if (wasIdle) {
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.play()
|
||||
} else if (!exoPlayer.isPlaying && exoPlayer.playWhenReady.not()) {
|
||||
// Queue had drained but player wasn't torn down — restart.
|
||||
exoPlayer.play()
|
||||
}
|
||||
|
||||
val player = MediaPlayer()
|
||||
try {
|
||||
player.setDataSource(audioFile.absolutePath)
|
||||
player.prepare()
|
||||
player.setOnCompletionListener {
|
||||
_amplitude.value = 0f
|
||||
completionListener?.invoke()
|
||||
}
|
||||
player.setOnErrorListener { _, what, extra ->
|
||||
Log.e(TAG, "MediaPlayer error: what=$what extra=$extra")
|
||||
_amplitude.value = 0f
|
||||
completionListener?.invoke()
|
||||
true
|
||||
}
|
||||
player.start()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MediaPlayer setup failed: ${e.message}")
|
||||
try { player.release() } catch (_: Exception) { /* ignore */ }
|
||||
throw e
|
||||
}
|
||||
|
||||
mediaPlayer = player
|
||||
attachVisualizer(player)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend until the current playback completes or errors. Cancellable —
|
||||
* if the caller cancels, playback is left running (use [stop] for a
|
||||
* hard teardown).
|
||||
* Suspend until the ExoPlayer queue is drained AND playback has stopped.
|
||||
*
|
||||
* **Semantic change from the old MediaPlayer implementation.** Previously
|
||||
* this returned when the *current file* completed. Now it returns when
|
||||
* the entire logical queue has been consumed — i.e. `_queueCount == 0 &&
|
||||
* !isPlaying`. This matches the gapless-playback model where adjacent
|
||||
* sentences play back-to-back from the same ExoPlayer, and it's exactly
|
||||
* what the V4 prefetch pipelining rewrite needs (synth worker can enqueue
|
||||
* N+1 while play worker is still awaiting queue-drain on N).
|
||||
*
|
||||
* If a caller appends new items to the queue while this is suspended,
|
||||
* the wait extends through the new items as well.
|
||||
*
|
||||
* Cancellable. If the caller cancels, playback is left running — use
|
||||
* [stop] for a hard teardown.
|
||||
*/
|
||||
suspend fun awaitCompletion(): Unit = suspendCancellableCoroutine { cont ->
|
||||
if (mediaPlayer == null) {
|
||||
cont.resume(Unit)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
completionListener = {
|
||||
completionListener = null
|
||||
if (cont.isActive) cont.resume(Unit)
|
||||
}
|
||||
cont.invokeOnCancellation {
|
||||
completionListener = null
|
||||
}
|
||||
suspend fun awaitCompletion() {
|
||||
// Fast-path: already idle.
|
||||
if (_queueCount.value == 0 && !_isPlaying.value) return
|
||||
combine(_queueCount, _isPlaying) { count, playing -> count == 0 && !playing }
|
||||
.first { drained -> drained }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard teardown: stop playback, release visualizer + player, reset
|
||||
* amplitude. Safe to call repeatedly.
|
||||
* Hard teardown of the current playback session. Clears the queue,
|
||||
* stops ExoPlayer, releases the Visualizer, and resets amplitude.
|
||||
* The ExoPlayer itself is kept alive for reuse — the next [play] call
|
||||
* will re-prepare it. Safe to call repeatedly.
|
||||
*/
|
||||
fun stop() {
|
||||
completionListener = null
|
||||
exoPlayer.clearMediaItems()
|
||||
exoPlayer.stop()
|
||||
_queueCount.value = 0
|
||||
_isPlaying.value = false
|
||||
|
||||
visualizer?.let { v ->
|
||||
try { v.enabled = false } catch (_: Exception) { /* ignore */ }
|
||||
try { v.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
visualizer = null
|
||||
|
||||
mediaPlayer?.let { p ->
|
||||
try {
|
||||
if (p.isPlaying) p.stop()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
try { p.reset() } catch (_: Exception) { /* ignore */ }
|
||||
try { p.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
mediaPlayer = null
|
||||
visualizerAttached = false
|
||||
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* True if there's an active [MediaPlayer]. Doesn't check `isPlaying` —
|
||||
* that would race with the completion listener.
|
||||
* True if the ExoPlayer has any queued media items (playing or paused
|
||||
* mid-queue). Matches the old semantic of "there's audio in flight".
|
||||
*/
|
||||
fun isPlaying(): Boolean = mediaPlayer != null
|
||||
fun isPlaying(): Boolean = _queueCount.value > 0
|
||||
|
||||
private fun attachVisualizer(player: MediaPlayer) {
|
||||
/**
|
||||
* Current ExoPlayer audio session id. Returns `0` until the underlying
|
||||
* [android.media.AudioTrack] has been allocated — Media3 defers that
|
||||
* allocation to first playback on most devices. Callers that need a
|
||||
* non-zero session id (barge-in's [android.media.audiofx.AcousticEchoCanceler]
|
||||
* attach path in [com.hermesandroid.relay.audio.BargeInListener]) should
|
||||
* poll this property briefly rather than assume it's hot-ready at
|
||||
* [VoicePlayer] construction time.
|
||||
*
|
||||
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
|
||||
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
|
||||
* thread-confined and [BargeInListener] reads this from its
|
||||
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
|
||||
* throws `IllegalStateException: Player is accessed on the wrong thread`.
|
||||
* The cache is populated from main-thread Media3 callbacks (the
|
||||
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
|
||||
*
|
||||
* Exposed read-only. B4 reads it via a provider lambda so the listener
|
||||
* can re-check across the 1 s poll window without holding a stale
|
||||
* reference.
|
||||
*/
|
||||
val audioSessionId: Int
|
||||
get() = cachedAudioSessionId
|
||||
|
||||
/**
|
||||
* Set the playback volume of the underlying ExoPlayer.
|
||||
*
|
||||
* **Barge-in use case.** The barge-in pipeline (see
|
||||
* `docs/plans/2026-04-17-voice-barge-in.md`, unit B6) runs the mic
|
||||
* through a Silero VAD while TTS plays. On a *single* "maybe speech"
|
||||
* frame — one positive frame that hasn't yet passed the hysteresis
|
||||
* debounce — we soft-duck via [duck] instead of hard-stopping. If the
|
||||
* speech is confirmed (enough consecutive positive frames pass the
|
||||
* debounce), [VoiceViewModel] calls the hard-stop path
|
||||
* (`interruptSpeaking()`); if the frame was a false positive, a
|
||||
* watchdog re-calls [unduck] to restore full volume. The result is a
|
||||
* fast-reacting but false-positive-tolerant interruption feel.
|
||||
*
|
||||
* @param volume linear gain in the range `0f..1f`; values outside this
|
||||
* range are clamped. Forwarded verbatim to `ExoPlayer.volume`.
|
||||
*/
|
||||
fun setVolume(volume: Float) {
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
currentVolume = clamped
|
||||
exoPlayer.volume = clamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-duck TTS to 30% of full volume. See [setVolume] for context —
|
||||
* used by barge-in on a single VAD positive frame, before the
|
||||
* hysteresis debounce confirms an actual interruption.
|
||||
*/
|
||||
fun duck() {
|
||||
setVolume(0.3f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore TTS to full volume. Pair with [duck]; safe to call even if
|
||||
* not currently ducked.
|
||||
*/
|
||||
fun unduck() {
|
||||
setVolume(1.0f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully release the underlying ExoPlayer. Call when the owning scope is
|
||||
* being destroyed; the VoicePlayer instance is unusable after this.
|
||||
*/
|
||||
fun release() {
|
||||
stop()
|
||||
exoPlayer.release()
|
||||
}
|
||||
|
||||
private fun attachVisualizer(audioSessionId: Int) {
|
||||
if (audioSessionId == 0) {
|
||||
// ExoPlayer returns 0 before the audio track is allocated; retry
|
||||
// on the next playback-start event.
|
||||
return
|
||||
}
|
||||
try {
|
||||
val viz = Visualizer(player.audioSessionId)
|
||||
val viz = Visualizer(audioSessionId)
|
||||
viz.captureSize = VISUALIZER_SIZE_BYTES.coerceIn(
|
||||
Visualizer.getCaptureSizeRange()[0],
|
||||
Visualizer.getCaptureSizeRange()[1],
|
||||
@@ -154,13 +343,16 @@ class VoicePlayer {
|
||||
)
|
||||
viz.enabled = true
|
||||
visualizer = viz
|
||||
visualizerAttached = true
|
||||
} catch (e: Exception) {
|
||||
// Some devices refuse Visualizer (MODIFY_AUDIO_SETTINGS denied,
|
||||
// OEM restrictions). Fall back to flat-zero amplitude rather
|
||||
// than killing the voice session.
|
||||
// than killing the voice session. Mark as "attached" so we don't
|
||||
// keep retrying on every isPlaying transition.
|
||||
Log.w(TAG, "Visualizer unavailable — amplitude stuck at 0: ${e.message}")
|
||||
_amplitude.value = 0f
|
||||
visualizer = null
|
||||
visualizerAttached = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,3 +381,14 @@ class VoicePlayer {
|
||||
else normalized.coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production ExoPlayer factory — used as the default for [VoicePlayer].
|
||||
* Split out as a top-level function so unit tests can swap it for a
|
||||
* MockK mock without touching Media3's `Builder` class loader.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun defaultExoPlayer(context: Context): ExoPlayer =
|
||||
ExoPlayer.Builder(context)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
@@ -2,60 +2,47 @@ package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures the user's voice into an `.m4a` (AAC-in-MP4) file for V2a voice
|
||||
* mode. The relay's `/voice/transcribe` endpoint feeds this to whisper-1 via
|
||||
* OpenAI, which accepts m4a/mp4 natively.
|
||||
* Captures the user's voice as 16 kHz mono PCM and writes a `.wav` file for
|
||||
* the relay STT endpoint. The raw PCM is retained for the server-mediated
|
||||
* `/voice/realtime/{session}` path so the main voice UI can send the same utterance
|
||||
* through the realtime websocket without opening a second microphone stream.
|
||||
*
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter) —
|
||||
* driven by polling `MediaRecorder.maxAmplitude` every ~16 ms. The polling
|
||||
* coroutine runs on the caller-supplied [scope] so it dies with the owning
|
||||
* ViewModel.
|
||||
*
|
||||
* One recorder instance owns at most one active recording at a time. Calling
|
||||
* [startRecording] again while a recording is in flight will stop the
|
||||
* previous one first. [stopRecording] is safe to call when nothing is
|
||||
* running (it just returns the last file, or throws if there never was one).
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter). The
|
||||
* value is computed from the same PCM frames that are written to disk, which
|
||||
* keeps legacy STT fallback and realtime voice testing on a single capture
|
||||
* path.
|
||||
*/
|
||||
class VoiceRecorder(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@Suppress("UNUSED_PARAMETER") private val scope: kotlinx.coroutines.CoroutineScope,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceRecorder"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val BIT_RATE = 64_000
|
||||
private const val AMPLITUDE_POLL_MS = 16L
|
||||
private const val BYTES_PER_SAMPLE = 2
|
||||
private const val CHANNEL_COUNT = 1
|
||||
private const val MAX_AMPLITUDE_SHORT = 32_767f
|
||||
private const val MAX_PCM_BYTES = 25 * 1024 * 1024
|
||||
|
||||
// Perceptual amplitude mapping constants. Raw PCM peak values from
|
||||
// MediaRecorder.maxAmplitude for a phone at arm's length:
|
||||
// silence / ambient : 100..500 (≤0.015 of max)
|
||||
// quiet speech : 500..3000 (0.015..0.09)
|
||||
// normal speech : 3000..8000 (0.09..0.24)
|
||||
// loud speech : 8000..18000 (0.24..0.55)
|
||||
// shout / clipping : 18000..32767 (0.55..1.0)
|
||||
//
|
||||
// Linear 0..1 puts normal conversation between 0.09 and 0.24 — the
|
||||
// meter barely moves. Subtract a noise floor, rescale into the
|
||||
// speech-ceiling window, then apply a sqrt curve so quiet speech
|
||||
// still registers visually without drowning loud speech at the top.
|
||||
// Keep the perceptual curve from the previous MediaRecorder-backed
|
||||
// implementation so the on-screen meter feels the same.
|
||||
private const val NOISE_FLOOR = 0.01f
|
||||
private const val SPEECH_CEILING = 0.35f
|
||||
}
|
||||
@@ -63,162 +50,231 @@ class VoiceRecorder(
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
val sampleRate: Int get() = SAMPLE_RATE
|
||||
|
||||
private val bufferLock = Any()
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var currentOutputFile: File? = null
|
||||
private var pollJob: Job? = null
|
||||
private var readThread: Thread? = null
|
||||
private var readDone: CountDownLatch? = null
|
||||
private var pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
private var lastPcmBytes: ByteArray = ByteArray(0)
|
||||
|
||||
/**
|
||||
* Begin a new recording. Returns the output [File] that will receive the
|
||||
* audio once [stopRecording] is called. Throws on permission failure or
|
||||
* encoder init failure — callers should catch and surface to the UI.
|
||||
* Begin a new recording. Returns the output [File] that will contain WAV
|
||||
* audio once [stopRecording] is called.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startRecording(): File {
|
||||
// Defensive: if a recording is somehow still running, tear it down
|
||||
// before starting a new one. MediaRecorder transitions are strict.
|
||||
if (mediaRecorder != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight — stopping it first")
|
||||
if (audioRecord != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight; stopping it first")
|
||||
try {
|
||||
stopRecording()
|
||||
} catch (_: Exception) {
|
||||
// Swallow — we're about to overwrite state anyway.
|
||||
releaseRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.m4a")
|
||||
currentOutputFile = outFile
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
|
||||
|
||||
val recorder = buildRecorder()
|
||||
try {
|
||||
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
recorder.setAudioSamplingRate(SAMPLE_RATE)
|
||||
recorder.setAudioEncodingBitRate(BIT_RATE)
|
||||
recorder.setAudioChannels(1)
|
||||
recorder.setOutputFile(outFile.absolutePath)
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, "MediaRecorder failed to start: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.wav")
|
||||
currentOutputFile = outFile
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
stopRequested.set(false)
|
||||
_amplitude.value = 0f
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
throw IllegalStateException("AudioRecord failed to initialize")
|
||||
}
|
||||
|
||||
try {
|
||||
recorder.startRecording()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MediaRecorder setup failed: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
}
|
||||
|
||||
mediaRecorder = recorder
|
||||
startAmplitudePolling()
|
||||
audioRecord = recorder
|
||||
val done = CountDownLatch(1)
|
||||
readDone = done
|
||||
readThread = Thread(
|
||||
{
|
||||
readPcmLoop(recorder, minBuffer)
|
||||
done.countDown()
|
||||
},
|
||||
"HermesVoiceRecorder",
|
||||
).also { it.start() }
|
||||
return outFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the active recording, flush the encoder, and return the completed
|
||||
* output [File]. Safe to call when nothing is recording — in that case
|
||||
* it returns the last file produced, or throws if there never was one.
|
||||
* Stop the active recording, write the WAV container, and return it.
|
||||
*/
|
||||
fun stopRecording(): File {
|
||||
val file = currentOutputFile
|
||||
?: throw IllegalStateException("stopRecording called with no active recording")
|
||||
|
||||
stopAmplitudePolling()
|
||||
|
||||
val recorder = mediaRecorder
|
||||
if (recorder != null) {
|
||||
val record = audioRecord
|
||||
stopRequested.set(true)
|
||||
if (record != null) {
|
||||
try {
|
||||
recorder.stop()
|
||||
record.stop()
|
||||
} catch (e: IllegalStateException) {
|
||||
// MediaRecorder.stop throws if called before any audio was
|
||||
// captured (sub-300ms recordings). Treat as recoverable —
|
||||
// the output file may be 0 bytes but the caller can check.
|
||||
Log.w(TAG, "MediaRecorder.stop threw — recording may be empty: ${e.message}")
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "MediaRecorder.stop runtime error: ${e.message}")
|
||||
} finally {
|
||||
releaseRecorder()
|
||||
Log.w(TAG, "AudioRecord.stop threw; recording may be empty: ${e.message}")
|
||||
}
|
||||
}
|
||||
readDone?.await(1, TimeUnit.SECONDS)
|
||||
releaseRecorder()
|
||||
|
||||
val pcm = synchronized(bufferLock) {
|
||||
pcmBuffer.toByteArray().also { lastPcmBytes = it }
|
||||
}
|
||||
writeWav(file, pcm)
|
||||
_amplitude.value = 0f
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a recording is currently active. Cheap — just checks whether
|
||||
* we have a live [MediaRecorder] reference.
|
||||
*/
|
||||
fun isRecording(): Boolean = mediaRecorder != null
|
||||
fun isRecording(): Boolean = audioRecord != null && !stopRequested.get()
|
||||
|
||||
fun lastPcmBytes(): ByteArray = synchronized(bufferLock) {
|
||||
lastPcmBytes.copyOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release any recorder resources without returning a file. Safe fallback
|
||||
* for error paths where the output file is known-invalid.
|
||||
* Release any recorder resources without returning a file.
|
||||
*/
|
||||
fun cancel() {
|
||||
stopAmplitudePolling()
|
||||
mediaRecorder?.let { r ->
|
||||
try {
|
||||
r.stop()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
stopRequested.set(true)
|
||||
audioRecord?.let { record ->
|
||||
try { record.stop() } catch (_: Exception) { }
|
||||
}
|
||||
readDone?.await(500, TimeUnit.MILLISECONDS)
|
||||
releaseRecorder()
|
||||
currentOutputFile?.let { f ->
|
||||
try { f.delete() } catch (_: Exception) { /* ignore */ }
|
||||
currentOutputFile?.let { file ->
|
||||
try { file.delete() } catch (_: Exception) { }
|
||||
}
|
||||
currentOutputFile = null
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer.reset()
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
mediaRecorder?.let { r ->
|
||||
try { r.reset() } catch (_: Exception) { /* ignore */ }
|
||||
try { r.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
mediaRecorder = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun buildRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
private fun startAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
val recorder = mediaRecorder ?: break
|
||||
val raw = try {
|
||||
recorder.maxAmplitude
|
||||
} catch (e: IllegalStateException) {
|
||||
// Recorder torn down under us — exit quietly.
|
||||
break
|
||||
private fun readPcmLoop(record: AudioRecord, minBuffer: Int) {
|
||||
val buffer = ByteArray(minBuffer)
|
||||
while (!stopRequested.get()) {
|
||||
val read = try {
|
||||
record.read(buffer, 0, buffer.size)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AudioRecord.read failed: ${e.message}")
|
||||
break
|
||||
}
|
||||
if (read > 0) {
|
||||
synchronized(bufferLock) {
|
||||
if (pcmBuffer.size() + read <= MAX_PCM_BYTES) {
|
||||
pcmBuffer.write(buffer, 0, read)
|
||||
} else {
|
||||
stopRequested.set(true)
|
||||
}
|
||||
}
|
||||
val raw01 = (raw.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
delay(AMPLITUDE_POLL_MS)
|
||||
updateAmplitude(buffer, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
private fun updateAmplitude(buffer: ByteArray, read: Int) {
|
||||
var peak = 0
|
||||
var index = 0
|
||||
val usable = read - (read % BYTES_PER_SAMPLE)
|
||||
while (index < usable) {
|
||||
val low = buffer[index].toInt() and 0xff
|
||||
val high = buffer[index + 1].toInt()
|
||||
val sample = (high shl 8) or low
|
||||
val abs = kotlin.math.abs(sample.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
|
||||
if (abs > peak) peak = abs
|
||||
index += BYTES_PER_SAMPLE
|
||||
}
|
||||
val raw01 = (peak.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
audioRecord?.let { record ->
|
||||
try { record.release() } catch (_: Exception) { }
|
||||
}
|
||||
audioRecord = null
|
||||
readThread = null
|
||||
readDone = null
|
||||
}
|
||||
|
||||
private fun writeWav(file: File, pcm: ByteArray) {
|
||||
try {
|
||||
file.outputStream().use { out ->
|
||||
out.write(wavHeader(pcm.size))
|
||||
out.write(pcm)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw IOException("Failed to write WAV recording: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun wavHeader(pcmBytes: Int): ByteArray {
|
||||
val totalDataLen = pcmBytes + 36
|
||||
val byteRate = SAMPLE_RATE * CHANNEL_COUNT * BYTES_PER_SAMPLE
|
||||
return ByteArray(44).also { header ->
|
||||
fun ascii(offset: Int, value: String) {
|
||||
value.encodeToByteArray().copyInto(header, offset)
|
||||
}
|
||||
fun leInt(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
header[offset + 2] = ((value shr 16) and 0xff).toByte()
|
||||
header[offset + 3] = ((value shr 24) and 0xff).toByte()
|
||||
}
|
||||
fun leShort(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
}
|
||||
|
||||
ascii(0, "RIFF")
|
||||
leInt(4, totalDataLen)
|
||||
ascii(8, "WAVE")
|
||||
ascii(12, "fmt ")
|
||||
leInt(16, 16)
|
||||
leShort(20, 1)
|
||||
leShort(22, CHANNEL_COUNT)
|
||||
leInt(24, SAMPLE_RATE)
|
||||
leInt(28, byteRate)
|
||||
leShort(32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
|
||||
leShort(34, 16)
|
||||
ascii(36, "data")
|
||||
leInt(40, pcmBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -32,6 +41,15 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val pairedSessionMetaJson: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Orchestrates pairing + session token lifecycle for the relay channel.
|
||||
*
|
||||
@@ -56,16 +74,196 @@ sealed class AuthState {
|
||||
class AuthManager(
|
||||
private val context: Context,
|
||||
private val multiplexer: ChannelMultiplexer,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
/**
|
||||
* Multi-connection: the id of the [com.hermesandroid.relay.data.Connection]
|
||||
* this AuthManager is bound to. Drives which EncryptedSharedPreferences
|
||||
* file the underlying [SessionTokenStore] reads/writes.
|
||||
*
|
||||
* Defaults to [CONNECTION_ID_LEGACY] so the pre-multi-connection call site
|
||||
* in `ConnectionViewModel` still compiles. Worker B removes the default
|
||||
* and passes a real connection id when they wire the active connection
|
||||
* through.
|
||||
*/
|
||||
private val connectionId: String = CONNECTION_ID_LEGACY,
|
||||
/**
|
||||
* Exact EncryptedSharedPreferences filename for this connection. New
|
||||
* connections use the deterministic id-derived name, but the migrated
|
||||
* legacy connection intentionally keeps [Connection.LEGACY_TOKEN_STORE_KEY].
|
||||
*/
|
||||
private val tokenStoreKey: String? = null,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val HINT_API_KEY_PRESENT = "api_key_present"
|
||||
private const val KEY_PAIRED_META = "paired_session_meta_json"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
/**
|
||||
* Sentinel [connectionId] meaning "bind this AuthManager to the legacy
|
||||
* single-connection EncryptedSharedPreferences file
|
||||
* ([Connection.LEGACY_TOKEN_STORE_KEY])". Used as the default ctor arg
|
||||
* so existing call sites don't need to change until Worker B threads
|
||||
* a real connection id through.
|
||||
*/
|
||||
const val CONNECTION_ID_LEGACY: String = "legacy"
|
||||
|
||||
internal fun shouldPreservePairedSessionOnAuthFail(
|
||||
currentState: AuthState,
|
||||
rawReason: String,
|
||||
): Boolean {
|
||||
val lower = rawReason.lowercase()
|
||||
return currentState is AuthState.Paired &&
|
||||
"timeout" in lower &&
|
||||
("auth" in lower || "authentication" in lower)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of a connection's stored device id without making
|
||||
* that connection active. Used by the connection removal path so it
|
||||
* can delete the per-device route list before deleting the token
|
||||
* store backing file.
|
||||
*/
|
||||
suspend fun readStoredDeviceId(context: Context, tokenStoreKey: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val appContext = context.applicationContext
|
||||
val primary = KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
primary.getString(KEY_DEVICE_ID)
|
||||
?: if (tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
runCatching {
|
||||
LegacyEncryptedPrefsTokenStore(appContext).getString(KEY_DEVICE_ID)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): ConnectionAuthSecrets = withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
ConnectionAuthSecrets(
|
||||
sessionToken = store.getString(KEY_SESSION_TOKEN),
|
||||
refreshToken = store.getString(KEY_REFRESH_TOKEN),
|
||||
deviceId = store.getString(KEY_DEVICE_ID),
|
||||
apiKey = store.getString(KEY_API_KEY),
|
||||
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun importStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
secrets: ConnectionAuthSecrets,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
|
||||
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
|
||||
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
|
||||
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
|
||||
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokenStoreForBackup(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): SessionTokenStore {
|
||||
val appContext = context.applicationContext
|
||||
return KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
private fun writeOrRemove(
|
||||
store: SessionTokenStore,
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value == null) {
|
||||
store.remove(key)
|
||||
} else {
|
||||
store.putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `profiles` array from an `auth.ok` payload into a list of
|
||||
* [Profile] entries. Extracted out of [handleAuthOk] so it's
|
||||
* exercisable from a pure JVM unit test without constructing an
|
||||
* Android [Context] / [kotlinx.coroutines.CoroutineScope].
|
||||
*
|
||||
* Defensive rules, in order:
|
||||
* - Non-[JsonObject] entries (stray strings, numbers) are dropped.
|
||||
* - An entry missing `name` is dropped — the picker has no label
|
||||
* to render for it.
|
||||
* - `model` defaults to `"unknown"` so a profile without a model
|
||||
* still renders as a selectable chip (server misconfiguration,
|
||||
* but we don't want to silently drop the only profile).
|
||||
* - `description` defaults to `""`.
|
||||
* - `system_message` is passed through as-is, including JSON `null`.
|
||||
* A null or missing value means "this profile has no SOUL.md on
|
||||
* disk — fall back to the personality/default system prompt at
|
||||
* send time". Kept separate from an empty string so ChatViewModel
|
||||
* can cleanly detect "no override" via `systemMessage?.isNotBlank()`.
|
||||
* - `gateway_running`, `has_soul`, `skill_count` (v0.7.0 runtime
|
||||
* metadata) are optional on the wire. Missing / malformed values
|
||||
* fall back to `false` / `false` / `0` so older relays stay
|
||||
* compatible and bad server data can't crash the pairing handshake.
|
||||
* - `api_server_*` metadata is optional. When present, it lets the
|
||||
* client route chat through a profile's isolated Hermes API
|
||||
* server without exposing that profile server's key.
|
||||
*/
|
||||
fun parseAgentProfiles(array: JsonArray): List<Profile> {
|
||||
return array.mapNotNull { entry ->
|
||||
val obj = entry as? JsonObject ?: return@mapNotNull null
|
||||
val name = obj["name"]?.jsonPrimitive?.contentOrNull
|
||||
?: return@mapNotNull null
|
||||
val model = obj["model"]?.jsonPrimitive?.contentOrNull
|
||||
?: "unknown"
|
||||
val description = obj["description"]?.jsonPrimitive?.contentOrNull
|
||||
?: ""
|
||||
val systemMessage = obj["system_message"]?.jsonPrimitive?.contentOrNull
|
||||
val gatewayRunning = obj["gateway_running"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val hasSoul = obj["has_soul"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val skillCount = obj["skill_count"]
|
||||
?.jsonPrimitive?.intOrNull ?: 0
|
||||
val apiServerEnabled = obj["api_server_enabled"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val apiServerUrl = obj["api_server_url"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerHost = obj["api_server_host"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerPort = obj["api_server_port"]
|
||||
?.jsonPrimitive?.intOrNull
|
||||
val apiServerKeyPresent = obj["api_server_key_present"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
Profile(
|
||||
name = name,
|
||||
model = model,
|
||||
description = description,
|
||||
systemMessage = systemMessage,
|
||||
gatewayRunning = gatewayRunning,
|
||||
hasSoul = hasSoul,
|
||||
skillCount = skillCount,
|
||||
apiServerEnabled = apiServerEnabled,
|
||||
apiServerUrl = apiServerUrl,
|
||||
apiServerHost = apiServerHost,
|
||||
apiServerPort = apiServerPort,
|
||||
apiServerKeyPresent = apiServerKeyPresent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -75,6 +273,48 @@ class AuthManager(
|
||||
private var _store: SessionTokenStore? = null
|
||||
private val storeMutex = Mutex()
|
||||
|
||||
/**
|
||||
* The encrypted-store filename for this connection — shared by [store]
|
||||
* and the plain hint file below so they always describe the same store.
|
||||
*/
|
||||
private val tokenPrefsName: String =
|
||||
tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain (non-encrypted) mirror of one boolean fact: "does this
|
||||
* connection have an API key stored?". Read at startup WITHOUT touching
|
||||
* the Keystore, so [ConnectionViewModel] can build the API client
|
||||
* immediately for key-less connections — the common local setup —
|
||||
* instead of queueing behind the encrypted store's first decrypt.
|
||||
*
|
||||
* Why this exists: on StrongBox devices every keystore operation runs
|
||||
* ~550ms and Tink serializes them process-globally; a measured S25
|
||||
* Ultra cold start spent 15 seconds in that marathon before
|
||||
* `getApiKey()` could return — only to answer "there is no key".
|
||||
*
|
||||
* The hint stores ONLY presence, never key material. It defaults to
|
||||
* `true` (unknown ⇒ assume a key exists ⇒ wait for the real decrypt),
|
||||
* so a missing or stale hint can never strip auth off a keyed
|
||||
* connection — the failure mode is "slow like before", never "401s".
|
||||
* It converges in [setApiKey]/[clearApiKey], in init's store
|
||||
* hydration, and after legacy migration.
|
||||
*/
|
||||
private val hintPrefs by lazy {
|
||||
context.getSharedPreferences("${tokenPrefsName}_plain_hints", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** True only when a previously-recorded hint says "no API key stored". */
|
||||
fun apiKeyKnownAbsent(): Boolean = !hintPrefs.getBoolean(HINT_API_KEY_PRESENT, true)
|
||||
|
||||
private fun recordApiKeyHint(present: Boolean) {
|
||||
_apiKeyPresent.value = present
|
||||
hintPrefs.edit().putBoolean(HINT_API_KEY_PRESENT, present).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily construct the best available token store. First tries
|
||||
* [KeystoreTokenStore] — if that fails on broken OEM keystores we fall
|
||||
@@ -90,8 +330,14 @@ class AuthManager(
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Multi-connection: [tokenPrefsName] picks the
|
||||
// EncryptedSharedPreferences filename for the bound
|
||||
// connection. The legacy sentinel keeps the pre-multi-
|
||||
// connection install on its original file so the existing
|
||||
// paired device keeps working with no migration.
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context) ?: LegacyEncryptedPrefsTokenStore(context)
|
||||
KeystoreTokenStore.tryCreate(context, tokenPrefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(context, tokenPrefsName)
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
@@ -107,13 +353,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 +462,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 +505,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,8 +519,17 @@ 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()
|
||||
// Converge the plain api-key-present hint with the decrypted
|
||||
// truth (also repairs a hint that predates legacy migration).
|
||||
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +614,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 +636,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 +673,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 +691,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 +750,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 +776,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 +840,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
|
||||
@@ -462,16 +857,16 @@ class AuthManager(
|
||||
val s = store()
|
||||
if (trimmed.isBlank()) {
|
||||
s.remove(KEY_API_KEY)
|
||||
_apiKeyPresent.value = false
|
||||
recordApiKeyHint(false)
|
||||
} else {
|
||||
s.putString(KEY_API_KEY, trimmed)
|
||||
_apiKeyPresent.value = true
|
||||
recordApiKeyHint(true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearApiKey() {
|
||||
store().remove(KEY_API_KEY)
|
||||
_apiKeyPresent.value = false
|
||||
recordApiKeyHint(false)
|
||||
}
|
||||
|
||||
val isPaired: Boolean
|
||||
@@ -483,10 +878,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 +939,129 @@ 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")
|
||||
if (shouldPreservePairedSessionOnAuthFail(_authState.value, rawReason)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleAuthFail: preserving paired session after transient auth timeout"
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingPairContextAfterAuthFailure(rawReason)
|
||||
_authState.value = AuthState.Failed(humanized)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "handleAuthFail: exception parsing payload", e)
|
||||
clearPendingPairContextAfterAuthFailure("parse failure")
|
||||
_authState.value = AuthState.Failed("Authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingPairContextAfterAuthFailure(reason: String) {
|
||||
if (serverIssuedCode == null) return
|
||||
serverIssuedCode = null
|
||||
pendingTtlSeconds = null
|
||||
pendingGrants = null
|
||||
pendingEndpoints = null
|
||||
_pairingCode.value = generatePairingCode()
|
||||
Log.i(
|
||||
TAG,
|
||||
"auth.fail consumed server-issued pairing code; " +
|
||||
"cleared pending pair context so reconnects stop (reason=$reason)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map common relay `auth.fail` reasons to user-friendly short strings.
|
||||
* The wizard VerifyStep surfaces the returned text directly, so this is
|
||||
* 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.
|
||||
|
||||
@@ -65,49 +65,112 @@ interface SessionTokenStore {
|
||||
* phone.
|
||||
*/
|
||||
class KeystoreTokenStore private constructor(
|
||||
private val prefs: SharedPreferences,
|
||||
override val hasHardwareBackedStorage: Boolean
|
||||
private val context: Context,
|
||||
private val wantsStrongBox: Boolean,
|
||||
override val hasHardwareBackedStorage: Boolean,
|
||||
private val prefsName: String,
|
||||
) : SessionTokenStore {
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. Built lazily via [buildPrefs] so the constructor can't
|
||||
// throw — [tryCreate] still controls the "is this device usable at all"
|
||||
// decision via its init probe below.
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
val builder = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
if (wantsStrongBox) {
|
||||
try {
|
||||
builder.setRequestStrongBoxBacked(true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "StrongBox request failed, falling back: ${e.message}")
|
||||
}
|
||||
}
|
||||
val masterKey = builder.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
prefsName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuke a corrupted EncryptedSharedPreferences file and rebuild a fresh
|
||||
* one. Triggered from any read/write that throws — the typical failure
|
||||
* mode is the master key getting rotated out from under us during a
|
||||
* Studio reinstall, after which every decrypt fails with
|
||||
* `AEADBadTagException` (or sometimes a wrapped `GeneralSecurityException`)
|
||||
* forever. The cure is to delete the file so the next pair flow re-stores
|
||||
* everything against a fresh key.
|
||||
*
|
||||
* Best-effort: swallow exceptions from the `clear()` and
|
||||
* `deleteSharedPreferences` calls themselves, since they can also throw
|
||||
* when the underlying state is wedged.
|
||||
*/
|
||||
private fun resetPrefs() {
|
||||
try {
|
||||
prefs.edit().clear().apply()
|
||||
} catch (_: Exception) { /* expected on a wedged file */ }
|
||||
try {
|
||||
context.deleteSharedPreferences(prefsName)
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
* null when any step throws (some older OEM ROMs have broken
|
||||
* 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(
|
||||
android.content.pm.PackageManager.FEATURE_STRONGBOX_KEYSTORE
|
||||
)
|
||||
|
||||
val builder = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
|
||||
if (wantsStrongBox) {
|
||||
try {
|
||||
builder.setRequestStrongBoxBacked(true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "StrongBox request failed, falling back: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val masterKey = builder.build()
|
||||
|
||||
val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
val store = KeystoreTokenStore(
|
||||
context = context.applicationContext,
|
||||
wantsStrongBox = wantsStrongBox,
|
||||
hasHardwareBackedStorage = wantsStrongBox,
|
||||
prefsName = prefsName,
|
||||
)
|
||||
|
||||
KeystoreTokenStore(prefs, wantsStrongBox)
|
||||
// Force a read so a wedged file from a prior install heals
|
||||
// here rather than at the first user-visible call.
|
||||
store.contains("__init_probe__")
|
||||
store
|
||||
} catch (e: Exception) {
|
||||
// Broken Keystore, expired key, etc. — fall back to legacy.
|
||||
Log.w(TAG, "KeystoreTokenStore init failed: ${e.message}")
|
||||
@@ -116,20 +179,56 @@ class KeystoreTokenStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getString(key: String): String? = prefs.getString(key, null)
|
||||
override fun getString(key: String): String? {
|
||||
return try {
|
||||
prefs.getString(key, null)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "getString($key) failed — wiping corrupted prefs: ${e.message}")
|
||||
resetPrefs()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun putString(key: String, value: String) {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
try {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "putString($key) failed — rebuilding prefs and retrying: ${e.message}")
|
||||
resetPrefs()
|
||||
try {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
} catch (e2: Exception) {
|
||||
Log.w(TAG, "putString($key) retry after reset failed: ${e2.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun remove(key: String) {
|
||||
prefs.edit().remove(key).apply()
|
||||
try {
|
||||
prefs.edit().remove(key).apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "remove($key) failed: ${e.message}")
|
||||
resetPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
override fun contains(key: String): Boolean = prefs.contains(key)
|
||||
override fun contains(key: String): Boolean {
|
||||
return try {
|
||||
prefs.contains(key)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "contains($key) failed — wiping corrupted prefs: ${e.message}")
|
||||
resetPrefs()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
try {
|
||||
prefs.edit().clear().apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "clearAll failed — falling back to file delete: ${e.message}")
|
||||
resetPrefs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,42 +243,102 @@ 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"
|
||||
private const val TAG = "LegacyEncryptedPrefs"
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = run {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
private val appContext: Context = context.applicationContext
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. See [KeystoreTokenStore.resetPrefs] for the rationale.
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
LEGACY_PREFS_NAME,
|
||||
return EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
prefsName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
private fun resetPrefs() {
|
||||
try {
|
||||
prefs.edit().clear().apply()
|
||||
} catch (_: Exception) { /* expected on a wedged file */ }
|
||||
try {
|
||||
appContext.deleteSharedPreferences(prefsName)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
|
||||
}
|
||||
prefs = buildPrefs()
|
||||
}
|
||||
|
||||
// AES256_GCM via MasterKey is hardware-backed (TEE) on essentially every
|
||||
// shipping Android device — but we don't have the StrongBox attestation
|
||||
// guarantee, so we report false here. The UI uses this to decide whether
|
||||
// to render the shield badge.
|
||||
override val hasHardwareBackedStorage: Boolean = false
|
||||
|
||||
override fun getString(key: String): String? = prefs.getString(key, null)
|
||||
override fun getString(key: String): String? {
|
||||
return try {
|
||||
prefs.getString(key, null)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "getString($key) failed — wiping legacy prefs: ${e.message}")
|
||||
resetPrefs()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun putString(key: String, value: String) {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
try {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "putString($key) failed — rebuilding legacy prefs and retrying: ${e.message}")
|
||||
resetPrefs()
|
||||
try {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
} catch (e2: Exception) {
|
||||
Log.w(TAG, "putString($key) retry after reset failed: ${e2.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun remove(key: String) {
|
||||
prefs.edit().remove(key).apply()
|
||||
try {
|
||||
prefs.edit().remove(key).apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "remove($key) failed: ${e.message}")
|
||||
resetPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
override fun contains(key: String): Boolean {
|
||||
return try {
|
||||
prefs.contains(key)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "contains($key) failed — wiping legacy prefs: ${e.message}")
|
||||
resetPrefs()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun contains(key: String): Boolean = prefs.contains(key)
|
||||
override fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
try {
|
||||
prefs.edit().clear().apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "clearAll failed — falling back to file delete: ${e.message}")
|
||||
resetPrefs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
|
||||
|
||||
/**
|
||||
* Phase 3 — safety-rails `bridge-safety-rails`
|
||||
*
|
||||
* Canonical "turn the bridge off after idle" unit of work. Not a real
|
||||
* `androidx.work.CoroutineWorker` — the project intentionally does not
|
||||
* depend on androidx.work — but its shape mirrors one exactly: a single
|
||||
* suspend [run] method that performs the work and returns.
|
||||
*
|
||||
* Why this pattern instead of dropping a WorkManager dep:
|
||||
* - Auto-disable is a pure in-memory decision: the toggle lives in our
|
||||
* own DataStore, no inter-process scheduling is required.
|
||||
* - Android's AlarmManager / WorkManager are needed when the work must
|
||||
* survive process death. For bridge, process death already implies
|
||||
* the service is disconnected and the master toggle re-evaluates
|
||||
* fresh on the next launch. So a coroutine-owned `delay` does it.
|
||||
* - Every command reschedules the timer, so the idle window is always
|
||||
* reset against wall clock. No drift concerns.
|
||||
*
|
||||
* When WorkManager is added later (say, if notif-listener needs background-posted
|
||||
* notifications on a schedule), this file is a natural upgrade point:
|
||||
* change the class to `CoroutineWorker(appContext, params)` and have
|
||||
* [BridgeSafetyManager.rescheduleAutoDisable] enqueue a [OneTimeWorkRequest]
|
||||
* instead of launching a local coroutine.
|
||||
*/
|
||||
class AutoDisableWorker(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AutoDisableWorker"
|
||||
private const val CHANNEL_ID = "bridge_auto_disable"
|
||||
private const val CHANNEL_NAME = "Bridge auto-disable"
|
||||
private const val NOTIFICATION_ID = 3821
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the auto-disable: flip the master toggle off and post a
|
||||
* one-shot "bridge paused" notification. Idempotent — safe to call
|
||||
* twice (the second call just re-writes the same DataStore value
|
||||
* and overrides the existing notification).
|
||||
*/
|
||||
suspend fun run() {
|
||||
try {
|
||||
HermesAccessibilityService.setMasterEnabled(context, false)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "run: failed to flip master toggle", t)
|
||||
}
|
||||
postNotification()
|
||||
}
|
||||
|
||||
// Lint can't trace through [hasPostNotificationsPermission] to see that
|
||||
// we early-return when the runtime grant isn't held, and the notify()
|
||||
// call is also wrapped in runCatching to swallow SecurityException as
|
||||
// a belt-and-braces. Suppress here rather than inlining the check —
|
||||
// the helper exists so the same gate can grow more conditions later
|
||||
// without each call site re-implementing it. Both IDs are needed:
|
||||
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
|
||||
// on API 33+); `MissingPermission` is the generic fallback.
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
private fun postNotification() {
|
||||
ensureChannel()
|
||||
if (!hasPostNotificationsPermission()) {
|
||||
Log.i(TAG, "POST_NOTIFICATIONS not granted — skipping auto-disable notification")
|
||||
return
|
||||
}
|
||||
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(context, 0, tapIntent, pendingFlags)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Bridge auto-disabled")
|
||||
.setContentText("Paused after idle — tap to re-enable in the Bridge tab.")
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(
|
||||
"Hermes bridge was idle for too long, so device control has been turned off " +
|
||||
"automatically. Open the Bridge tab to turn it back on if you still need it."
|
||||
))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
|
||||
}.onFailure { Log.w(TAG, "postNotification: notify failed", it) }
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val existing = nm.getNotificationChannel(CHANNEL_ID)
|
||||
if (existing != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Fires once when the bridge auto-disables after being idle."
|
||||
setShowBadge(false)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
|
||||
import com.hermesandroid.relay.accessibility.MediaProjectionHolder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Phase 3 — safety-rails `bridge-safety-rails`
|
||||
*
|
||||
* Persistent foreground service that signals "Hermes agent has device
|
||||
* control" to the user whenever the bridge master toggle is on. The
|
||||
* notification is:
|
||||
*
|
||||
* - Ongoing (can't be swiped away)
|
||||
* - Two actions: "Disable" (broadcast into the master toggle) and
|
||||
* "Settings" (deep-link into BridgeSafetySettingsScreen)
|
||||
* - Channel `bridge_foreground` at DEFAULT importance (we do NOT want
|
||||
* heads-up because that would pop over every screen the agent taps)
|
||||
*
|
||||
* # Foreground service type
|
||||
*
|
||||
* On Android 10+ foreground services must declare a `foregroundServiceType`.
|
||||
* For Tier 5 we use `specialUse` with the justification string
|
||||
* "Persistent indicator that the Hermes agent has device control, per
|
||||
* Tier 5 safety rails." Play Store review allowance is declared in the
|
||||
* manifest `<property name="...SPECIAL_USE"/>`.
|
||||
*
|
||||
* We deliberately do NOT use `FOREGROUND_SERVICE_MEDIA_PROJECTION` here
|
||||
* even though accessibility's `ScreenCapture.kt` uses MediaProjection — that
|
||||
* permission is already declared, and binding the foreground service to
|
||||
* mediaProjection would couple its lifecycle to the current screen-grant,
|
||||
* which doesn't match our "always on while bridge is active" semantics.
|
||||
*
|
||||
* # Lifecycle wiring
|
||||
*
|
||||
* [BridgeViewModel] observes the master-toggle StateFlow and calls
|
||||
* [start] / [stop] on transitions. The service itself does not observe
|
||||
* the toggle — it's a passive "I'm on" indicator, and bundling the flow
|
||||
* subscription into the service would mean the service owns its own
|
||||
* coroutine scope + we'd need to worry about bind/unbind timing. Simpler
|
||||
* to have the ViewModel drive it.
|
||||
*/
|
||||
class BridgeForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BridgeForegroundSvc"
|
||||
|
||||
const val CHANNEL_ID = "bridge_foreground"
|
||||
private const val CHANNEL_NAME = "Bridge active"
|
||||
const val NOTIFICATION_ID = 4712
|
||||
|
||||
const val ACTION_START = "com.hermesandroid.relay.bridge.START"
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.bridge.STOP"
|
||||
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)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
ACTION_DISABLE -> {
|
||||
Log.i(TAG, "ACTION_DISABLE → flipping master toggle off")
|
||||
scope.launch {
|
||||
runCatching {
|
||||
HermesAccessibilityService.setMasterEnabled(applicationContext, false)
|
||||
}.onFailure { Log.w(TAG, "master toggle write failed", it) }
|
||||
}
|
||||
// Keep the service alive until BridgeViewModel sees the
|
||||
// toggle flip and calls stop(); that's the canonical path.
|
||||
return START_STICKY
|
||||
}
|
||||
ACTION_OPEN_SETTINGS -> {
|
||||
Log.i(TAG, "ACTION_OPEN_SETTINGS → launching MainActivity with deep-link to bridge safety")
|
||||
// PHASE3-safety-rails-followup: deep-link to BridgeSafetySettingsScreen.
|
||||
// MainActivity reads EXTRA_NAV_ROUTE in onCreate / onNewIntent
|
||||
// and emits it on the NavRouteRequest SharedFlow, which RelayApp
|
||||
// collects and forwards to the NavController. The route string
|
||||
// is hardcoded here on purpose to avoid pulling the entire
|
||||
// ui.RelayApp graph into the bridge service classpath — if you
|
||||
// change Screen.BridgeSafetySettings.route in RelayApp.kt,
|
||||
// change it here too.
|
||||
val launch = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, "settings/bridge_safety")
|
||||
}
|
||||
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 ===
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// ForegroundServiceType: lint requires the manifest `<service>` to declare
|
||||
// `foregroundServiceType` for targetSdk >= 34. The SIDELOAD manifest does
|
||||
// (specialUse|mediaProjection) + declares the matching FOREGROUND_SERVICE_*
|
||||
// permissions. The GOOGLEPLAY flavor deliberately omits this service AND
|
||||
// those permissions (no device-control capability for Play-Store
|
||||
// compliance), so this code is unreachable there — the service can't be
|
||||
// started without a manifest declaration. Lint analyzes the merged
|
||||
// googlePlay manifest and can't see the sideload guarantee, so suppress
|
||||
// here rather than weaken googlePlay by granting it specialUse.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// === 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 lists both in `foregroundServiceType`.
|
||||
val typeMask = if (hasMediaProjectionType) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
} 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
|
||||
// FOREGROUND_SERVICE_TYPE_SPECIAL_USE is Android 14+.
|
||||
// Fall through to the plain startForeground on Q..T —
|
||||
// AGP will attach the manifest-declared type automatically.
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startForeground failed — bridge indicator will not be visible", t)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): android.app.Notification {
|
||||
val tapIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
|
||||
|
||||
val disableIntent = Intent(this, BridgeForegroundService::class.java)
|
||||
.setAction(ACTION_DISABLE)
|
||||
val disablePending = PendingIntent.getService(this, 1, disableIntent, pendingFlags)
|
||||
|
||||
val settingsIntent = Intent(this, BridgeForegroundService::class.java)
|
||||
.setAction(ACTION_OPEN_SETTINGS)
|
||||
val settingsPending = PendingIntent.getService(this, 2, settingsIntent, pendingFlags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes agent has device control")
|
||||
.setContentText("Bridge is active — tap Disable to stop at any time.")
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(
|
||||
"The Hermes agent can currently read the screen and perform " +
|
||||
"actions on your behalf through the accessibility service. " +
|
||||
"Tap Disable to turn this off immediately."
|
||||
))
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Disable", disablePending)
|
||||
.addAction(0, "Settings", settingsPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Persistent indicator while the Hermes agent has device control."
|
||||
setShowBadge(false)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BridgeSafetySettings
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
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.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Phase 3 — safety-rails `bridge-safety-rails`
|
||||
*
|
||||
* Central enforcement point for Tier 5 safety: per-app blocklist, destructive
|
||||
* verb confirmation, and idle-based auto-disable. Owned as a singleton-per-
|
||||
* process by [ConnectionViewModel] and injected into [BridgeCommandHandler].
|
||||
*
|
||||
* # Integration surface
|
||||
*
|
||||
* - [checkPackageAllowed] — called with the currently foregrounded package
|
||||
* (from `HermesAccessibilityService.currentApp`). Returns false if the
|
||||
* user has that package on the blocklist. Caller maps false → HTTP 403.
|
||||
*
|
||||
* - [requiresConfirmation] — cheap synchronous predicate: does the agent's
|
||||
* requested action (`/tap_text` / `/type`) carry one of the user's
|
||||
* destructive verbs in its body? Returns false for every other path.
|
||||
*
|
||||
* - [awaitConfirmation] — suspend function that shows a system-overlay
|
||||
* modal and waits for the user to tap Allow / Deny. Returns true on
|
||||
* allow, false on deny or on [BridgeSafetySettings.confirmationTimeoutSeconds]
|
||||
* timeout. The suspending `BridgeCommandHandler` action coroutine is
|
||||
* what's held here — the phone's agent call stays open until the user
|
||||
* reacts, which is exactly the UX we want (the server sees a slow
|
||||
* response, not a denial race).
|
||||
*
|
||||
* - [rescheduleAutoDisable] — every accepted command bumps the idle timer
|
||||
* forward; after [BridgeSafetySettings.autoDisableMinutes] of silence
|
||||
* the master toggle flips off and a one-shot notification fires.
|
||||
* [cancelAutoDisable] cancels the pending timer (called when the master
|
||||
* toggle flips off manually, so we don't race the timer against the
|
||||
* user).
|
||||
*
|
||||
* # Overlay <-> coroutine wiring
|
||||
*
|
||||
* The confirmation modal lives in a [SYSTEM_ALERT_WINDOW]-backed overlay
|
||||
* (see [ConfirmationOverlayHost]). The `awaitConfirmation` coroutine
|
||||
* registers a pending entry keyed by a monotonic request id, shows the
|
||||
* overlay, and then waits on the entry's `CompletableDeferred<Boolean>`
|
||||
* under a `withTimeout`. The overlay's Allow / Deny buttons complete the
|
||||
* deferred. On timeout we dismiss the overlay from the manager side and
|
||||
* return false ("treat silence as deny"). If the overlay host is not
|
||||
* available — e.g. the user hasn't granted `SYSTEM_ALERT_WINDOW` yet — we
|
||||
* fail-closed and return false so no destructive action slips through.
|
||||
*
|
||||
* # No WorkManager
|
||||
*
|
||||
* The Android app does not depend on androidx.work. [AutoDisableWorker]
|
||||
* documents the canonical pattern, but the live path is a coroutine
|
||||
* `Job` owned by this manager, delayed by the configured minutes. This is
|
||||
* acceptable because we are the in-memory owner of the master-toggle flow
|
||||
* — no inter-process or cross-restart scheduling is needed. On process
|
||||
* death the master toggle is simply evaluated fresh from DataStore, and
|
||||
* any command not explicitly sent within the idle window never actually
|
||||
* happens because the app isn't running.
|
||||
*/
|
||||
class BridgeSafetyManager(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "BridgeSafetyMgr"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: BridgeSafetyManager? = null
|
||||
|
||||
/**
|
||||
* Process-wide accessor. Both [BridgeCommandHandler] and the overlay
|
||||
* host need to reach the same manager instance without a DI graph;
|
||||
* [ConnectionViewModel] calls [install] once at init time.
|
||||
*/
|
||||
fun peek(): BridgeSafetyManager? = INSTANCE
|
||||
|
||||
fun install(context: Context, scope: CoroutineScope): BridgeSafetyManager {
|
||||
val existing = INSTANCE
|
||||
if (existing != null) return existing
|
||||
val created = BridgeSafetyManager(context.applicationContext, scope)
|
||||
INSTANCE = created
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val prefsRepo = BridgeSafetyPreferencesRepository(appContext)
|
||||
|
||||
/** Latest settings snapshot — UI + checks read this via [settings]. */
|
||||
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
|
||||
* overlay was opened for. [ConcurrentHashMap] because the completion
|
||||
* happens on the Compose overlay thread and registration on the bridge
|
||||
* scope's dispatcher.
|
||||
*/
|
||||
private val pendingConfirmations = ConcurrentHashMap<Long, PendingConfirmation>()
|
||||
private val nextRequestId = AtomicLong(0L)
|
||||
|
||||
/** Coroutine job that fires auto-disable after idle. */
|
||||
@Volatile
|
||||
private var autoDisableJob: Job? = null
|
||||
|
||||
/**
|
||||
* Remaining time (epoch millis) for the current auto-disable job, or
|
||||
* null when idle. BridgeSafetySummaryCard reads this as a countdown.
|
||||
*/
|
||||
private val _autoDisableAtMs = MutableStateFlow<Long?>(null)
|
||||
val autoDisableAtMs: StateFlow<Long?> = _autoDisableAtMs.asStateFlow()
|
||||
|
||||
init {
|
||||
// Eagerly observe DataStore — writes from the settings screen flow
|
||||
// into the cache so checkPackageAllowed / requiresConfirmation can
|
||||
// read synchronously without a suspend hop.
|
||||
scope.launch {
|
||||
prefsRepo.settings.collect { latest ->
|
||||
_settings.value = latest
|
||||
settingsHydrated = true
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
prefsRepo.trustedDestructiveVerbs.collect { latest ->
|
||||
_trustedDestructiveVerbs.value = latest
|
||||
trustedHydrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blocklist ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns false iff [packageName] is explicitly blocklisted. A null
|
||||
* or blank package (accessibility service hasn't seen a window yet)
|
||||
* is treated as allowed — we can't block what we don't know.
|
||||
*/
|
||||
suspend fun checkPackageAllowed(packageName: String?): Boolean {
|
||||
if (packageName.isNullOrBlank()) return true
|
||||
val snapshot = currentSettings()
|
||||
return packageName !in snapshot.blocklist
|
||||
}
|
||||
|
||||
// ── Destructive verbs ────────────────────────────────────────────────
|
||||
|
||||
suspend fun requiresConfirmation(method: String, text: String?): Boolean {
|
||||
if (text.isNullOrBlank()) 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the confirmation overlay and suspend until the user reacts or
|
||||
* the timeout elapses. Returns true to allow, false to deny.
|
||||
*
|
||||
* Fail-closed: if the SYSTEM_ALERT_WINDOW permission hasn't been
|
||||
* granted, or if the overlay host can't be reached for any reason, we
|
||||
* return false and log a warning. Better a missed agent command than
|
||||
* a silently-allowed destructive action.
|
||||
*/
|
||||
suspend fun awaitConfirmation(method: String, text: String?): Boolean {
|
||||
val snapshot = currentSettings()
|
||||
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 = matchedVerb,
|
||||
deferred = deferred,
|
||||
)
|
||||
pendingConfirmations[requestId] = pending
|
||||
|
||||
val host = ConfirmationOverlayHost.instance
|
||||
if (host == null) {
|
||||
Log.w(TAG, "awaitConfirmation: no overlay host installed — failing closed (deny)")
|
||||
pendingConfirmations.remove(requestId)
|
||||
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 {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
host.showConfirmation(pending) { resolution ->
|
||||
resolveConfirmation(requestId, resolution)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shown.isFailure) {
|
||||
Log.w(TAG, "awaitConfirmation: host.showConfirmation threw — denying", shown.exceptionOrNull())
|
||||
pendingConfirmations.remove(requestId)
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Log.i(TAG, "awaitConfirmation: timed out after ${timeoutMs}ms — denying")
|
||||
host.dismissConfirmation(requestId)
|
||||
pendingConfirmations.remove(requestId)
|
||||
false
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "awaitConfirmation: unexpected failure — denying", t)
|
||||
host.dismissConfirmation(requestId)
|
||||
pendingConfirmations.remove(requestId)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the overlay UI when the user taps Allow / Deny (or when
|
||||
* the overlay is dismissed programmatically). Safe to call for an
|
||||
* unknown id (we just drop on the floor).
|
||||
*/
|
||||
fun resolveConfirmation(requestId: Long, allowed: Boolean) {
|
||||
val pending = pendingConfirmations.remove(requestId) ?: return
|
||||
pending.deferred.complete(allowed)
|
||||
}
|
||||
|
||||
// ── Auto-disable timer ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cancel any pending timer and arm a fresh one. Called on every accepted
|
||||
* bridge command — an actively-used bridge never auto-disables.
|
||||
*/
|
||||
fun rescheduleAutoDisable() {
|
||||
val minutes = _settings.value.autoDisableMinutes
|
||||
val delayMs = minutes * 60_000L
|
||||
val fireAt = System.currentTimeMillis() + delayMs
|
||||
autoDisableJob?.cancel()
|
||||
_autoDisableAtMs.value = fireAt
|
||||
|
||||
autoDisableJob = (scope + SupervisorJob()).launch {
|
||||
try {
|
||||
delay(delayMs)
|
||||
Log.i(TAG, "Auto-disable fired after $minutes min of idle")
|
||||
// Hand off to the canonical worker so both code paths look
|
||||
// identical from a behavioral standpoint (notification +
|
||||
// master-toggle flip).
|
||||
AutoDisableWorker(appContext).run()
|
||||
} catch (_: Throwable) {
|
||||
// Cancellation is expected on reschedule — swallow quietly.
|
||||
} finally {
|
||||
if (_autoDisableAtMs.value == fireAt) _autoDisableAtMs.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAutoDisable() {
|
||||
autoDisableJob?.cancel()
|
||||
autoDisableJob = null
|
||||
_autoDisableAtMs.value = null
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// DataStore so the very first command after install() doesn't
|
||||
// race the collector and see stale defaults.
|
||||
if (settingsHydrated) return _settings.value
|
||||
return try {
|
||||
val first = prefsRepo.settings.first()
|
||||
_settings.value = first
|
||||
settingsHydrated = true
|
||||
first
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "currentSettings: DataStore read failed — using defaults", t)
|
||||
BridgeSafetySettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun containsDestructiveVerb(text: String, verbs: Set<String>): Boolean {
|
||||
if (text.isBlank()) return false
|
||||
val lower = text.lowercase()
|
||||
for (verb in verbs) {
|
||||
val v = verb.lowercase()
|
||||
if (v.isBlank()) continue
|
||||
// \b<verb>\b — word-boundary match, so "send" matches "send it"
|
||||
// but not "sender" or "sendmail". Kotlin's Regex `\b` uses the
|
||||
// JVM Pattern engine; we pre-escape the verb in case a user
|
||||
// added something like `pay.` via the settings screen.
|
||||
val pattern = Regex("\\b${Regex.escape(v)}\\b", RegexOption.IGNORE_CASE)
|
||||
if (pattern.containsMatchIn(lower)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun firstMatchedVerb(text: String, verbs: Set<String>): String? {
|
||||
if (text.isBlank()) return null
|
||||
val lower = text.lowercase()
|
||||
return verbs.firstOrNull { v ->
|
||||
val vl = v.lowercase()
|
||||
vl.isNotBlank() && Regex("\\b${Regex.escape(vl)}\\b", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(lower)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-flight confirmation modal. Held in [BridgeSafetyManager.pendingConfirmations]
|
||||
* and surfaced to the overlay host so the Compose dialog can render the
|
||||
* full context.
|
||||
*/
|
||||
data class PendingConfirmation(
|
||||
val id: Long,
|
||||
val method: String,
|
||||
val text: String,
|
||||
val verb: String,
|
||||
val deferred: CompletableDeferred<Boolean>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Abstraction the safety manager talks to when it needs a modal on screen.
|
||||
* The live implementation lives in [BridgeStatusOverlay] (it's the same
|
||||
* [WindowManager] pipeline that hosts the ambient status dot, so we only
|
||||
* hold one SYSTEM_ALERT_WINDOW attachment per process).
|
||||
*/
|
||||
interface ConfirmationOverlayHost {
|
||||
/**
|
||||
* Render the confirmation modal. Must be idempotent per [request.id] —
|
||||
* calling twice with the same id is a no-op. [onResult] is invoked once
|
||||
* when the user reacts (or when the modal is dismissed externally, in
|
||||
* which case pass `false`).
|
||||
*/
|
||||
fun showConfirmation(
|
||||
request: PendingConfirmation,
|
||||
onResult: (allowed: Boolean) -> Unit,
|
||||
)
|
||||
|
||||
/** Dismiss a modal without resolving the deferred (the manager side does that). */
|
||||
fun dismissConfirmation(requestId: Long)
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
var instance: ConfirmationOverlayHost? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
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
|
||||
|
||||
/**
|
||||
* Phase 3 — safety-rails `bridge-safety-rails`
|
||||
*
|
||||
* WindowManager-backed overlay host. Serves two jobs in one place so we
|
||||
* only ever attach a single `SYSTEM_ALERT_WINDOW` View per process:
|
||||
*
|
||||
* 1. A small floating status chip ("Hermes is active") shown when the
|
||||
* user has opted in via [BridgeSafetySettings.statusOverlayEnabled].
|
||||
* 2. A full-width centered destructive-verb confirmation modal that
|
||||
* [BridgeSafetyManager] fires from [BridgeSafetyManager.awaitConfirmation].
|
||||
*
|
||||
* The two overlays are independent [ComposeView] attachments — they
|
||||
* don't share layout params. That keeps the chip a tiny permanent hole
|
||||
* 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
|
||||
* a `ViewTreeLifecycleOwner` (normally the containing Activity provides
|
||||
* one). Compose requires one to run its recomposer, so we attach a
|
||||
* minimal [OverlayLifecycleOwner] that's always in the RESUMED state
|
||||
* while the view is attached. Same deal for SavedStateRegistryOwner
|
||||
* (required by SavedStateHandle inside composables) and ViewModelStoreOwner.
|
||||
*/
|
||||
class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BridgeStatusOverlay"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: BridgeStatusOverlay? = null
|
||||
|
||||
fun install(context: Context): BridgeStatusOverlay {
|
||||
val existing = INSTANCE
|
||||
if (existing != null) return existing
|
||||
val created = BridgeStatusOverlay(context.applicationContext)
|
||||
INSTANCE = created
|
||||
ConfirmationOverlayHost.instance = created
|
||||
return created
|
||||
}
|
||||
|
||||
fun peek(): BridgeStatusOverlay? = INSTANCE
|
||||
}
|
||||
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val wm: WindowManager =
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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, 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 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
|
||||
}
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
setContent {
|
||||
MaterialTheme { BridgeStatusOverlayChip(unattended = unattended) }
|
||||
}
|
||||
}
|
||||
attachLifecycle(compose)
|
||||
|
||||
val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
overlayType(),
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.END
|
||||
x = 24
|
||||
y = 96
|
||||
}
|
||||
|
||||
runCatching { wm.addView(compose, params) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "addView(chip) failed", it)
|
||||
return
|
||||
}
|
||||
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
|
||||
chipView = compose
|
||||
chipUnattended = unattended
|
||||
}
|
||||
|
||||
// ── Confirmation modal ───────────────────────────────────────────────
|
||||
|
||||
override fun showConfirmation(
|
||||
request: PendingConfirmation,
|
||||
onResult: (allowed: Boolean) -> Unit,
|
||||
) {
|
||||
if (activeConfirmations.containsKey(request.id)) return
|
||||
if (!Settings.canDrawOverlays(appContext)) {
|
||||
Log.w(TAG, "showConfirmation: SYSTEM_ALERT_WINDOW not granted — denying")
|
||||
onResult(false)
|
||||
return
|
||||
}
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
DestructiveVerbConfirmDialog(
|
||||
method = request.method,
|
||||
verb = request.verb,
|
||||
fullText = request.text,
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
attachLifecycle(compose)
|
||||
|
||||
val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
overlayType(),
|
||||
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
dimAmount = 0.6f
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
val added = runCatching { wm.addView(compose, params) }.isSuccess
|
||||
if (!added) {
|
||||
Log.w(TAG, "addView(confirm) failed — denying")
|
||||
onResult(false)
|
||||
return
|
||||
}
|
||||
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
|
||||
activeConfirmations[request.id] = compose
|
||||
}
|
||||
|
||||
override fun dismissConfirmation(requestId: Long) {
|
||||
val view = activeConfirmations.remove(requestId) ?: return
|
||||
runCatching { wm.removeView(view) }
|
||||
.onFailure { Log.w(TAG, "removeView(confirm $requestId) failed", it) }
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────
|
||||
|
||||
private fun overlayType(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
WindowManager.LayoutParams.TYPE_PHONE
|
||||
}
|
||||
|
||||
private fun attachLifecycle(view: View) {
|
||||
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`; `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.
|
||||
*
|
||||
* 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,
|
||||
SavedStateRegistryOwner {
|
||||
|
||||
private val registry = LifecycleRegistry(this)
|
||||
override val lifecycle: Lifecycle get() = registry
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
registry.currentState = Lifecycle.State.DESTROYED
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
@@ -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,83 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Shared profile/personality display and request identity helpers.
|
||||
*
|
||||
* A null profile name is the app's explicit "Server default" state. The
|
||||
* relay also advertises the root Hermes config as a synthetic profile named
|
||||
* "default"; for request/session identity that row is an alias of server
|
||||
* default so it does not split chat, voice, or session scope.
|
||||
*/
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
|
||||
fun effectiveProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
|
||||
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
return when {
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun agentName(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
connectionLabel: String?,
|
||||
): String {
|
||||
profileDisplayName(profile)?.let { return it }
|
||||
|
||||
val personalityName = if (
|
||||
selectedPersonality == "default" &&
|
||||
defaultPersonality.isNotBlank()
|
||||
) {
|
||||
defaultPersonality
|
||||
} else {
|
||||
selectedPersonality
|
||||
}
|
||||
|
||||
return when {
|
||||
personalityName.isNotBlank() && personalityName != "default" ->
|
||||
titleCase(personalityName.trim())
|
||||
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
|
||||
else -> "Hermes"
|
||||
}
|
||||
}
|
||||
|
||||
fun personalityLabel(
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
): String = when {
|
||||
selectedPersonality != "default" && selectedPersonality.isNotBlank() ->
|
||||
titleCase(selectedPersonality.trim())
|
||||
defaultPersonality.isNotBlank() -> titleCase(defaultPersonality.trim())
|
||||
else -> "Default"
|
||||
}
|
||||
|
||||
fun isServerDefaultAlias(profileName: String?): Boolean =
|
||||
profileName?.trim()?.equals("default", ignoreCase = true) == true
|
||||
|
||||
fun normalizeSelection(profile: Profile?): Profile? =
|
||||
if (isServerDefaultAlias(profile?.name)) null else profile
|
||||
|
||||
fun profileRequestName(profileName: String?): String? =
|
||||
profileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && !isServerDefaultAlias(it) }
|
||||
|
||||
fun profileSessionKey(profileName: String?): String =
|
||||
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
|
||||
|
||||
fun profileContextKey(connectionId: String?, profileName: String?): String =
|
||||
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
|
||||
|
||||
private fun titleCase(value: String): String =
|
||||
value.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* User-tunable voice barge-in preferences.
|
||||
*
|
||||
* Phase V follow-on — owned by the voice-barge-in plan (Wave 1 / unit B1).
|
||||
*
|
||||
* Barge-in lets the user interrupt TTS playback by speaking. The three knobs
|
||||
* here back the Voice Settings "Interruption" section added by B5 and are
|
||||
* consumed by [com.hermesandroid.relay.viewmodel.VoiceViewModel] (wired in
|
||||
* B4):
|
||||
*
|
||||
* - [enabled] — master toggle for the whole barge-in path. When false, the
|
||||
* listener never starts and TTS plays uninterrupted. Default off at launch
|
||||
* on both flavors so existing users aren't surprised by mic activation
|
||||
* during a speaking turn.
|
||||
*
|
||||
* - [sensitivity] — maps to Silero VAD threshold + hysteresis tuning inside
|
||||
* [com.hermesandroid.relay.audio.VadEngine]. [BargeInSensitivity.Off] is
|
||||
* a UI convenience for "disable without flipping the top-level toggle";
|
||||
* it short-circuits the VAD to `isSpeech=false` regardless of input.
|
||||
*
|
||||
* - [resumeAfterInterruption] — if the user interrupts, then falls silent
|
||||
* within the 600 ms watchdog, should we re-enqueue the un-played sentence
|
||||
* chunks so the agent finishes speaking? On by default — without it,
|
||||
* barge-in behaves like a hard cancel, which is more abrupt than most
|
||||
* conversational UX expects.
|
||||
*
|
||||
* Matches the [BridgePreferences] / [VoicePreferences] / [MediaSettings] style:
|
||||
* single shared DataStore (`relayDataStore`), one key per scalar field, enum
|
||||
* stored as its `name` (cheap + schema-evolvable via fall-back to default on
|
||||
* unknown values). No migration needed — preferences are additive.
|
||||
*/
|
||||
data class BargeInPreferences(
|
||||
val enabled: Boolean = DEFAULT_ENABLED,
|
||||
val sensitivity: BargeInSensitivity = DEFAULT_SENSITIVITY,
|
||||
val resumeAfterInterruption: Boolean = DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
|
||||
/**
|
||||
* VAD sensitivity preset for barge-in detection.
|
||||
*
|
||||
* Tuning lives in [com.hermesandroid.relay.audio.VadEngine.setSensitivity] —
|
||||
* see the B2 unit spec for the exact `(threshold, attack, release,
|
||||
* consecutive)` tuple each value maps to. [Off] is UI-only shorthand for
|
||||
* "disable detection without clearing the toggle".
|
||||
*/
|
||||
enum class BargeInSensitivity {
|
||||
Off,
|
||||
Low,
|
||||
Default,
|
||||
High,
|
||||
}
|
||||
|
||||
const val DEFAULT_ENABLED: Boolean = false
|
||||
val DEFAULT_SENSITIVITY: BargeInSensitivity = BargeInSensitivity.Default
|
||||
const val DEFAULT_RESUME_AFTER_INTERRUPTION: Boolean = true
|
||||
|
||||
/**
|
||||
* DataStore-backed repository for [BargeInPreferences].
|
||||
*
|
||||
* Primary constructor takes an [android.content.Context] and resolves the
|
||||
* shared [Context.relayDataStore]; the secondary constructor takes a raw
|
||||
* [DataStore] so unit tests can inject a filesystem-backed instance without
|
||||
* needing an Android [android.content.Context]. Matches the shape of
|
||||
* [BridgeSafetyPreferencesRepository] (field `flow: Flow<...>`, per-field
|
||||
* suspend setters).
|
||||
*/
|
||||
class BargeInPreferencesRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
|
||||
private val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
|
||||
private val KEY_RESUME_AFTER_INTERRUPTION =
|
||||
booleanPreferencesKey("barge_in_resume_after_interruption")
|
||||
}
|
||||
|
||||
val flow: Flow<BargeInPreferences> = dataStore.data
|
||||
.map { prefs ->
|
||||
BargeInPreferences(
|
||||
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
|
||||
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
|
||||
?: DEFAULT_SENSITIVITY,
|
||||
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
|
||||
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setEnabled(value: Boolean) {
|
||||
dataStore.edit { it[KEY_ENABLED] = value }
|
||||
}
|
||||
|
||||
suspend fun setSensitivity(value: BargeInSensitivity) {
|
||||
dataStore.edit { it[KEY_SENSITIVITY] = value.name }
|
||||
}
|
||||
|
||||
suspend fun setResumeAfterInterruption(value: Boolean) {
|
||||
dataStore.edit { it[KEY_RESUME_AFTER_INTERRUPTION] = value }
|
||||
}
|
||||
|
||||
private fun decodeSensitivity(raw: String): BargeInSensitivity =
|
||||
runCatching { BargeInSensitivity.valueOf(raw) }.getOrDefault(DEFAULT_SENSITIVITY)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* User-tunable bridge mode preferences + persisted activity log.
|
||||
*
|
||||
* Phase 3 Wave 1 — owned by Agent bridge-ui (`bridge-screen-ui`).
|
||||
*
|
||||
* - [masterEnabled] — the headline "Allow Agent Control" switch on BridgeScreen.
|
||||
* Compose-layer gate only; the real safety fence is that the user must also
|
||||
* enable `HermesAccessibilityService` in Android Settings (which is the
|
||||
* Tier-5 "honest" trust boundary Google cares about). Persisting it here lets
|
||||
* us survive restarts and lets Agent accessibility's `HermesAccessibilityService` query
|
||||
* the same source of truth without needing its own DataStore file.
|
||||
*
|
||||
* - [activityLog] — rolling window of recent [BridgeActivityEntry] rows that
|
||||
* back the Activity Log card. Capped at [MAX_LOG_ENTRIES] on every append
|
||||
* so we don't grow the DataStore preferences file unbounded (this is a Prefs
|
||||
* datastore, not Room — large blob values hurt commit latency). Serialized
|
||||
* as a single JSON array string under one key so the whole read/update is
|
||||
* atomic. That's cheaper than Proto DataStore for a cap this small and
|
||||
* matches the `VoicePreferences.kt` / `MediaSettings.kt` style already in
|
||||
* the tree.
|
||||
*
|
||||
* The log schema intentionally does NOT carry the screenshot bytes — those
|
||||
* live in `MediaRegistry` on the relay side and are fetched via
|
||||
* `RelayHttpClient.fetchMedia(token)` when the user expands the row. Storing
|
||||
* `thumbnailToken: String?` as an opaque reference keeps DataStore small and
|
||||
* reuses the existing MediaRegistry LRU-cap + FileProvider cache story.
|
||||
*/
|
||||
@Serializable
|
||||
data class BridgeActivityEntry(
|
||||
/** Epoch millis when the command was received from the relay. */
|
||||
val timestampMs: Long,
|
||||
/** Short method name — `tap`, `tap_text`, `type`, `swipe`, `read_screen`, etc. */
|
||||
val method: String,
|
||||
/** Free-form one-line summary of the args — `"(540, 1200)"`, `"send"`, `"Chrome"`. */
|
||||
val summary: String,
|
||||
/** Execution status — Pending, Success, Failed, Blocked (by Tier-5 safety rails). */
|
||||
val status: BridgeActivityStatus,
|
||||
/** Optional longer result text — set on Success / Failed to show in the expanded row. */
|
||||
val resultText: String? = null,
|
||||
/**
|
||||
* Optional screenshot MediaRegistry token (`hermes-relay://<token>` or the
|
||||
* raw token stem). The UI resolves this through the existing InboundAttachmentCard
|
||||
* pipeline — bridge-ui deliberately does not invent a second thumbnail cache.
|
||||
* Null for commands that don't carry a screenshot.
|
||||
*/
|
||||
val thumbnailToken: String? = null,
|
||||
/** Unique ID used as the LazyColumn key — lets Compose animate inserts cleanly. */
|
||||
val id: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class BridgeActivityStatus { Pending, Success, Failed, Blocked }
|
||||
|
||||
data class BridgeSettings(
|
||||
val masterEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
class BridgePreferencesRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val KEY_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
|
||||
private val KEY_ACTIVITY_LOG = stringPreferencesKey("bridge_activity_log")
|
||||
|
||||
/** Hard cap on persisted entries. See file-level KDoc for rationale. */
|
||||
const val MAX_LOG_ENTRIES = 100
|
||||
const val DEFAULT_MASTER_ENABLED = false
|
||||
}
|
||||
|
||||
// Lenient JSON — ignore unknown keys so we can evolve the schema without
|
||||
// breaking installed users on app upgrade, mirroring how PairingPreferences
|
||||
// and MediaSettings handle forward-compat.
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
val settings: Flow<BridgeSettings> = context.relayDataStore.data.map { prefs ->
|
||||
BridgeSettings(
|
||||
masterEnabled = prefs[KEY_MASTER_ENABLED] ?: DEFAULT_MASTER_ENABLED,
|
||||
)
|
||||
}
|
||||
|
||||
val activityLog: Flow<List<BridgeActivityEntry>> = context.relayDataStore.data.map { prefs ->
|
||||
val raw = prefs[KEY_ACTIVITY_LOG] ?: return@map emptyList()
|
||||
runCatching { json.decodeFromString<List<BridgeActivityEntry>>(raw) }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
suspend fun setMasterEnabled(enabled: Boolean) {
|
||||
context.relayDataStore.edit { it[KEY_MASTER_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a new entry and trim to [MAX_LOG_ENTRIES]. Idempotent on
|
||||
* duplicate ids — if an entry with the same id already exists we replace
|
||||
* it in-place (used when a Pending entry transitions to Success/Failed).
|
||||
*/
|
||||
suspend fun appendEntry(entry: BridgeActivityEntry) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_ACTIVITY_LOG]?.let {
|
||||
runCatching { json.decodeFromString<List<BridgeActivityEntry>>(it) }
|
||||
.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
val deduped = current.filterNot { it.id == entry.id }
|
||||
val updated = (listOf(entry) + deduped).take(MAX_LOG_ENTRIES)
|
||||
prefs[KEY_ACTIVITY_LOG] = json.encodeToString(updated)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearLog() {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_ACTIVITY_LOG] = json.encodeToString(emptyList<BridgeActivityEntry>())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* User-tunable Tier 5 bridge-safety preferences.
|
||||
*
|
||||
* Phase 3 Wave 2 — owned by Agent safety-rails (`bridge-safety-rails`).
|
||||
*
|
||||
* The five knobs this file backs match the Tier 5 spec:
|
||||
*
|
||||
* - [blocklist] — per-app package-name allowlist-inverse. If the currently
|
||||
* foregrounded package is in this set, `BridgeCommandHandler` refuses
|
||||
* every action with HTTP 403. Defaults ship with a conservative list of
|
||||
* common banking apps + password managers so first-run users are safe
|
||||
* from "agent taps Transfer in my banking app" accidents before they
|
||||
* ever touch the settings screen. Users can edit freely.
|
||||
*
|
||||
* - [destructiveVerbs] — words that trigger a confirmation modal when they
|
||||
* appear in `/tap_text` or `/type` payloads. Seeded with a set of verbs
|
||||
* that carry irreversible or high-stakes consequences. Editable.
|
||||
*
|
||||
* - [autoDisableMinutes] — idle timeout after which the master toggle
|
||||
* auto-flips to false. Rescheduled on every command so an active agent
|
||||
* never triggers it; a runaway agent that stops sending commands for
|
||||
* this long loses bridge access automatically.
|
||||
*
|
||||
* - [statusOverlayEnabled] — opt-in floating-dot indicator (like the
|
||||
* screen-recording red dot) that's visible while bridge is active.
|
||||
* Off by default — some users hate persistent overlays, foreground-
|
||||
* service notification already signals liveness.
|
||||
*
|
||||
* - [confirmationTimeoutSeconds] — how long the destructive-verb modal
|
||||
* waits before treating silence as DENY. Default 30s.
|
||||
*
|
||||
* Matches the `BridgePreferences.kt` / `VoicePreferences.kt` / `MediaSettings.kt`
|
||||
* style: a single DataStore (`relayDataStore`), lists serialized as JSON
|
||||
* strings under one key each, lenient deserialization so schema evolution
|
||||
* doesn't break installed users.
|
||||
*/
|
||||
data class BridgeSafetySettings(
|
||||
val blocklist: Set<String> = DEFAULT_BLOCKLIST,
|
||||
val destructiveVerbs: Set<String> = DEFAULT_DESTRUCTIVE_VERBS,
|
||||
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. */
|
||||
val DEFAULT_BLOCKLIST: Set<String> = setOf(
|
||||
// Banking — US / UK / generic
|
||||
"com.chase.sig.android",
|
||||
"com.wf.wellsfargomobile",
|
||||
"com.bankofamerica.digitalwallet",
|
||||
"com.usaa.mobile.android.usaa",
|
||||
"com.konylabs.capitalone",
|
||||
"com.americanexpress.android.acctsvcs.us",
|
||||
"com.discoverfinancial.mobile",
|
||||
"com.infonow.bofa",
|
||||
"uk.co.hsbc.hsbcukmobilebanking",
|
||||
"com.barclays.android.barclaysmobilebanking",
|
||||
"com.monzo.android",
|
||||
"co.uk.getmondo",
|
||||
"co.revolut.app",
|
||||
"com.starlingbank.android",
|
||||
// Payments / crypto
|
||||
"com.venmo",
|
||||
"com.squareup.cash",
|
||||
"com.paypal.android.p2pmobile",
|
||||
"com.coinbase.android",
|
||||
"co.mona.android",
|
||||
// Password managers
|
||||
"com.lastpass.lpandroid",
|
||||
"com.agilebits.onepassword",
|
||||
"com.x8bit.bitwarden",
|
||||
"com.dashlane.frozenaccount",
|
||||
"com.keepersecurity.passwordmanager",
|
||||
"com.bitwarden.authenticator",
|
||||
// 2FA apps
|
||||
"com.google.android.apps.authenticator2",
|
||||
"com.authy.authy",
|
||||
"com.duosecurity.duomobile",
|
||||
)
|
||||
|
||||
/**
|
||||
* Seeded destructive-verb list. Matched case-insensitively as whole words
|
||||
* (via regex `\b<verb>\b`) inside `tap_text` / `type` payloads. Users can
|
||||
* add/remove via [BridgeSafetySettingsScreen].
|
||||
*/
|
||||
val DEFAULT_DESTRUCTIVE_VERBS: Set<String> = setOf(
|
||||
"send",
|
||||
"pay",
|
||||
"delete",
|
||||
"transfer",
|
||||
"confirm",
|
||||
"submit",
|
||||
"post",
|
||||
"publish",
|
||||
"buy",
|
||||
"purchase",
|
||||
"charge",
|
||||
"withdraw",
|
||||
)
|
||||
|
||||
const val DEFAULT_AUTO_DISABLE_MINUTES: Int = 30
|
||||
const val MIN_AUTO_DISABLE_MINUTES: Int = 5
|
||||
const val MAX_AUTO_DISABLE_MINUTES: Int = 120
|
||||
|
||||
const val DEFAULT_STATUS_OVERLAY_ENABLED: Boolean = false
|
||||
|
||||
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 {
|
||||
private val KEY_BLOCKLIST = stringPreferencesKey("bridge_blocklist")
|
||||
private val KEY_DESTRUCTIVE_VERBS = stringPreferencesKey("bridge_destructive_verbs")
|
||||
private val KEY_AUTO_DISABLE_MINUTES = intPreferencesKey("bridge_auto_disable_minutes")
|
||||
private val KEY_STATUS_OVERLAY = booleanPreferencesKey("bridge_status_overlay_enabled")
|
||||
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")
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
val settings: Flow<BridgeSafetySettings> = context.relayDataStore.data.map { prefs ->
|
||||
val initialized = prefs[KEY_SAFETY_INITIALIZED] ?: false
|
||||
val blocklist = prefs[KEY_BLOCKLIST]?.let { decodeSet(it) }
|
||||
?: if (initialized) emptySet() else DEFAULT_BLOCKLIST
|
||||
val verbs = prefs[KEY_DESTRUCTIVE_VERBS]?.let { decodeSet(it) }
|
||||
?: if (initialized) emptySet() else DEFAULT_DESTRUCTIVE_VERBS
|
||||
BridgeSafetySettings(
|
||||
blocklist = blocklist,
|
||||
destructiveVerbs = verbs,
|
||||
autoDisableMinutes = (prefs[KEY_AUTO_DISABLE_MINUTES] ?: DEFAULT_AUTO_DISABLE_MINUTES)
|
||||
.coerceIn(MIN_AUTO_DISABLE_MINUTES, MAX_AUTO_DISABLE_MINUTES),
|
||||
statusOverlayEnabled = prefs[KEY_STATUS_OVERLAY] ?: DEFAULT_STATUS_OVERLAY_ENABLED,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setBlocklist(packages: Set<String>) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_BLOCKLIST] = json.encodeToString(packages.toList().sorted())
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addToBlocklist(packageName: String) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_BLOCKLIST]?.let { decodeSet(it) } ?: DEFAULT_BLOCKLIST
|
||||
val next = (current + packageName).toList().sorted()
|
||||
prefs[KEY_BLOCKLIST] = json.encodeToString(next)
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromBlocklist(packageName: String) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_BLOCKLIST]?.let { decodeSet(it) } ?: DEFAULT_BLOCKLIST
|
||||
val next = (current - packageName).toList().sorted()
|
||||
prefs[KEY_BLOCKLIST] = json.encodeToString(next)
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDestructiveVerbs(verbs: Set<String>) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DESTRUCTIVE_VERBS] = json.encodeToString(
|
||||
verbs.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet().toList().sorted()
|
||||
)
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_DESTRUCTIVE_VERBS]?.let { decodeSet(it) } ?: DEFAULT_DESTRUCTIVE_VERBS
|
||||
val next = (current + normalized).toList().sorted()
|
||||
prefs[KEY_DESTRUCTIVE_VERBS] = json.encodeToString(next)
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_DESTRUCTIVE_VERBS]?.let { decodeSet(it) } ?: DEFAULT_DESTRUCTIVE_VERBS
|
||||
val next = (current - normalized).toList().sorted()
|
||||
prefs[KEY_DESTRUCTIVE_VERBS] = json.encodeToString(next)
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoDisableMinutes(minutes: Int) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_AUTO_DISABLE_MINUTES] =
|
||||
minutes.coerceIn(MIN_AUTO_DISABLE_MINUTES, MAX_AUTO_DISABLE_MINUTES)
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setStatusOverlayEnabled(enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_STATUS_OVERLAY] = enabled
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setConfirmationTimeoutSeconds(seconds: Int) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_CONFIRMATION_TIMEOUT] =
|
||||
seconds.coerceIn(MIN_CONFIRMATION_TIMEOUT_SECONDS, MAX_CONFIRMATION_TIMEOUT_SECONDS)
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
// === v0.4.1 unattended-access setters ===
|
||||
|
||||
/**
|
||||
* Persist the unattended-access opt-in state. Called from [BridgeViewModel]
|
||||
* after the user flips the toggle (and dismissed the scary warning on
|
||||
* first enable). Does NOT touch [KEY_UNATTENDED_WARNING_SEEN] — that
|
||||
* latch is owned by [setUnattendedWarningSeen] so a Disable→Re-enable
|
||||
* cycle doesn't re-show the dialog.
|
||||
*/
|
||||
suspend fun setUnattendedAccessEnabled(enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_UNATTENDED_ACCESS_ENABLED] = enabled
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latch that records whether the user has dismissed the one-time
|
||||
* scary opt-in dialog for unattended access. Set true the first time
|
||||
* the user taps "I understand" (or whatever the confirm button reads).
|
||||
* Never gets cleared in normal operation — the user's understanding
|
||||
* persists across enable/disable cycles. Cleared only via [reset]
|
||||
* (ie. user wipes app data).
|
||||
*/
|
||||
suspend fun setUnattendedWarningSeen(seen: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_UNATTENDED_WARNING_SEEN] = seen
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
// === END v0.4.1 unattended-access setters ===
|
||||
|
||||
// === "Don't ask again" trusted destructive verbs ===
|
||||
|
||||
/**
|
||||
* Live Flow of the verbs the user has marked "don't ask again" for.
|
||||
* Values are normalized (lowercase, trimmed) on write, so comparisons
|
||||
* against [BridgeSafetySettings.destructiveVerbs] and the incoming
|
||||
* modal `verb` field don't need any extra casing logic at the read
|
||||
* site. Master blocklist + master-disable still take precedence over
|
||||
* this set — this is only consulted AFTER the destructive-verb gate
|
||||
* decides a confirmation would otherwise fire.
|
||||
*/
|
||||
val trustedDestructiveVerbs: Flow<Set<String>> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS]?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun setTrustedDestructiveVerbs(verbs: Set<String>) {
|
||||
val normalized = verbs
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addTrustedDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] ?: emptySet()
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = current + normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearTrustedDestructiveVerbs() {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = emptySet()
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
// === END trusted destructive verbs ===
|
||||
|
||||
private fun decodeSet(raw: String): Set<String> =
|
||||
runCatching { json.decodeFromString<List<String>>(raw).toSet() }
|
||||
.getOrDefault(emptySet())
|
||||
}
|
||||
@@ -39,8 +39,124 @@ data class ChatMessage(
|
||||
val estimatedCost: Double? = null,
|
||||
// Agent/personality name for display on assistant messages
|
||||
val agentName: String? = null,
|
||||
// Small provenance badges rendered on assistant bubbles.
|
||||
val badges: List<String> = emptyList(),
|
||||
// File attachments (images, documents, etc.)
|
||||
val attachments: List<Attachment> = emptyList()
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
/**
|
||||
* Rich content cards emitted by the agent via `CARD:{json}` line
|
||||
* markers in the text stream. Parsed in
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.scanForCardMarkers]
|
||||
* and rendered inline by
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble]. Mirrors
|
||||
* [attachments]' lifecycle — the marker line is stripped from
|
||||
* [content] on match so the raw `CARD:{...}` never appears in the
|
||||
* bubble text, same as the `MEDIA:` parser.
|
||||
*/
|
||||
val cards: List<HermesCard> = emptyList(),
|
||||
/**
|
||||
* Tapped actions on any of [cards]. Checked by the renderer so a card
|
||||
* whose action has been dispatched collapses into a confirmation state
|
||||
* rather than re-offering the buttons.
|
||||
*/
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
/**
|
||||
* Structured trace of a phone-local voice intent dispatch. Populated only
|
||||
* for messages whose [id] starts with `voice-intent-` and that originated
|
||||
* from the sideload voice classifier. Used by
|
||||
* [com.hermesandroid.relay.voice.VoiceIntentSyncBuilder] to reconstruct
|
||||
* OpenAI-format `assistant` + `tool` message pairs the next time chat
|
||||
* sends a payload to the server, so the LLM sees prior voice actions in
|
||||
* its session memory.
|
||||
*
|
||||
* Null on every other message — including server-loaded history, normal
|
||||
* user messages, regular assistant turns, and tool-call cards rendered
|
||||
* via [ToolCall].
|
||||
*
|
||||
* The sync builder treats messages with [voiceIntent] != null and
|
||||
* [VoiceIntentTrace.syncedToServer] == false as the inputs to its
|
||||
* synthesis pass; on a successful send we flip [VoiceIntentTrace.syncedToServer]
|
||||
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* so they're not re-sent on the next turn.
|
||||
*/
|
||||
val voiceIntent: VoiceIntentTrace? = null,
|
||||
/**
|
||||
* Provider-native Realtime Agent turns can answer without calling Hermes.
|
||||
* Those local-only assistant turns need to be spliced into the next Hermes
|
||||
* chat/run payload so switching back to normal chat preserves context.
|
||||
*
|
||||
* Hermes-backed realtime turns leave this null because Hermes already owns
|
||||
* the durable session turn; the provider's spoken summary is UI/runtime
|
||||
* provenance, not another canonical assistant message.
|
||||
*/
|
||||
val realtimeTurn: RealtimeTurnTrace? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Structured details about a phone-local voice intent that was dispatched
|
||||
* in-process via [com.hermesandroid.relay.network.handlers.BridgeCommandHandler.handleLocalCommand].
|
||||
*
|
||||
* Captured on a [ChatMessage] (id prefix `voice-intent-`) so the next chat
|
||||
* payload can include synthetic OpenAI-format `assistant` + `tool` message
|
||||
* pairs that bring the server-side LLM up to speed on what the user did via
|
||||
* voice. Without this, follow-up text questions like "did that work?" hit
|
||||
* the LLM with no prior context and get hallucinated answers.
|
||||
*
|
||||
* Why a single field instead of three booleans + nullable strings on
|
||||
* [ChatMessage]: keeps the ChatMessage surface small and makes the "this is
|
||||
* a voice-intent trace, treat it specially" check a single null-vs-non-null
|
||||
* comparison rather than a multi-field rule that would drift over time.
|
||||
*
|
||||
* @property toolName The Hermes plugin tool name the intent maps to
|
||||
* (`android_open_app`, `android_send_sms`, etc.). Matches the names the
|
||||
* gateway-side LLM tools use when it calls the same actions itself. Must
|
||||
* start with `android_` to be a valid sync target — the builder uses this
|
||||
* prefix as a sanity gate when constructing the synthetic tool_call.
|
||||
* @property argumentsJson Compact JSON object with the args the intent
|
||||
* resolved to, e.g. `{"app_name":"Chrome","package":"com.android.chrome"}`
|
||||
* for an Open App dispatch. Stored as a string so we can hand it straight
|
||||
* to the OpenAI `function.arguments` field, which is itself a JSON-encoded
|
||||
* string by spec. Must be valid JSON.
|
||||
* @property success True if the local dispatch returned a 200-class status,
|
||||
* false on any other status (denial, permission missing, dispatcher
|
||||
* failure, etc.). Drives whether the synthetic `tool`-role response
|
||||
* includes an `error` field.
|
||||
* @property resultJson Compact JSON object describing the dispatch outcome.
|
||||
* On success, typically `{"ok":true,...}` with any tool-specific fields
|
||||
* from [com.hermesandroid.relay.network.handlers.LocalDispatchResult.resultJson].
|
||||
* On failure, an error envelope including `ok:false`, `error`, optionally
|
||||
* `error_code`. Stored as a string and rendered verbatim into the
|
||||
* synthetic `tool`-role message's `content` field.
|
||||
* @property syncedToServer Idempotency guard. Flipped to true by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* the moment we hand the request payload to the API client. Once true,
|
||||
* the trace is excluded from future sync passes — the server-side
|
||||
* session has already absorbed it.
|
||||
*/
|
||||
data class VoiceIntentTrace(
|
||||
val toolName: String,
|
||||
val argumentsJson: String,
|
||||
val success: Boolean,
|
||||
val resultJson: String,
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Local provider-native realtime turn that has not necessarily been absorbed
|
||||
* into the Hermes session yet.
|
||||
*
|
||||
* Stored on the assistant message so the next normal chat send can emit a
|
||||
* compact OpenAI-format user/assistant pair before the live user message. This
|
||||
* keeps Realtime Agent and Hermes Chat + Voice Output as one conversation even
|
||||
* when the realtime provider answered directly.
|
||||
*/
|
||||
data class RealtimeTurnTrace(
|
||||
val userText: String,
|
||||
val assistantText: String,
|
||||
val provider: String? = null,
|
||||
val model: String? = null,
|
||||
val voice: String? = null,
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -118,6 +234,8 @@ data class ToolCall(
|
||||
val success: Boolean?,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
val runId: String? = null,
|
||||
val provenance: String? = null,
|
||||
// Duration tracking
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val completedAt: Long? = null
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
@Serializable
|
||||
data class DashboardConnectionStatus(
|
||||
val checkedAtMillis: Long? = null,
|
||||
val reachable: Boolean = false,
|
||||
val authRequired: Boolean? = null,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authenticated: Boolean? = null,
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
* Each connection has its own:
|
||||
* - API server URL + relay URL
|
||||
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
|
||||
* session token, device ID, API key, and paired-session metadata.
|
||||
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
||||
* so that store is intrinsically per-connection as long as hosts differ).
|
||||
* - Last-active session ID (to restore the open chat on connection switch).
|
||||
* - Transport hint + session expiry mirrored from the server's `auth.ok`
|
||||
* payload so the connection list can show "expires in 3d" without cracking
|
||||
* open the token store.
|
||||
*
|
||||
* Switching connection is a HEAVY context swap — caller is expected to tear down
|
||||
* the current [com.hermesandroid.relay.network.ConnectionManager],
|
||||
* [com.hermesandroid.relay.auth.AuthManager], and API client, then construct
|
||||
* fresh ones pointed at the new connection's `tokenStoreKey`.
|
||||
*
|
||||
* **Zero-disruption migration:** the legacy pre-multi-connection install kept
|
||||
* all of its auth state in a single EncryptedSharedPreferences file named
|
||||
* [LEGACY_TOKEN_STORE_KEY]. On first launch after the multi-connection upgrade,
|
||||
* [ConnectionStore.migrateLegacyConnectionIfNeeded] seeds connection 0 pointing
|
||||
* at that existing file — no token migration, no re-pair.
|
||||
*
|
||||
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
|
||||
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
|
||||
* free to mean upstream Hermes profiles: separate host-side Hermes homes
|
||||
* under `~/.hermes/profiles/<name>/`, each with its own config, SOUL, memory,
|
||||
* sessions, skills, cron, and provider state.
|
||||
*/
|
||||
@Serializable
|
||||
data class Connection(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val apiServerUrl: String,
|
||||
val relayUrl: String,
|
||||
val tokenStoreKey: String,
|
||||
/**
|
||||
* Hermes dashboard/admin URL. Dashboard management features use this
|
||||
* separately from the relay pairing channel; a blank/null value means
|
||||
* "derive from [apiServerUrl] using the conventional same-host :9119".
|
||||
*/
|
||||
val dashboardUrl: String? = null,
|
||||
val dashboardAuthRequired: Boolean? = null,
|
||||
val dashboardAuthProviders: List<String> = emptyList(),
|
||||
val dashboardLastStatus: DashboardConnectionStatus? = null,
|
||||
/**
|
||||
* Candidate host routes for this saved Hermes server. Standard setup
|
||||
* stores at least one candidate here so API, dashboard, voice, and Relay
|
||||
* helpers can follow LAN/Tailscale/public handoff before Relay pairing.
|
||||
* Older installs and legacy serialized records default to an empty list.
|
||||
*/
|
||||
val routeCandidates: List<EndpointCandidate> = emptyList(),
|
||||
/** Optional user preference such as "lan" or "tailscale"; null means Auto. */
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
val transportHint: String? = null,
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
* [com.hermesandroid.relay.auth.KeystoreTokenStore]'s original
|
||||
* hardcoded `PREFS_NAME`. Connection 0 re-uses this file as-is so the
|
||||
* existing paired device keeps working across the upgrade.
|
||||
*/
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
* from a connection UUID. Trimmed to the first 8 characters of the
|
||||
* UUID so the on-disk filename stays short and human-diffable, which
|
||||
* matters because [android.content.Context.deleteSharedPreferences]
|
||||
* only accepts a filename string.
|
||||
*/
|
||||
fun buildTokenStoreKey(id: String): String = "hermes_auth_${id.take(8)}"
|
||||
|
||||
/**
|
||||
* Human-friendly default label for a newly-added connection. Uses the
|
||||
* hostname of the API server URL so "http://192.168.1.10:8642" becomes
|
||||
* "192.168.1.10". Falls back to the raw URL if parsing fails (e.g.,
|
||||
* user typed a malformed value — better to show something recognizable
|
||||
* than to crash).
|
||||
*/
|
||||
fun extractDefaultLabel(apiServerUrl: String): String {
|
||||
return try {
|
||||
URI(apiServerUrl).host ?: apiServerUrl
|
||||
} catch (_: Exception) {
|
||||
apiServerUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
apiServerUrl: String,
|
||||
dashboardPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
val derived = deriveDefaultDashboardUrl(apiServerUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "ws"
|
||||
"https" -> "wss"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun buildRouteCandidates(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
role = inferRouteRole(apiServerUrl),
|
||||
priority = 0,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
.map { it.first.trim() to it.second.trim() }
|
||||
.filter { (_, url) -> url.isNotBlank() }
|
||||
.forEachIndexed { index, (role, url) ->
|
||||
endpointCandidateFromApiUrl(
|
||||
role = role.ifBlank { inferRouteRole(url) },
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay a freshly-rebuilt candidate list onto an existing stored
|
||||
* one, preserving the stored extras (priority > 0) that the rebuild
|
||||
* doesn't already cover. URL edits rebuild only the route(s) the
|
||||
* user actually touched — without this merge, saving an API or
|
||||
* Relay URL collapsed the stored list to a single candidate,
|
||||
* silently dropping the setup wizard's Tailscale route (or a
|
||||
* pairing payload's extra endpoints) and killing LAN/VPN roaming.
|
||||
*
|
||||
* Stored extras are preserved **verbatim** (role, priority, relay
|
||||
* URL) rather than re-derived, so payload-specified relay URLs
|
||||
* survive. Host:port collisions defer to the rebuilt entry.
|
||||
*/
|
||||
fun mergeRouteCandidates(
|
||||
rebuilt: List<EndpointCandidate>,
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize hand-typed API-URL input: trim, strip trailing slashes,
|
||||
* default a missing scheme to `http://`, and default a missing port
|
||||
* to [defaultPort] — most Hermes API servers speak plain HTTP on
|
||||
* 8642, and a bare `192.168.1.10` / Tailscale `100.x.y.z` is by far
|
||||
* the most common thing users type.
|
||||
*
|
||||
* URLs that already carry a scheme are preserved **verbatim**
|
||||
* (including a wrong one like `ws://`, so downstream validators can
|
||||
* complain precisely): an explicit `https://hermes.example.com` may
|
||||
* be a reverse proxy on 443, and force-appending :8642 would break
|
||||
* it. Port-defaulting applies only to scheme-less input, where the
|
||||
* user is visibly relying on our defaults.
|
||||
*/
|
||||
fun normalizeApiUrlInput(raw: String, defaultPort: Int = 8642): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
|
||||
val withScheme = "http://$trimmed"
|
||||
val uri = runCatching { URI(withScheme) }.getOrNull()
|
||||
val canAppendPort = uri != null &&
|
||||
!uri.host.isNullOrBlank() &&
|
||||
uri.port <= 0 &&
|
||||
uri.rawPath.isNullOrEmpty() &&
|
||||
uri.rawQuery == null
|
||||
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
|
||||
}
|
||||
|
||||
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
|
||||
fun endpointCandidateFromApiUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = if (uri.port > 0) uri.port else 8642
|
||||
val resolvedRelayUrl = relayUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl)
|
||||
?: return null
|
||||
val transportHint = when {
|
||||
resolvedRelayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
resolvedRelayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
?.lowercase()
|
||||
?: return "custom"
|
||||
return when {
|
||||
host.endsWith(".ts.net") || isTailscaleIpv4(host) -> "tailscale"
|
||||
host == "localhost" ||
|
||||
host == "127.0.0.1" ||
|
||||
host == "::1" ||
|
||||
isPrivateLanIpv4(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTailscaleIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return parts[0] == 100 && parts[1] in 64..127
|
||||
}
|
||||
|
||||
private fun isPrivateLanIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
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 normalized = connection.withDashboardDefaults()
|
||||
val next = current.filterNot { it.id == connection.id } + normalized
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the connection with [connection]'s id. No-op if no connection
|
||||
* with that id exists — callers should use [addConnection] for inserts.
|
||||
*/
|
||||
suspend fun updateConnection(connection: Connection) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (current.none { it.id == connection.id }) {
|
||||
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
|
||||
return@edit
|
||||
}
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.map { if (it.id == connection.id) normalized else it }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the connection with [id] from the list and delete its backing
|
||||
* EncryptedSharedPreferences file. If [id] is currently active, the
|
||||
* active pointer is cleared — callers are responsible for picking a new
|
||||
* active connection.
|
||||
*
|
||||
* The EncryptedSharedPreferences file is deleted via
|
||||
* [Context.deleteSharedPreferences], which is documented as API 24+ and
|
||||
* is safe on our minSdk 26. The legacy connection's file
|
||||
* ([Connection.LEGACY_TOKEN_STORE_KEY]) is deleted the same way — there's
|
||||
* nothing structurally special about it once the user explicitly asks
|
||||
* to remove connection 0.
|
||||
*/
|
||||
suspend fun removeConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
var removed: Connection? = null
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
removed = current.firstOrNull { it.id == id }
|
||||
val next = current.filterNot { it.id == id }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
|
||||
if (prefs[KEY_ACTIVE_CONNECTION_ID] == id) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory-reset helper: clear the persisted connection list, active
|
||||
* pointer, legacy profile aliases, and every known per-connection auth
|
||||
* store. Unlike removing one connection, this intentionally does not pick
|
||||
* a successor; callers are resetting the app back to "no connection".
|
||||
*/
|
||||
suspend fun clearAllConnections() {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
val normalizedConnections = connections.map { it.withDashboardDefaults() }
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (normalizedConnections.isEmpty()) {
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
} else {
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(normalizedConnections)
|
||||
}
|
||||
if (normalizedActiveId == null) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = normalizedActiveId
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteTokenStoresFor(connection: Connection) {
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setActiveConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_activeConnectionId.value = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update just the `lastActiveSessionId` on the identified connection.
|
||||
* Called whenever the user picks a chat session so connection-switch can
|
||||
* restore the same session on re-selection. No-op if the connection
|
||||
* doesn't exist.
|
||||
*/
|
||||
suspend fun setLastActiveSessionId(connectionId: String, sessionId: String?) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) target.copy(lastActiveSessionId = sessionId) else it
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the identified connection with pairing metadata pulled out of an
|
||||
* `auth.ok` payload. No-op if the connection doesn't exist — callers are
|
||||
* responsible for ordering this after [addConnection].
|
||||
*
|
||||
* Both [pairedAtMillis] and [expiresAtMillis] are epoch milliseconds —
|
||||
* pass `System.currentTimeMillis()` for pairedAt and `expires_at * 1000`
|
||||
* for the seconds-based auth.ok payload field. `ConnectionsSettingsScreen`
|
||||
* assumes millis when rendering relative time; pass seconds here and
|
||||
* cards will always read as "Paired decades ago".
|
||||
*/
|
||||
suspend fun markPaired(
|
||||
connectionId: String,
|
||||
pairedAtMillis: Long,
|
||||
transportHint: String?,
|
||||
expiresAtMillis: Long?,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) {
|
||||
target.copy(
|
||||
pairedAt = pairedAtMillis,
|
||||
transportHint = transportHint,
|
||||
expiresAt = expiresAtMillis,
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot legacy migration: if no connections are persisted yet, seed a
|
||||
* connection 0 pointing at [Connection.LEGACY_TOKEN_STORE_KEY] so the
|
||||
* existing paired install keeps working without a re-pair.
|
||||
*
|
||||
* Idempotent — subsequent calls after connection 0 is seeded (or after
|
||||
* the user has added real connections) are a no-op. Pass the legacy URL
|
||||
* / session values from whatever store currently holds them (e.g.,
|
||||
* [ConnectionViewModel]'s DataStore-backed URL preferences).
|
||||
*/
|
||||
suspend fun migrateLegacyConnectionIfNeeded(
|
||||
legacyApiServerUrl: String?,
|
||||
legacyRelayUrl: String?,
|
||||
legacyLastSessionId: String?,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val existing = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (existing.isNotEmpty()) {
|
||||
// Already migrated or already has user-created connections.
|
||||
return@edit
|
||||
}
|
||||
if (legacyApiServerUrl.isNullOrBlank() && legacyRelayUrl.isNullOrBlank()) {
|
||||
Log.i(TAG, "migrateLegacyConnectionIfNeeded: no legacy URLs to seed")
|
||||
return@edit
|
||||
}
|
||||
val apiUrl = legacyApiServerUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_API_URL
|
||||
val relayUrl = legacyRelayUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_RELAY_URL
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
val seed = Connection(
|
||||
id = id,
|
||||
label = Connection.extractDefaultLabel(apiUrl),
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
|
||||
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
|
||||
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
|
||||
pairedAt = null,
|
||||
lastActiveSessionId = legacyLastSessionId,
|
||||
transportHint = null,
|
||||
expiresAt = null,
|
||||
)
|
||||
val next = listOf(seed)
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_connections.value = next
|
||||
_activeConnectionId.value = id
|
||||
Log.i(TAG, "migrateLegacyConnectionIfNeeded: seeded connection 0 id=$id apiUrl=$apiUrl")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDashboardStatus(
|
||||
connectionId: String,
|
||||
status: DashboardConnectionStatus,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) {
|
||||
target.copy(
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
dashboardAuthRequired = status.authRequired,
|
||||
dashboardAuthProviders = status.authProviders,
|
||||
dashboardLastStatus = status,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encoding helpers ---------------------------------------------------
|
||||
|
||||
private fun encodeConnections(list: List<Connection>): String =
|
||||
json.encodeToString(connectionListSerializer, list)
|
||||
|
||||
private fun decodeConnections(raw: String?): List<Connection> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString(connectionListSerializer, raw)
|
||||
.map { it.withDashboardDefaults() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
||||
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
|
||||
|
||||
// Pre-rename DataStore keys — read once in init on first launch after
|
||||
// the rename, then wiped. See the init block above.
|
||||
private val KEY_LEGACY_PROFILES = stringPreferencesKey("profiles_v1")
|
||||
private val KEY_LEGACY_ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
|
||||
|
||||
// Match the defaults used by ConnectionViewModel so a seeded connection
|
||||
// from migrateLegacyConnectionIfNeeded() resolves to the same endpoints
|
||||
// a fresh install would.
|
||||
private const val DEFAULT_API_URL = "http://localhost:8642"
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
/**
|
||||
* Validation rules for user-editable connection fields. Kept as a standalone
|
||||
* object so the same rules fire in all save paths — inline dialog feedback,
|
||||
* post-pairing add-connection, and any future import path — without the VM,
|
||||
* the UI, and the data layer each re-implementing string checks.
|
||||
*
|
||||
* All validators return null on success or a short human-readable message
|
||||
* suitable for surfacing in an OutlinedTextField `supportingText` slot or a
|
||||
* Snackbar. Messages are intentionally concise — callers add context ("in
|
||||
* connection label", "in relay URL") if the surface needs it.
|
||||
*/
|
||||
object ConnectionValidation {
|
||||
|
||||
const val LABEL_MAX_LEN: Int = 40
|
||||
|
||||
/**
|
||||
* @return null when valid, else a message describing the problem.
|
||||
* Callers should `.trim()` before persisting — this does not
|
||||
* mutate the input.
|
||||
*/
|
||||
fun validateLabel(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "Label can't be blank"
|
||||
if (trimmed.length > LABEL_MAX_LEN) return "Label too long (max $LABEL_MAX_LEN)"
|
||||
// Control characters (newlines, tabs, nul, etc.) would render badly
|
||||
// in chips and are almost certainly a paste-accident.
|
||||
if (trimmed.any { it.isISOControl() }) return "Label can't contain control characters"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* API server must be http:// or https:// with a host. The user pairs
|
||||
* before they can save a connection, so malformed URLs shouldn't reach
|
||||
* this path — but defense-in-depth against manual edits / restored
|
||||
* backups is worth the few lines.
|
||||
*/
|
||||
fun validateApiServerUrl(raw: String): String? = validateUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "API server URL",
|
||||
)
|
||||
|
||||
/** Relay URL must be ws:// or wss:// with a host. */
|
||||
fun validateRelayUrl(raw: String): String? = validateUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("ws", "wss"),
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/**
|
||||
* Catches the "added the same server twice" mistake. Matches when the
|
||||
* candidate's api + relay URLs exactly match an existing connection
|
||||
* (case-insensitive on scheme + host, per RFC 3986). [excludeId] skips
|
||||
* a specific connection so renames don't trip over their own entry.
|
||||
*
|
||||
* Deliberately lenient: two connections may share either URL alone (dev
|
||||
* that points API at prod + relay at a test box, say). Full exact-match
|
||||
* on both is the only blocked case.
|
||||
*/
|
||||
fun findDuplicate(
|
||||
connections: List<Connection>,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
excludeId: String? = null,
|
||||
): Connection? = connections.firstOrNull { c ->
|
||||
c.id != excludeId &&
|
||||
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
|
||||
c.relayUrl.equals(relayUrl, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "$kind can't be blank"
|
||||
val uri = try {
|
||||
URI(trimmed)
|
||||
} catch (_: URISyntaxException) {
|
||||
return "$kind is malformed"
|
||||
}
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme == null || scheme !in allowedSchemes) {
|
||||
return "$kind must start with ${allowedSchemes.joinToString(" or ") { "$it://" }}"
|
||||
}
|
||||
if (uri.host.isNullOrBlank()) return "$kind has no host"
|
||||
val port = uri.port
|
||||
if (port != -1 && (port < 1 || port > 65535)) return "$kind has an invalid port"
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import android.util.Log
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
|
||||
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.StoredDashboardCookie
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -13,15 +17,27 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages app data: backup, restore, and reset.
|
||||
*
|
||||
* Backup format is a JSON file containing settings and connection info.
|
||||
* Tokens are NOT included in backups for security.
|
||||
* Backup format is a JSON file containing full connection metadata and
|
||||
* credentials. Treat exported files as sensitive secrets.
|
||||
*/
|
||||
class DataManager(private val context: Context) {
|
||||
class DataManager(
|
||||
private val context: Context,
|
||||
/**
|
||||
* Multi-connection: the [ConnectionStore] singleton whose snapshot gets
|
||||
* written into [AppBackup.connections] on export. Nullable for
|
||||
* legacy/compat call sites that construct a [DataManager] without
|
||||
* connection support; a null store just means "export an empty
|
||||
* connections list" (equivalent to v2 behavior).
|
||||
*/
|
||||
private val connectionStore: ConnectionStore? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DataManager"
|
||||
@@ -39,53 +55,188 @@ class DataManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup data model -- only non-sensitive settings.
|
||||
* Tokens and device IDs are never included.
|
||||
* Backup data model.
|
||||
*
|
||||
* **Schema history:**
|
||||
* - v1: `serverUrl` only (single endpoint, pre-API-split).
|
||||
* - v2: adds `apiServerUrl` + `relayUrl`; `profiles: List<String>` held
|
||||
* server-issued session labels from `auth.ok` (never actually populated
|
||||
* on export — the field was vestigial).
|
||||
* - v3 (2026-04-18): `profiles: List<Profile>` — carried the new
|
||||
* multi-connection definitions under the then-current "Profile" name.
|
||||
* - v4 (2026-04-18): `connections: List<Connection>` — same shape as v3
|
||||
* under the renamed concept. v3 imports get their `profiles` field
|
||||
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
|
||||
* get `connections = emptyList()` since the old string list was not
|
||||
* structurally compatible.
|
||||
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||
* paired metadata, and dashboard cookies.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 2,
|
||||
val version: Int = 5,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
val theme: String = "auto",
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val profiles: List<String> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val activeConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionSecretBackup(
|
||||
val connectionId: String,
|
||||
val tokenStoreKey: String,
|
||||
val auth: ConnectionAuthSecrets = ConnectionAuthSecrets(),
|
||||
val dashboardCookies: List<DashboardCookieBackup> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardCookieBackup(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Export app settings to a JSON string.
|
||||
* Does NOT include session tokens or device IDs (security).
|
||||
* Includes connection credentials. The export UI must warn the user that
|
||||
* the resulting JSON file is sensitive.
|
||||
*
|
||||
* The `sessionLabels` parameter is a legacy dead parameter — it was
|
||||
* previously sourced from `AuthManager.sessionLabels`, a field removed
|
||||
* in Pass 2 of the multi-connection rollout (2026-04-18) when it was
|
||||
* replaced by the structured `agentProfiles: StateFlow<List<Profile>>`.
|
||||
* Kept only for call-site signature stability; not written to the
|
||||
* backup. The backup's [AppBackup.connections] comes from the
|
||||
* injected [connectionStore] snapshot. Callers should pass
|
||||
* `emptyList()`. Will be removed in a later pass.
|
||||
*/
|
||||
suspend fun exportSettings(
|
||||
serverUrl: String?,
|
||||
theme: String,
|
||||
onboardingCompleted: Boolean,
|
||||
profiles: List<String>,
|
||||
@Suppress("UNUSED_PARAMETER") sessionLabels: List<String>,
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"exportSettings: no ConnectionStore wired — writing empty connections list " +
|
||||
"(caller constructed DataManager without the multi-connection ctor arg)",
|
||||
)
|
||||
}
|
||||
val connectionSecrets = connectionsSnapshot.map { connection ->
|
||||
ConnectionSecretBackup(
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
auth = AuthManager.exportStoredSecrets(context, connection.tokenStoreKey),
|
||||
dashboardCookies = EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).load().map { it.toBackup() },
|
||||
)
|
||||
}
|
||||
val backup = AppBackup(
|
||||
version = 2,
|
||||
version = 5,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
profiles = profiles,
|
||||
exportedAt = System.currentTimeMillis()
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
)
|
||||
return json.encodeToString(backup)
|
||||
}
|
||||
|
||||
suspend fun restoreConnectionBackup(backup: AppBackup) {
|
||||
val store = connectionStore ?: return
|
||||
deleteSensitivePreferenceFiles()
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
backup.connectionSecrets.forEach { secret ->
|
||||
val connection = connectionsById[secret.connectionId] ?: return@forEach
|
||||
AuthManager.importStoredSecrets(
|
||||
context = context,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
secrets = secret.auth,
|
||||
)
|
||||
EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).save(secret.dashboardCookies.map { it.toStoredCookie() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from a JSON string.
|
||||
* Returns the parsed backup, or null if invalid.
|
||||
*
|
||||
* For v1/v2 backups we deliberately drop the old `profiles: List<String>`
|
||||
* field on its own, since kotlinx.serialization's `ignoreUnknownKeys`
|
||||
* would throw if it found the old scalar-string entries where it now
|
||||
* expects [Connection] objects. We pre-parse as a [JsonElement] and
|
||||
* rebuild the object with `connections = []` on older schema versions.
|
||||
*
|
||||
* For v3 backups, the old `profiles: List<Profile>` field is re-mapped
|
||||
* to `connections: List<Connection>` — same wire shape, just renamed.
|
||||
*/
|
||||
fun importSettings(jsonString: String): AppBackup? {
|
||||
return try {
|
||||
json.decodeFromString<AppBackup>(jsonString)
|
||||
val element = json.parseToJsonElement(jsonString)
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val version = obj["version"]?.let {
|
||||
(it as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
} ?: 4
|
||||
val normalized = when {
|
||||
version < 3 -> {
|
||||
// Strip the incompatible v1/v2 `profiles` field so the
|
||||
// serializer doesn't try to decode List<String> into
|
||||
// List<Connection>. The feature never populated the list
|
||||
// in export anyway, so no user data is lost.
|
||||
Log.d(
|
||||
TAG,
|
||||
"importSettings: dropping legacy v$version profiles field " +
|
||||
"(schema was vestigial)",
|
||||
)
|
||||
JsonObject(obj - "profiles")
|
||||
}
|
||||
version == 3 -> {
|
||||
// v3 used `profiles: List<Profile>` with the same wire
|
||||
// shape as v4's `connections: List<Connection>`. Swap
|
||||
// the key name and decode as v4.
|
||||
val profilesField = obj["profiles"]
|
||||
val withoutProfiles = obj - "profiles"
|
||||
if (profilesField != null) {
|
||||
JsonObject(withoutProfiles + ("connections" to profilesField))
|
||||
} else {
|
||||
JsonObject(withoutProfiles)
|
||||
}
|
||||
}
|
||||
else -> obj
|
||||
}
|
||||
json.decodeFromJsonElement(AppBackup.serializer(), normalized)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse backup JSON", e)
|
||||
null
|
||||
@@ -138,6 +289,11 @@ class DataManager(private val context: Context) {
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
|
||||
// Multi-connection reset: clear the hot ConnectionStore state and
|
||||
// delete every per-connection token store before the global
|
||||
// DataStore is wiped.
|
||||
connectionStore?.clearAllConnections()
|
||||
|
||||
// Clear all DataStore preferences
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
|
||||
@@ -148,15 +304,7 @@ class DataManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the EncryptedSharedPreferences file for auth tokens
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val authFile = File(prefsDir, "$AUTH_PREFS_NAME.xml")
|
||||
if (authFile.exists()) {
|
||||
authFile.delete()
|
||||
Log.d(TAG, "Deleted auth preferences file")
|
||||
}
|
||||
}
|
||||
deleteSensitivePreferenceFiles()
|
||||
|
||||
// Clear cache directory
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -171,6 +319,61 @@ class DataManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val stores = buildSet {
|
||||
add(AUTH_PREFS_NAME)
|
||||
add(Connection.LEGACY_TOKEN_STORE_KEY)
|
||||
prefsDir.listFiles()?.forEach { file ->
|
||||
if (file.extension == "xml") {
|
||||
val name = file.nameWithoutExtension
|
||||
if (
|
||||
name.startsWith("hermes_auth_") ||
|
||||
name.startsWith("hermes_dashboard_")
|
||||
) {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stores.forEach { storeName ->
|
||||
try {
|
||||
context.deleteSharedPreferences(storeName)
|
||||
Log.d(TAG, "Deleted auth preferences file: $storeName")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($storeName) failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.toBackup(): DashboardCookieBackup =
|
||||
DashboardCookieBackup(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
private fun DashboardCookieBackup.toStoredCookie(): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
||||
* pairing, 2026-04-19).
|
||||
*
|
||||
* Pairing QRs can now carry an ordered list of candidate endpoints so a single
|
||||
* pairing works across LAN / Tailscale / public-reverse-proxy networks. The
|
||||
* phone picks the highest-priority reachable candidate at connect time and
|
||||
* re-evaluates on network change.
|
||||
*
|
||||
* Wire contract (v3 pairing payload):
|
||||
* ```json
|
||||
* {
|
||||
* "role": "lan",
|
||||
* "priority": 0,
|
||||
* "api": { "host": "192.168.1.100", "port": 8642, "tls": false },
|
||||
* "relay": { "url": "ws://192.168.1.100:8767", "transport_hint": "ws" }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **Semantics (locked by ADR 24):**
|
||||
* - [role] is an open string. Known values `lan` / `tailscale` / `public`
|
||||
* get styled labels; anything else renders generically (`Custom VPN (<role>)`).
|
||||
* No enum, no normalization — the raw role string must round-trip exactly
|
||||
* so HMAC canonicalization holds.
|
||||
* - [priority] is strict, `0 = highest`. Reachability never promotes a lower
|
||||
* priority over a higher one; it only breaks ties among equal priorities.
|
||||
* - The per-endpoint [RelayEndpoint] intentionally carries **only** the URL
|
||||
* and transport hint. The pairing `code`, `ttl_seconds`, and `grants` stay
|
||||
* at the top level of the pairing payload because they're per-pair
|
||||
* artifacts — not per-endpoint.
|
||||
*/
|
||||
@Serializable
|
||||
data class EndpointCandidate(
|
||||
val role: String,
|
||||
val priority: Int = 0,
|
||||
val api: ApiEndpoint,
|
||||
val relay: RelayEndpoint,
|
||||
)
|
||||
|
||||
/**
|
||||
* The API-server half of an [EndpointCandidate] — the HTTP/SSE target the
|
||||
* phone uses for `/v1/runs`, `/v1/chat/completions`, `/api/sessions/...`, etc.
|
||||
*
|
||||
* Note: [tls] defaults to false so a v2-synthesized candidate (built from a
|
||||
* legacy QR with no `endpoints` field and no top-level `tls`) still
|
||||
* deserializes cleanly.
|
||||
*/
|
||||
@Serializable
|
||||
data class ApiEndpoint(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val tls: Boolean = false,
|
||||
) {
|
||||
/** Build the full API server URL from host, port, and tls flag. */
|
||||
val url: String
|
||||
get() = "${if (tls) "https" else "http"}://$host:$port"
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay-server half of an [EndpointCandidate] — the WSS URL the phone
|
||||
* opens for the bridge + terminal channels.
|
||||
*
|
||||
* @property url full WebSocket URL, e.g. `ws://192.168.1.100:8767` (dev) or
|
||||
* `wss://hermes.example.com/relay` (fronted by a reverse proxy).
|
||||
* @property transportHint `"wss"` / `"ws"` / `null`. Drives the plaintext-ws
|
||||
* consent gate and the transport-security UI badge. Never gates
|
||||
* behavior on its own — the scheme of [url] is authoritative.
|
||||
*/
|
||||
@Serializable
|
||||
data class RelayEndpoint(
|
||||
val url: String,
|
||||
@SerialName("transport_hint")
|
||||
val transportHint: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns true when [EndpointCandidate.role] is one of the built-in, styled
|
||||
* roles: `lan`, `tailscale`, or `public`. Case-insensitive match — but the
|
||||
* role string itself is still preserved verbatim for HMAC canonicalization.
|
||||
*
|
||||
* Unknown roles (`"wireguard"`, `"zerotier"`, `"netbird-eu"`, operator-defined
|
||||
* labels) return false so the UI can fall back to [displayLabel]'s generic
|
||||
* "Custom VPN" treatment.
|
||||
*/
|
||||
fun EndpointCandidate.isKnownRole(): Boolean {
|
||||
return when (role.lowercase()) {
|
||||
"lan", "tailscale", "public" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for the UI. Known roles get fixed-case styled labels;
|
||||
* unknown roles render as `"Custom VPN (<role>)"` with the raw role preserved
|
||||
* so an operator can see exactly what they labeled it.
|
||||
*
|
||||
* The raw [role] on the [EndpointCandidate] is NOT modified — it stays in its
|
||||
* emitted form for HMAC canonicalization. This is a display-only transform.
|
||||
*/
|
||||
fun EndpointCandidate.displayLabel(): String {
|
||||
return when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
else -> "Custom VPN ($role)"
|
||||
}
|
||||
}
|
||||
@@ -59,4 +59,68 @@ object FeatureFlags {
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety hook for the V5 ExoPlayer rollout.
|
||||
*
|
||||
* Default `true` — VoicePlayer uses Media3 ExoPlayer for gapless TTS
|
||||
* queue playback. No MediaPlayer fallback is wired this session; if a
|
||||
* regression surfaces in the field we'll rewire one and honor this flag
|
||||
* at the construction site. Flipping this to `false` today has no
|
||||
* effect — it's a placeholder hook, not yet load-bearing.
|
||||
*/
|
||||
const val useExoPlayerVoice: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile-time gating based on the active Gradle product flavor.
|
||||
*
|
||||
* Phase 3 keeps "Hermes Bridge" as the umbrella, but only the `sideload`
|
||||
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
|
||||
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
|
||||
* companion, media, and session-grant surfaces without screen reading, taps,
|
||||
* typing, screenshots, overlays, or unattended control.
|
||||
*
|
||||
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
|
||||
* 1. baseline — sideload only (app open, tap, navigate within app)
|
||||
* 2. screen context — sideload only (Accessibility tree / screen reads)
|
||||
* 3. voice-first — sideload only (always-on voice capture)
|
||||
* 4. vision-first — sideload only (always-on screen reading)
|
||||
* 5. safety rails — sideload only (confirmation dialogs, action log)
|
||||
* 6. ambitious future — sideload only (cross-app macros, scheduling)
|
||||
*/
|
||||
object BuildFlavor {
|
||||
const val GOOGLE_PLAY = "googlePlay"
|
||||
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 get() = current == SIDELOAD // baseline device control
|
||||
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
|
||||
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
|
||||
val bridgeTier4: Boolean get() = current == SIDELOAD // vision-first
|
||||
val bridgeTier5: Boolean get() = current == SIDELOAD // safety rails
|
||||
val bridgeTier6: Boolean get() = current == SIDELOAD // future ambitious
|
||||
|
||||
/** Human-readable badge label for the Settings → About version row. */
|
||||
val displayName: String
|
||||
get() = when (current) {
|
||||
GOOGLE_PLAY -> "Google Play"
|
||||
SIDELOAD -> "Sideload"
|
||||
else -> current.ifBlank { "Unknown" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,80 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* An agent profile advertised by a Hermes server in its `auth.ok` payload.
|
||||
*
|
||||
* Historically (pre-R1) the server scanned a non-existent top-level
|
||||
* `profiles:` key in `~/.hermes/config.yaml` and effectively shipped an
|
||||
* empty list. Worker R1 rewrote the relay-side loader to scan the REAL
|
||||
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
|
||||
* and added [systemMessage], sourced from each profile's `SOUL.md`.
|
||||
*
|
||||
* A Profile is an upstream Hermes profile context within a Connection.
|
||||
* Upstream stores named profiles as separate Hermes homes under
|
||||
* `~/.hermes/profiles/<name>/`. Switching profile changes the active agent
|
||||
* identity for the Android chat surface:
|
||||
* - which profile API server the phone routes chat/session calls to when
|
||||
* the relay advertises [apiServerUrl];
|
||||
* - which profile name the phone sends to the server for new sessions and
|
||||
* chat turns when isolated routing is not available;
|
||||
* - which model and system message the phone can send as compatibility
|
||||
* fallback (via [model] and [systemMessage]);
|
||||
* - which profile-scoped session id Android resumes for local chat context.
|
||||
*
|
||||
* It does not mutate the server's configured default profile.
|
||||
*
|
||||
* Wire shape uses snake_case (`system_message`), this class uses camelCase
|
||||
* (`systemMessage`) — translated via [SerialName].
|
||||
*
|
||||
* **v0.7.0 runtime metadata.** Three optional fields — [gatewayRunning],
|
||||
* [hasSoul], [skillCount] — describe what the relay observes about each
|
||||
* profile directory at discovery time:
|
||||
* - [gatewayRunning] is a read-only probe (best-effort; can be stale or
|
||||
* wrong if the relay's last probe missed a restart). Drives the green/
|
||||
* grey status dot in the agent sheet.
|
||||
* - [hasSoul] is true when the profile directory has a non-empty
|
||||
* `SOUL.md` on disk. Decoupled from `systemMessage != null` so a SOUL
|
||||
* that fails to load (permissions, I/O) still reports its presence.
|
||||
* - [skillCount] is the count of skills visible under the profile
|
||||
* directory — drives the "N skills" chip.
|
||||
*
|
||||
* All three default to safe zero-values and are optional on the wire, so
|
||||
* older relays without the fields deserialize cleanly as
|
||||
* `gatewayRunning = false, hasSoul = false, skillCount = 0`.
|
||||
*
|
||||
* **Hermes profile API metadata.** A relay can advertise an isolated
|
||||
* profile API server without exposing its secret. When [apiServerUrl] is
|
||||
* present, Android routes chat/session traffic to that URL and reuses the
|
||||
* active connection's stored API key. Operators that use distinct API keys
|
||||
* per profile should pair those profile API servers as separate connections.
|
||||
*/
|
||||
@Serializable
|
||||
data class Profile(
|
||||
val name: String,
|
||||
val model: String,
|
||||
val description: String = "",
|
||||
@SerialName("system_message")
|
||||
val systemMessage: String? = null,
|
||||
@SerialName("gateway_running")
|
||||
val gatewayRunning: Boolean = false,
|
||||
@SerialName("has_soul")
|
||||
val hasSoul: Boolean = false,
|
||||
@SerialName("skill_count")
|
||||
val skillCount: Int = 0,
|
||||
@SerialName("api_server_enabled")
|
||||
val apiServerEnabled: Boolean = false,
|
||||
@SerialName("api_server_url")
|
||||
val apiServerUrl: String? = null,
|
||||
@SerialName("api_server_host")
|
||||
val apiServerHost: String? = null,
|
||||
@SerialName("api_server_port")
|
||||
val apiServerPort: Int? = null,
|
||||
@SerialName("api_server_key_present")
|
||||
val apiServerKeyPresent: Boolean = false,
|
||||
) {
|
||||
val hasIsolatedApi: Boolean
|
||||
get() = !apiServerUrl.isNullOrBlank()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Wire contracts for the v0.7.0 Profile Inspector endpoints:
|
||||
*
|
||||
* - `GET /api/profiles/{name}/config`
|
||||
* - `GET /api/profiles/{name}/skills`
|
||||
* - `GET /api/profiles/{name}/soul`
|
||||
* - `GET /api/profiles/{name}/memory`
|
||||
*
|
||||
* All four endpoints are read-only introspection views served by the relay
|
||||
* directly off disk (no gateway round-trip). Field names mirror the Python
|
||||
* worker's contracts exactly — any rename here is a protocol break.
|
||||
*
|
||||
* Optional fields on the wire (`truncated`, `readonly`) default to safe
|
||||
* values so older relays that omit them deserialize without failing the
|
||||
* whole payload.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Response for `GET /api/profiles/{name}/config`.
|
||||
*
|
||||
* `config` is a raw [JsonObject] so the UI can render arbitrary nested YAML
|
||||
* loaded from the profile's `config.yaml`. We don't model every possible
|
||||
* config shape — that's upstream Hermes territory and churns frequently.
|
||||
*
|
||||
* @property readonly The relay always serves this view read-only; the flag
|
||||
* is advisory. Optional on the wire, defaults to false.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileConfigResponse(
|
||||
val profile: String,
|
||||
val path: String,
|
||||
val config: JsonObject,
|
||||
val readonly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One entry in the [ProfileSkillsResponse.skills] list.
|
||||
*
|
||||
* `enabled` is optional on the wire; defaults to true so a pre-v0.7 relay
|
||||
* that doesn't emit the field treats every skill as enabled.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSkillEntry(
|
||||
val name: String,
|
||||
val category: String,
|
||||
val description: String,
|
||||
val path: String,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** Response for `GET /api/profiles/{name}/skills`. */
|
||||
@Serializable
|
||||
data class ProfileSkillsResponse(
|
||||
val profile: String,
|
||||
val skills: List<ProfileSkillEntry>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `GET /api/profiles/{name}/soul`.
|
||||
*
|
||||
* When `exists=false`, [content] is typically an empty string and the UI
|
||||
* should render an empty-state pointing at the expected [path].
|
||||
*
|
||||
* [truncated] is optional on the wire — Python may omit when false.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSoulResponse(
|
||||
val profile: String,
|
||||
val path: String,
|
||||
val content: String,
|
||||
val exists: Boolean,
|
||||
@SerialName("size_bytes")
|
||||
val sizeBytes: Long,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One entry in the [ProfileMemoryResponse.entries] list — a single memory
|
||||
* file found under the profile's memories directory.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileMemoryEntry(
|
||||
val name: String,
|
||||
val filename: String,
|
||||
val path: String,
|
||||
val content: String,
|
||||
@SerialName("size_bytes")
|
||||
val sizeBytes: Long,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Response for `GET /api/profiles/{name}/memory`. */
|
||||
@Serializable
|
||||
data class ProfileMemoryResponse(
|
||||
val profile: String,
|
||||
@SerialName("memories_dir")
|
||||
val memoriesDir: String,
|
||||
val entries: List<ProfileMemoryEntry>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `PUT /api/profiles/{name}/soul`. Server echoes back the
|
||||
* profile name, on-disk path, and bytes written so the UI can
|
||||
* optimistically confirm the write without an immediate re-fetch.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSoulUpdateResponse(
|
||||
val ok: Boolean,
|
||||
val profile: String,
|
||||
val path: String,
|
||||
@SerialName("bytes_written")
|
||||
val bytesWritten: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `PUT /api/profiles/{name}/memory/{filename}`. Same shape
|
||||
* as [ProfileSoulUpdateResponse] plus the filename so a client can
|
||||
* confirm which entry it just wrote (relevant when creating a new file
|
||||
* — the request echo proves the server stored it under the requested
|
||||
* name rather than silently rewriting a collision).
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileMemoryUpdateResponse(
|
||||
val ok: Boolean,
|
||||
val profile: String,
|
||||
val filename: String,
|
||||
val path: String,
|
||||
@SerialName("bytes_written")
|
||||
val bytesWritten: Long,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-connection persisted selection of the active profile name.
|
||||
*
|
||||
* Why separate from [relayDataStore]: the main `relay_settings` store holds
|
||||
* a large pile of global settings that are expensive to iterate on every
|
||||
* profile-selection change, and we want these mappings to survive clean-up
|
||||
* passes over the main store without special-casing per-connection keys.
|
||||
* The dedicated `profile_selections` DataStore is small, scoped, and can be
|
||||
* cleared wholesale without collateral damage.
|
||||
*
|
||||
* Stored shape: one string-preference key per connection id, value is the
|
||||
* profile `name` string (NOT the serialized Profile object — the Profile
|
||||
* itself is advertised fresh by the server on every `auth.ok` and the
|
||||
* on-server set can drift between app launches, so we resolve name → Profile
|
||||
* at read time against the current [ConnectionViewModel.agentProfiles] list).
|
||||
*
|
||||
* A `null` value means "clear" — the key is removed from the store rather
|
||||
* than written as an empty string. That way the flow emits null cleanly
|
||||
* on fresh installs and on explicit clears.
|
||||
*
|
||||
* See Commit 3 of feature/profile-config-readonly for wiring. The caller
|
||||
* ([com.hermesandroid.relay.viewmodel.ConnectionViewModel]) handles the
|
||||
* name → Profile resolution and is the sole consumer.
|
||||
*/
|
||||
class ProfileSelectionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSelectionsDataStore)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Preference-key factory. Per-connection so every connection gets
|
||||
* its own slot — wholesale clearing of the store still works by
|
||||
* calling [clear] per connection id (or, in disaster-recovery,
|
||||
* dropping the file).
|
||||
*/
|
||||
private fun keyFor(connectionId: String) =
|
||||
stringPreferencesKey("selected_profile_$connectionId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the selected profile name for [connectionId]. Passing `null`
|
||||
* removes the key — fresh installs and explicit clears both converge
|
||||
* on the same "no key" state.
|
||||
*/
|
||||
suspend fun setSelectedProfile(connectionId: String, profileName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId)
|
||||
if (profileName == null) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = profileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the persisted profile name for [connectionId], or `null` when
|
||||
* no selection has been stored. Callers must resolve the name against
|
||||
* the current server-advertised profile list — if the profile was
|
||||
* removed on the server, the caller should treat the resolution as
|
||||
* null (see ConnectionViewModel for the reference resolver).
|
||||
*/
|
||||
fun selectedProfileFlow(connectionId: String): Flow<String?> {
|
||||
val key = keyFor(connectionId)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the persisted selection for [connectionId]. Called from the
|
||||
* Connection removal path AFTER the switch-away job completes so we
|
||||
* don't delete the selection the just-unmounted store is still writing.
|
||||
*/
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated DataStore for [ProfileSelectionStore]. Kept separate from
|
||||
* [relayDataStore] so the two stores can evolve independently and a nuke
|
||||
* of one doesn't take out the other.
|
||||
*/
|
||||
internal val Context.profileSelectionsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_selections")
|
||||
@@ -0,0 +1,75 @@
|
||||
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, per-Hermes-profile last active chat session.
|
||||
*
|
||||
* This is intentionally separate from [ProfileSelectionStore]. Selection says
|
||||
* which agent is active; this store says which chat session belongs to that
|
||||
* agent on that connection. Null profile name is the explicit Server default
|
||||
* context.
|
||||
*/
|
||||
class ProfileSessionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSessionsDataStore)
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_session__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setSessionId(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
sessionId: String?,
|
||||
) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName)
|
||||
if (sessionId.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = sessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sessionIdFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileSessionsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_sessions")
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Compact text context sent to the relay when opening a provider-native
|
||||
* Realtime Agent session.
|
||||
*/
|
||||
data class RealtimeConversationContextMessage(
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val source: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Friendly-name store for terminal tabs, keyed by the stable wire-side
|
||||
* `session_name` (e.g. `hermes-<deviceId>-tabN`). Cosmetic-only — the name
|
||||
* never crosses the wire; tmux reattach + server-side bookkeeping still use
|
||||
* the opaque session name.
|
||||
*
|
||||
* Keyed on `session_name` because that's also what tmux keys on — if the
|
||||
* user detaches, restarts the app, and reattaches, the same name re-binds
|
||||
* to the same tmux session. Kill explicitly clears the name (via
|
||||
* [clearName]) because the underlying session is destroyed; detach leaves
|
||||
* the name intact as a "come back to this later" hint.
|
||||
*/
|
||||
class TerminalTabNameStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_NAMES_JSON = stringPreferencesKey("terminal_tab_names_json")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
|
||||
val namesFlow: Flow<Map<String, String>> = dataStore.data.map { prefs ->
|
||||
val raw = prefs[KEY_NAMES_JSON] ?: return@map emptyMap()
|
||||
runCatching { JSON.decodeFromString<Map<String, String>>(raw) }
|
||||
.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
/** Set or clear (null / blank) the friendly name for [sessionName]. */
|
||||
suspend fun setName(sessionName: String, displayName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[KEY_NAMES_JSON]
|
||||
?.let { runCatching { JSON.decodeFromString<Map<String, String>>(it) }.getOrNull() }
|
||||
?: emptyMap()
|
||||
val next = if (displayName.isNullOrBlank()) {
|
||||
current - sessionName
|
||||
} else {
|
||||
current + (sessionName to displayName.trim().take(MAX_NAME_LEN))
|
||||
}
|
||||
prefs[KEY_NAMES_JSON] = JSON.encodeToString(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the entry for a session that's being forcibly retired (e.g. kill). */
|
||||
suspend fun clearName(sessionName: String) = setName(sessionName, null)
|
||||
}
|
||||
|
||||
private const val MAX_NAME_LEN = 40
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Snapshot of a single tool-call invocation for the Stats-for-Nerds +
|
||||
* Timeline panels.
|
||||
*
|
||||
* Derived from [ToolCall] on the assistant messages but denormalized so
|
||||
* consumers don't need to walk the message list themselves. The ring
|
||||
* buffer that owns these is bounded — usually the last 10 tool calls
|
||||
* across all assistant messages in the active chat session.
|
||||
*
|
||||
* Status is split into two booleans so the UI can render three visual
|
||||
* states without introducing an enum dependency:
|
||||
* - `isComplete = false` → in-progress (spinner)
|
||||
* - `isComplete = true && success == true` → completed
|
||||
* - `isComplete = true && success == false` → failed
|
||||
*/
|
||||
data class ToolCallEvent(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val startedAtMs: Long,
|
||||
val completedAtMs: Long?,
|
||||
val isComplete: Boolean,
|
||||
val success: Boolean?,
|
||||
val resultSummary: String?,
|
||||
val errorSummary: String?,
|
||||
) {
|
||||
/** Elapsed wall-clock ms; null if the call hasn't completed. */
|
||||
val durationMs: Long?
|
||||
get() = completedAtMs?.let { it - startedAtMs }
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
@@ -20,48 +23,118 @@ import kotlinx.coroutines.flow.map
|
||||
* (V1 doesn't accept a language param).
|
||||
*/
|
||||
data class VoiceSettings(
|
||||
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 3000L,
|
||||
val autoTts: Boolean = false,
|
||||
val language: String = "",
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
* across turns (persistent conversation). When false, falls back to the
|
||||
* legacy one-session-per-utterance path. See
|
||||
* docs/plans/2026-05-24-realtime-persistent-session.md.
|
||||
*/
|
||||
val realtimePersistentSession: Boolean = true,
|
||||
)
|
||||
|
||||
class VoicePreferencesRepository(private val context: Context) {
|
||||
enum class VoiceEngineMode(val storageValue: String) {
|
||||
HermesVoiceOutput("hermes_voice_output"),
|
||||
RealtimeAgent("realtime_agent");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoiceEngineMode =
|
||||
values().firstOrNull { it.storageValue == value } ?: HermesVoiceOutput
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoiceAudioRoute(val storageValue: String) {
|
||||
Auto("auto"),
|
||||
Standard("standard"),
|
||||
Relay("relay");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoiceAudioRoute =
|
||||
values().firstOrNull { it.storageValue == value } ?: Auto
|
||||
}
|
||||
}
|
||||
|
||||
class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENGINE_MODE = stringPreferencesKey("voice_engine_mode")
|
||||
private val KEY_AUDIO_ROUTE = stringPreferencesKey("voice_audio_route")
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
|
||||
private val KEY_LANGUAGE = stringPreferencesKey("voice_language")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
|
||||
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
|
||||
const val DEFAULT_AUDIO_ROUTE = "auto"
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
|
||||
const val DEFAULT_AUTO_TTS = false
|
||||
const val DEFAULT_LANGUAGE = ""
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
}
|
||||
|
||||
val settings: Flow<VoiceSettings> = context.relayDataStore.data.map { prefs ->
|
||||
VoiceSettings(
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
|
||||
)
|
||||
val settings: Flow<VoiceSettings> = dataStore.data
|
||||
.map { prefs ->
|
||||
VoiceSettings(
|
||||
engineMode = VoiceEngineMode.fromStorage(
|
||||
prefs[KEY_ENGINE_MODE] ?: DEFAULT_ENGINE_MODE,
|
||||
).storageValue,
|
||||
audioRoute = VoiceAudioRoute.fromStorage(
|
||||
prefs[KEY_AUDIO_ROUTE] ?: DEFAULT_AUDIO_ROUTE,
|
||||
).storageValue,
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setEngineMode(mode: VoiceEngineMode) {
|
||||
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setAudioRoute(route: VoiceAudioRoute) {
|
||||
dataStore.edit { it[KEY_AUDIO_ROUTE] = route.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setInteractionMode(mode: String) {
|
||||
context.relayDataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
}
|
||||
|
||||
suspend fun setSilenceThresholdMs(ms: Long) {
|
||||
context.relayDataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setAutoTts(enabled: Boolean) {
|
||||
context.relayDataStore.edit { it[KEY_AUTO_TTS] = enabled }
|
||||
dataStore.edit { it[KEY_AUTO_TTS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setLanguage(language: String) {
|
||||
context.relayDataStore.edit { it[KEY_LANGUAGE] = language }
|
||||
dataStore.edit { it[KEY_LANGUAGE] = language }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setRealtimePersistentSession(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.diagnostics
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
enum class DiagnosticCategory(val label: String) {
|
||||
Api("API"),
|
||||
Relay("Relay"),
|
||||
Session("Session"),
|
||||
Voice("Voice"),
|
||||
Endpoint("Route"),
|
||||
Auth("Auth"),
|
||||
}
|
||||
|
||||
enum class DiagnosticSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class DiagnosticLogEntry(
|
||||
val timestampMs: Long,
|
||||
val category: DiagnosticCategory,
|
||||
val severity: DiagnosticSeverity,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
val endpointRole: String? = null,
|
||||
val url: String? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
)
|
||||
|
||||
object DiagnosticsLog {
|
||||
private const val MAX_ENTRIES = 200
|
||||
private const val MAX_TEXT_LENGTH = 180
|
||||
|
||||
private val lock = Any()
|
||||
private val _entries = MutableStateFlow<List<DiagnosticLogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<DiagnosticLogEntry>> = _entries.asStateFlow()
|
||||
|
||||
fun record(
|
||||
category: DiagnosticCategory,
|
||||
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
) {
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
category = category,
|
||||
severity = severity,
|
||||
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
|
||||
detail = clean(detail),
|
||||
endpointRole = clean(endpointRole),
|
||||
url = sanitizeUrl(url),
|
||||
elapsedMs = elapsedMs,
|
||||
)
|
||||
synchronized(lock) {
|
||||
_entries.value = (_entries.value + entry).takeLast(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
fun recent(
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 30,
|
||||
): List<DiagnosticLogEntry> {
|
||||
val source = entries.value.asReversed()
|
||||
val filtered = if (categories == null) {
|
||||
source
|
||||
} else {
|
||||
source.filter { it.category in categories }
|
||||
}
|
||||
return filtered.take(limit.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun sanitizeUrl(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val noQuery = trimmed.substringBefore('?').substringBefore('#')
|
||||
val schemeEnd = noQuery.indexOf("://")
|
||||
val noUserInfo = if (schemeEnd >= 0) {
|
||||
val prefix = noQuery.substring(0, schemeEnd + 3)
|
||||
val rest = noQuery.substring(schemeEnd + 3)
|
||||
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, slash)
|
||||
val path = rest.substring(slash)
|
||||
val safeAuthority = authority.substringAfterLast('@')
|
||||
prefix + safeAuthority + path
|
||||
} else {
|
||||
noQuery
|
||||
}
|
||||
return noUserInfo.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
private fun clean(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return trimmed
|
||||
.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,28 @@ class ChannelMultiplexer {
|
||||
// TODO: Phase 2 — terminal channel handler
|
||||
handlers["terminal"]?.onMessage(envelope)
|
||||
}
|
||||
"bridge" -> {
|
||||
// TODO: Phase 3 — bridge channel handler
|
||||
handlers["bridge"]?.onMessage(envelope)
|
||||
}
|
||||
// === PHASE3-accessibility: bridge channel routing ===
|
||||
// bridge.command envelopes come FROM the server and are
|
||||
// dispatched to a [BridgeCommandHandler] which hands them to
|
||||
// the [HermesAccessibilityService]'s [ActionExecutor]. Responses
|
||||
// (bridge.response / bridge.status) flow back through [send]
|
||||
// directly — the handler never consumes its own responses.
|
||||
//
|
||||
// This branch is intentionally symmetric with "chat" and
|
||||
// "terminal": route inbound envelopes to whatever handler is
|
||||
// registered. The handler registration itself happens in
|
||||
// [ConnectionViewModel] so the ViewModel controls whether
|
||||
// bridge routing is active (Bridge can be gated by build
|
||||
// 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
|
||||
}
|
||||
@@ -91,6 +109,28 @@ class ChannelMultiplexer {
|
||||
sendCallback?.invoke(envelope)
|
||||
}
|
||||
|
||||
// === PHASE3-notif-listener: notification outbound routing ===
|
||||
//
|
||||
// `HermesNotificationCompanion` is a system-bound
|
||||
// `NotificationListenerService` that lives outside the ViewModel
|
||||
// scope. To push posted-notification envelopes onto the WSS
|
||||
// connection, it grabs the live multiplexer reference (set by
|
||||
// `ConnectionViewModel` via the static companion `multiplexer`
|
||||
// slot on the service) and calls [sendNotification].
|
||||
//
|
||||
// This is a thin wrapper over [send] with a no-op fast path when
|
||||
// no send callback is wired yet (relay disconnected). We drop on
|
||||
// the floor at this layer rather than buffering — the service
|
||||
// owns the cold-start buffer in its `pendingEnvelopes` queue, and
|
||||
// dropping when the relay is offline matches the smartwatch
|
||||
// companion semantics (a wearable doesn't replay notifications
|
||||
// it missed while out of range either).
|
||||
fun sendNotification(envelope: Envelope) {
|
||||
val cb = sendCallback ?: return
|
||||
cb.invoke(envelope)
|
||||
}
|
||||
// === END PHASE3-notif-listener ===
|
||||
|
||||
/**
|
||||
* Handle system channel messages (auth, ping/pong).
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,7 +20,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.CertificatePinner
|
||||
@@ -59,7 +71,38 @@ class ConnectionManager(
|
||||
* Defaults to always-allow for tests and legacy call sites. Production
|
||||
* wiring passes the AuthManager gate from [ConnectionViewModel].
|
||||
*/
|
||||
private val reconnectGate: () -> Boolean = { true }
|
||||
private val reconnectGate: () -> Boolean = { true },
|
||||
/**
|
||||
* Application context used to register the [ConnectivityManager
|
||||
* .NetworkCallback] that drives ADR 24's network-aware re-resolution.
|
||||
* Nullable for legacy call sites / tests — when null, the callback is
|
||||
* never registered and the manager degrades to single-URL behavior.
|
||||
*/
|
||||
private val context: Context? = null,
|
||||
/**
|
||||
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
|
||||
* either [endpointCandidatesProvider] or a non-null [deviceIdProvider],
|
||||
* every call to [connect] first consults the resolver before opening the
|
||||
* WSS; on network changes the resolver is re-run and we hot-swap to the
|
||||
* new winner. When null the manager uses the caller-supplied URL verbatim
|
||||
* (pre-ADR-24 behavior).
|
||||
*/
|
||||
private val endpointResolver: EndpointResolver? = null,
|
||||
/**
|
||||
* Candidate supplier for the active saved connection. This is the
|
||||
* standard-Hermes route source: it works before Relay pairing, so API,
|
||||
* dashboard, voice, and future Relay calls can hand off between LAN and
|
||||
* Tailscale using the same resolver. If it returns an empty list, we fall
|
||||
* back to the legacy per-device PairingPreferences source below.
|
||||
*/
|
||||
private val endpointCandidatesProvider: (suspend () -> List<EndpointCandidate>)? = null,
|
||||
/**
|
||||
* Suspending supplier for the active device id. Used to key into
|
||||
* [PairingPreferences.getDeviceEndpoints] during resolution. `null`
|
||||
* disables multi-endpoint resolution even when [endpointResolver] is
|
||||
* non-null — the manager falls back to the single-URL path.
|
||||
*/
|
||||
private val deviceIdProvider: (suspend () -> String?)? = null,
|
||||
) {
|
||||
private val supervisorJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
|
||||
@@ -90,10 +133,19 @@ class ConnectionManager(
|
||||
@Volatile
|
||||
private var client: OkHttpClient = buildClient()
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var serverUrl: String? = null
|
||||
private var reconnectAttempt = 0
|
||||
private var shouldReconnect = true
|
||||
// Last HTTP status seen during WSS upgrade, captured in onFailure.
|
||||
// Used by scheduleReconnect() to pick an appropriate backoff — notably
|
||||
// a much longer one when the server is rate-limiting us (HTTP 429) so
|
||||
// we don't re-fill the ban bucket and brick our own auth window.
|
||||
@Volatile
|
||||
private var lastUpgradeResponseCode: Int? = null
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
@@ -106,27 +158,157 @@ class ConnectionManager(
|
||||
private val _isInsecureConnection = MutableStateFlow(false)
|
||||
val isInsecureConnection: StateFlow<Boolean> = _isInsecureConnection.asStateFlow()
|
||||
|
||||
// ADR 24 — currently-active endpoint candidate. Null when the manager is
|
||||
// running in legacy single-URL mode (no resolver wired, no candidates in
|
||||
// DataStore, or resolve() returned null and we fell back to the caller's
|
||||
// URL). Surfaced through [activeEndpoint] for the UI status chip + the
|
||||
// Endpoints card in Settings.
|
||||
private val _activeEndpoint = MutableStateFlow<EndpointCandidate?>(null)
|
||||
val activeEndpoint: StateFlow<EndpointCandidate?> = _activeEndpoint.asStateFlow()
|
||||
|
||||
/**
|
||||
* Manual role override. When non-null, the resolver's output is replaced
|
||||
* with whichever candidate in the stored list matches this role (case-
|
||||
* insensitive) — provided it's reachable. Reachability still gates: a
|
||||
* user-preferred endpoint that doesn't respond to HEAD /health falls
|
||||
* back through the normal priority chain.
|
||||
*
|
||||
* Two writers feed this: a sticky [Connection.preferredRouteRole] is
|
||||
* restored into it on connection load, and the Routes card's transient
|
||||
* "Use now" writes it directly without persisting. Cleared on
|
||||
* [disconnect] per ADR 24's "clears on disconnect" semantics.
|
||||
*
|
||||
* Exposed as [manualRoleOverrideFlow] so the Routes card can label the
|
||||
* current route as automatic / preferred / manually switched.
|
||||
*/
|
||||
private val _manualRoleOverride = MutableStateFlow<String?>(null)
|
||||
val manualRoleOverrideFlow: StateFlow<String?> = _manualRoleOverride.asStateFlow()
|
||||
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/**
|
||||
* Debounce job for network-change re-resolution. Android fires one
|
||||
* onAvailable per satisfying network (Wi-Fi + cell + VPN can land within
|
||||
* milliseconds of each other, and registration itself replays every
|
||||
* current network), so each event cancels the previous pending resolve
|
||||
* and the last one wins after a short settle window.
|
||||
*/
|
||||
@Volatile
|
||||
private var networkResolveJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
// Register at construction, not on first connect(). Standard
|
||||
// (no-Relay) connections never open the WSS socket, but their HTTP
|
||||
// surfaces (chat, dashboard, voice) still need [activeEndpoint] to
|
||||
// follow LAN/Tailscale handoffs — leaving registration inside
|
||||
// connect() left the whole ADR 24 network-aware path dormant for
|
||||
// exactly those users. No-op when [context] is null (tests).
|
||||
ensureNetworkCallbackRegistered()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
// Settle window before re-resolving after a network event. Long
|
||||
// enough to coalesce the onAvailable burst of a handoff, short
|
||||
// enough that a route swap still feels immediate.
|
||||
private const val NETWORK_RESOLVE_DEBOUNCE_MS = 300L
|
||||
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
|
||||
// on the WSS upgrade, we're IP-banned server-side — retrying at
|
||||
// our normal 1-30s cadence re-fills the ban bucket and keeps us
|
||||
// banned forever. Waiting at least as long as the server's block
|
||||
// duration lets the ban expire naturally.
|
||||
private const val RATE_LIMIT_BACKOFF_MS = 300_000L
|
||||
}
|
||||
|
||||
fun setInsecureMode(enabled: Boolean) {
|
||||
if (_insecureMode.value == enabled) {
|
||||
return
|
||||
}
|
||||
_insecureMode.value = enabled
|
||||
if (enabled) {
|
||||
Log.w(TAG, "⚠ INSECURE MODE ENABLED — ws:// connections allowed. Do NOT use in production.")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Insecure relay mode enabled",
|
||||
detail = "ws:// connections are allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(url: String) {
|
||||
// Register the network callback on the first connect attempt. We
|
||||
// only do this once per manager lifetime; [shutdown] tears it down.
|
||||
ensureNetworkCallbackRegistered()
|
||||
|
||||
// ADR 24: if we have a resolver + device id, try the multi-endpoint
|
||||
// path first. Fall back to the caller-supplied URL whenever the
|
||||
// resolver returns nothing — preserving pre-ADR-24 single-URL
|
||||
// behavior for freshly-upgraded installs and for v1/v2 QRs where
|
||||
// the synthesized list just collapses to the same URL anyway.
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: url
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
|
||||
"relay=${resolved.relay.url} (fallback would have been $url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay route selected",
|
||||
endpointRole = resolved.role,
|
||||
url = resolved.relay.url,
|
||||
)
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
Log.d(TAG, "connect: no resolver winner — using supplied url $url")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Using configured relay URL",
|
||||
detail = "No resolver winner",
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
* reopen the socket against that URL. Keeping this separate prevents
|
||||
* the callback from re-running the resolve loop inside another
|
||||
* resolve loop.
|
||||
*/
|
||||
private fun connectToUrlOnMainPath(
|
||||
url: String,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
val isInsecure = url.startsWith("ws://") && !url.startsWith("wss://")
|
||||
if (isInsecure && !_insecureMode.value) {
|
||||
Log.e(TAG, "Blocked ws:// connection — insecure mode is disabled. Use wss:// or enable insecure mode in Settings.")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket blocked",
|
||||
detail = "ws:// is disabled",
|
||||
url = url,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
Log.e(TAG, "Invalid URL scheme — must start with ws:// or wss://")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket URL invalid",
|
||||
detail = "URL must start with ws:// or wss://",
|
||||
url = url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,16 +317,293 @@ class ConnectionManager(
|
||||
// hits the HTTP root and comes back as 404 Not Found during the
|
||||
// upgrade handshake. We still accept an explicit path if present.
|
||||
val normalized = normalizeRelayUrl(url)
|
||||
val existingState = _connectionState.value
|
||||
if (serverUrl == normalized &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
) {
|
||||
Log.i(TAG, "connect: already ${existingState.name.lowercase()} to $normalized — skipping duplicate open")
|
||||
return
|
||||
}
|
||||
val previousSocket = webSocket
|
||||
|
||||
_isInsecureConnection.value = isInsecure
|
||||
if (isInsecure) {
|
||||
Log.w(TAG, "⚠ Connecting over INSECURE ws:// to: $normalized")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Opening insecure relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
} else {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Opening relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
}
|
||||
|
||||
serverUrl = normalized
|
||||
shouldReconnect = true
|
||||
reconnectAttempt = 0
|
||||
doConnect(normalized)
|
||||
doConnect(normalized, previousSocket, replaceReason)
|
||||
}
|
||||
|
||||
// ----- ADR 24 — multi-endpoint resolution --------------------------------
|
||||
|
||||
/**
|
||||
* Load the device's stored [EndpointCandidate] list and hand it to
|
||||
* [EndpointResolver.resolve]. Returns `null` when any precondition is
|
||||
* missing (no resolver wired, no context, no device id, empty list) OR
|
||||
* when no candidate was reachable — caller then falls back to the
|
||||
* legacy single-URL path.
|
||||
*
|
||||
* Wraps the DataStore read in a 1-second timeout; if DataStore stalls
|
||||
* for any reason we don't block the connect loop forever.
|
||||
*/
|
||||
suspend fun resolveBestEndpoint(): EndpointCandidate? = resolveBestEndpointSafe()
|
||||
|
||||
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
|
||||
val resolver = endpointResolver ?: return null
|
||||
val ctx = context ?: return null
|
||||
|
||||
val endpoints = try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
endpointCandidatesProvider?.invoke()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: run {
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
if (endpoints.isEmpty()) return null
|
||||
|
||||
// Manual override: if the user pinned a role in the Endpoints card,
|
||||
// try that one first; fall through to the strict-priority algorithm
|
||||
// if it isn't reachable.
|
||||
_manualRoleOverride.value?.let { preferredRole ->
|
||||
val preferred = endpoints.firstOrNull {
|
||||
it.role.equals(preferredRole, ignoreCase = true)
|
||||
}
|
||||
if (preferred != null) {
|
||||
// Single-element list still respects the 2s probe gate.
|
||||
val winner = resolver.resolve(listOf(preferred))
|
||||
if (winner != null) return winner
|
||||
Log.i(TAG, "manualRoleOverride=$preferredRole not reachable — " +
|
||||
"falling through to strict-priority resolve")
|
||||
}
|
||||
}
|
||||
|
||||
return resolver.resolve(endpoints)
|
||||
}
|
||||
|
||||
/**
|
||||
* User-triggered re-probe. Forces a fresh resolve + reconnect regardless
|
||||
* of cache state. Backs the "Probe now" row action in the Endpoints card.
|
||||
* Fire-and-forget wrapper around [probeAndReconnectNow] for callers that
|
||||
* don't need the outcome.
|
||||
*/
|
||||
fun probeAndReconnect() {
|
||||
scope.launch { probeAndReconnectNow() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaitable body of [probeAndReconnect]. Returns the resolved winner —
|
||||
* or null when no candidate answered — so callers (probe-status UI) can
|
||||
* report the outcome instead of guessing with a fixed delay.
|
||||
*
|
||||
* Unlike the pre-2026-06 version this ALWAYS publishes the resolve
|
||||
* outcome to [activeEndpoint]: a standard (no relay socket) connection
|
||||
* whose probes all failed used to early-return before publishing,
|
||||
* leaving the Routes card stuck on "Resolving" with no feedback. The
|
||||
* only exception is the live-socket transient-miss guard shared with
|
||||
* [refreshActiveEndpoint].
|
||||
*/
|
||||
suspend fun probeAndReconnectNow(): EndpointCandidate? {
|
||||
endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// — keep the live route published rather than downgrading every
|
||||
// HTTP surface to the saved URL. Mirrors refreshActiveEndpoint.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run endpoint resolution and publish the winner without forcing a
|
||||
* WSS reconnect. Used by HTTP-only surfaces (chat/voice/relay HTTP)
|
||||
* so they can follow LAN/Tailscale/VPN route changes even when the relay
|
||||
* socket is currently disconnected or intentionally not paired.
|
||||
*
|
||||
* @param clearProbeCache wipe the resolver's probe cache first. Pass
|
||||
* `true` from "the world may have changed" triggers (app resume,
|
||||
* network change) — otherwise a route that died within the positive
|
||||
* cache TTL (60s) can still be returned as the winner.
|
||||
*/
|
||||
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
|
||||
if (clearProbeCache) endpointResolver?.clearCache()
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// (slow resume, mid-handoff blip) — keep publishing the live
|
||||
// route instead of downgrading every HTTP surface to the saved
|
||||
// URL. Mirrors scheduleNetworkReResolve's guard.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a specific role as the preferred endpoint. Cleared on [disconnect]
|
||||
* per the Endpoints-card contract. No-op until the next connect / probe
|
||||
* cycle — call [probeAndReconnect] to apply immediately.
|
||||
*/
|
||||
fun setManualRoleOverride(role: String?) {
|
||||
_manualRoleOverride.value = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${_manualRoleOverride.value ?: "(cleared)"}")
|
||||
}
|
||||
|
||||
fun getManualRoleOverride(): String? = _manualRoleOverride.value
|
||||
|
||||
private fun markActiveEndpointUnreachable(reason: String) {
|
||||
val active = _activeEndpoint.value ?: return
|
||||
endpointResolver?.markUnreachable(active)
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced network-change re-resolution, shared by both NetworkCallback
|
||||
* events. Re-runs the resolver and publishes the winner to
|
||||
* [activeEndpoint] so HTTP-only surfaces (chat, dashboard, standard
|
||||
* voice) follow the route change even when no relay socket exists. When
|
||||
* a socket IS up, additionally swaps it to a differing winner, or
|
||||
* reconnects a disconnected socket on the same winner — preserving the
|
||||
* pre-refactor relay-path behavior.
|
||||
*/
|
||||
private fun scheduleNetworkReResolve(closeReason: String) {
|
||||
if (endpointResolver == null) return
|
||||
networkResolveJob?.cancel()
|
||||
networkResolveJob = scope.launch {
|
||||
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null) {
|
||||
// Don't clear a live socket's endpoint on a transient probe
|
||||
// miss — only drop the published route when nothing is
|
||||
// actually connected.
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
if (current == null) return@launch
|
||||
// After an explicit disconnect() the route still publishes above
|
||||
// (HTTP surfaces keep roaming), but no socket action: without
|
||||
// this gate a network event whose winner differs from the last
|
||||
// URL would resurrect a socket the user deliberately closed.
|
||||
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||
// the swap path never re-checked it.)
|
||||
if (!shouldReconnect) return@launch
|
||||
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "network change: same winner is disconnected — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureNetworkCallbackRegistered() {
|
||||
val ctx = context ?: return
|
||||
if (networkCallback != null) return
|
||||
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
|
||||
endpointResolver?.clearCache()
|
||||
scheduleNetworkReResolve("Network change — switching endpoint")
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
}
|
||||
}
|
||||
try {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
networkCallback = callback
|
||||
Log.i(TAG, "registered NetworkCallback for ADR 24 re-resolution")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "registerNetworkCallback failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
val ctx = context ?: return
|
||||
val cb = networkCallback ?: return
|
||||
try {
|
||||
val cm = ctx.getSystemService(ConnectivityManager::class.java)
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "unregisterNetworkCallback failed: ${e.message}")
|
||||
} finally {
|
||||
networkCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeRelayUrl(url: String): String {
|
||||
@@ -165,14 +624,27 @@ class ConnectionManager(
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket disconnect requested",
|
||||
url = serverUrl,
|
||||
)
|
||||
webSocket?.close(1000, "Client disconnect")
|
||||
webSocket = null
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_isInsecureConnection.value = false
|
||||
// ADR 24: clear manual override on explicit disconnect — a "Use
|
||||
// now" switch lasts until the user disconnects, then resets to
|
||||
// resolver-picked. A sticky preferredRouteRole is re-installed by
|
||||
// the ViewModel on the next connection load.
|
||||
_manualRoleOverride.value = null
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
disconnect()
|
||||
unregisterNetworkCallback()
|
||||
supervisorJob.cancel()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
@@ -183,17 +655,38 @@ class ConnectionManager(
|
||||
webSocket?.send(text)
|
||||
}
|
||||
|
||||
private fun doConnect(url: String) {
|
||||
private fun isActiveSocket(socket: WebSocket): Boolean = webSocket === socket
|
||||
|
||||
private fun doConnect(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
val existingState = _connectionState.value
|
||||
if (previousSocketToClose == null &&
|
||||
serverUrl == url &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
) {
|
||||
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
|
||||
return
|
||||
}
|
||||
|
||||
_connectionState.value = if (reconnectAttempt > 0) {
|
||||
ConnectionState.Reconnecting
|
||||
} else {
|
||||
ConnectionState.Connecting
|
||||
}
|
||||
|
||||
scope.launch { doConnectInternal(url) }
|
||||
scope.launch { doConnectInternal(url, previousSocketToClose, replaceReason) }
|
||||
}
|
||||
|
||||
private fun doConnectInternal(url: String) {
|
||||
private fun doConnectInternal(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
// Rebuild the client so the CertificatePinner picks up the current
|
||||
// pin store snapshot — crucial right after applyServerIssuedCodeAndReset
|
||||
// wipes a pin for re-pair. buildClient() does a tiny DataStore read
|
||||
@@ -204,10 +697,25 @@ class ConnectionManager(
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
Log.i(TAG, "doConnect: opening WSS to $url")
|
||||
val newSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onOpen: stale WSS handshake ignored ($url)")
|
||||
runCatching { webSocket.close(1000, "Stale relay socket") }
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
lastUpgradeResponseCode = null
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket connected",
|
||||
url = url,
|
||||
)
|
||||
|
||||
// TOFU: record the peer cert fingerprint if we don't have one
|
||||
// yet. OkHttp populates response.handshake when the connection
|
||||
@@ -230,6 +738,10 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onMessage: stale WSS envelope ignored ($url)")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val envelope = json.decodeFromString<Envelope>(text)
|
||||
multiplexer.route(envelope)
|
||||
@@ -239,20 +751,60 @@ 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) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onClosed: stale WSS close ignored ($url code=$code reason=$reason)")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "onClosed: code=$code reason=$reason")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay socket closed",
|
||||
detail = "code=$code reason=$reason",
|
||||
url = url,
|
||||
)
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onFailure: stale WSS failure ignored ($url ${t.javaClass.simpleName}: ${t.message})")
|
||||
return
|
||||
}
|
||||
val code = response?.code
|
||||
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=$code)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket failed",
|
||||
detail = listOfNotNull(
|
||||
t.javaClass.simpleName,
|
||||
t.message,
|
||||
code?.let { "HTTP $it" },
|
||||
).joinToString(": "),
|
||||
url = url,
|
||||
)
|
||||
lastUpgradeResponseCode = code
|
||||
if (response == null) {
|
||||
markActiveEndpointUnreachable("socket failure")
|
||||
}
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
t.printStackTrace()
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
webSocket = newSocket
|
||||
previousSocketToClose
|
||||
?.takeIf { it !== newSocket }
|
||||
?.let { staleSocket ->
|
||||
runCatching { staleSocket.close(1000, replaceReason) }
|
||||
staleSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
@@ -265,6 +817,13 @@ class ConnectionManager(
|
||||
// the rate limiter and block ourselves.
|
||||
if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate says no pair context — aborting retry")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Session,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect skipped",
|
||||
detail = "No paired session or pending pair code",
|
||||
url = serverUrl,
|
||||
)
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
return
|
||||
}
|
||||
@@ -272,8 +831,33 @@ class ConnectionManager(
|
||||
val url = serverUrl ?: return
|
||||
reconnectAttempt++
|
||||
|
||||
val backoffMs = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
// Server-issued 429 means we're IP-banned — keep retrying at our
|
||||
// normal exponential cadence and we'll re-fill the ban bucket on
|
||||
// every attempt, extending the ban indefinitely. Wait out the
|
||||
// server's full block window instead.
|
||||
val backoffMs = if (lastUpgradeResponseCode == 429) {
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect delayed",
|
||||
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
RATE_LIMIT_BACKOFF_MS
|
||||
} else {
|
||||
(BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
if (lastUpgradeResponseCode != 429) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay reconnect scheduled",
|
||||
detail = "Retrying in ${backoffMs / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
delay(backoffMs)
|
||||
@@ -281,7 +865,19 @@ class ConnectionManager(
|
||||
// expires, auth state may have changed (e.g., user hit Revoke
|
||||
// during the retry window).
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
doConnect(url)
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
if (targetUrl != null && normalizeRelayUrl(targetUrl) != url) {
|
||||
Log.i(TAG, "scheduleReconnect: switching $url → ${normalizeRelayUrl(targetUrl)}")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else {
|
||||
doConnect(url)
|
||||
}
|
||||
} else if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate turned false during backoff — aborting retry")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
|
||||
@@ -19,32 +19,69 @@ class ConnectivityObserver(private val context: Context) {
|
||||
|
||||
fun observe(): Flow<Status> = callbackFlow {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
if (connectivityManager == null) {
|
||||
trySend(Status.Unavailable)
|
||||
awaitClose { }
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun hasAnyInternetNetwork(): Boolean =
|
||||
connectivityManager.allNetworks.any { network ->
|
||||
hasInternetCapability(connectivityManager.getNetworkCapabilities(network))
|
||||
}
|
||||
|
||||
fun sendCurrentStatus(fallbackWhenNone: Status) {
|
||||
trySend(statusForInternetAvailability(hasAnyInternetNetwork(), fallbackWhenNone))
|
||||
}
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(Status.Available)
|
||||
sendCurrentStatus(Status.Available)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
) {
|
||||
sendCurrentStatus(
|
||||
if (hasInternetCapability(networkCapabilities)) {
|
||||
Status.Available
|
||||
} else {
|
||||
Status.Lost
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(Status.Lost)
|
||||
sendCurrentStatus(Status.Lost)
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
trySend(Status.Unavailable)
|
||||
sendCurrentStatus(Status.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
// Emit current state
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
val isConnected = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
trySend(if (isConnected) Status.Available else Status.Unavailable)
|
||||
sendCurrentStatus(Status.Unavailable)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hasInternetCapability(caps: NetworkCapabilities?): Boolean =
|
||||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
|
||||
internal fun statusForInternetAvailability(
|
||||
hasAnyInternetNetwork: Boolean,
|
||||
fallbackWhenNone: ConnectivityObserver.Status,
|
||||
): ConnectivityObserver.Status =
|
||||
if (hasAnyInternetNetwork) ConnectivityObserver.Status.Available else fallbackWhenNone
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.KeystoreTokenStore
|
||||
import com.hermesandroid.relay.auth.LegacyEncryptedPrefsTokenStore
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// Status/session/provider snapshots are @Serializable so the Manage tab's
|
||||
// disk cache (DashboardManageDiskCache) can persist Loaded entries verbatim.
|
||||
@Serializable
|
||||
data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardAuthProvider(
|
||||
val name: String,
|
||||
val displayName: String? = null,
|
||||
val supportsPassword: Boolean = false,
|
||||
) {
|
||||
val isRedirectProvider: Boolean
|
||||
get() = !supportsPassword
|
||||
}
|
||||
|
||||
data class DashboardLoginResponse(
|
||||
val ok: Boolean,
|
||||
val next: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardAuthSession(
|
||||
val authenticated: Boolean,
|
||||
val username: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardWsTicket(
|
||||
val ticket: String,
|
||||
val ttlSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Native client for the Hermes dashboard/admin server (:9119).
|
||||
*
|
||||
* This is deliberately separate from [HermesApiClient] and all relay pairing
|
||||
* clients. Dashboard cookies authenticate standard admin surfaces such as
|
||||
* skills/cron/MCP/profile config; relay pairing remains the auth path for
|
||||
* terminal, bridge, media relay, and profile memory file editing.
|
||||
*/
|
||||
class DashboardApiClient(
|
||||
baseUrl: String,
|
||||
private val okHttpClient: OkHttpClient = defaultClient(),
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
},
|
||||
) {
|
||||
private val baseUrl: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
suspend fun getStatus(): Result<DashboardStatus> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/status").mapCatching { parseStatus(it) }
|
||||
}
|
||||
|
||||
suspend fun getAuthProviders(): Result<List<DashboardAuthProvider>> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/auth/providers").mapCatching { root ->
|
||||
parseProviders(root["providers"])
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
getJson(normalized)
|
||||
}
|
||||
|
||||
suspend fun getJsonElement(path: String): Result<JsonElement> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.get()
|
||||
.build()
|
||||
executeJsonElement(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun postJsonObject(
|
||||
path: String,
|
||||
payload: JsonObject = JsonObject(emptyMap()),
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun putJsonObject(
|
||||
path: String,
|
||||
payload: JsonObject,
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.put(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun deleteJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.delete()
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
/** DELETE with a JSON body — upstream's `DELETE /api/env` reads the key from the body. */
|
||||
suspend fun deleteJsonObjectWithBody(
|
||||
path: String,
|
||||
payload: JsonObject,
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.delete(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
// --- Models (dashboard parity with hermes-desktop Settings → Model) ---
|
||||
|
||||
/** Full provider/model universe — REST twin of the TUI's `model.options` RPC. */
|
||||
suspend fun getModelOptions(): Result<JsonObject> = getJsonObject("/api/model/options")
|
||||
|
||||
/**
|
||||
* Assign the main model in `~/.hermes/config.yaml` (new sessions only).
|
||||
* Upstream may answer `{ok: false, confirm_required: true, warning: ...}`
|
||||
* for expensive models — re-call with [confirmExpensive] after the user
|
||||
* accepts the warning.
|
||||
*/
|
||||
suspend fun setMainModel(
|
||||
provider: String,
|
||||
model: String,
|
||||
confirmExpensive: Boolean = false,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/model/set",
|
||||
payload = buildJsonObject {
|
||||
put("scope", "main")
|
||||
put("provider", provider)
|
||||
put("model", model)
|
||||
if (confirmExpensive) put("confirm_expensive_model", true)
|
||||
},
|
||||
)
|
||||
|
||||
// --- Env / keys (dashboard parity with hermes-desktop Settings → Keys) ---
|
||||
|
||||
/** Curated env-var inventory: name → {is_set, redacted_value, description, category, ...}. */
|
||||
suspend fun getEnvVars(): Result<JsonObject> = getJsonObject("/api/env")
|
||||
|
||||
suspend fun setEnvVar(key: String, value: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/env",
|
||||
payload = buildJsonObject {
|
||||
put("key", key)
|
||||
put("value", value)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun deleteEnvVar(key: String): Result<JsonObject> =
|
||||
deleteJsonObjectWithBody(
|
||||
path = "/api/env",
|
||||
payload = buildJsonObject { put("key", key) },
|
||||
)
|
||||
|
||||
/** Server rate-limits reveals (5 per 30s) and audit-logs each one. */
|
||||
suspend fun revealEnvVar(key: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/env/reveal",
|
||||
payload = buildJsonObject { put("key", key) },
|
||||
)
|
||||
|
||||
// --- Skills hub (dashboard parity with hermes-desktop Browse-hub tab) ---
|
||||
|
||||
/**
|
||||
* Parallel multi-source hub search. Response carries `results` (name /
|
||||
* description / source / identifier / trust_level / repo / tags),
|
||||
* `source_counts`, `timed_out`, and `installed` (identifier → lock entry)
|
||||
* so already-installed results can be marked. Server caps limit at 50 and
|
||||
* fans out with a 30s overall timeout — keep client read timeouts above that.
|
||||
*/
|
||||
suspend fun searchSkillsHub(query: String, limit: Int = 20): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/search?q=${queryValue(query)}&limit=${limit.coerceIn(1, 50)}")
|
||||
|
||||
/** SKILL.md + manifest for an identifier WITHOUT installing — read before you trust. */
|
||||
suspend fun previewSkillsHub(identifier: String): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/preview?identifier=${queryValue(identifier)}")
|
||||
|
||||
/**
|
||||
* Configured hub sources + featured skills (`{sources, index_available,
|
||||
* featured, installed}`) — content for the browse dialog before the first
|
||||
* search. Featured entries share the search-result payload shape.
|
||||
*/
|
||||
suspend fun getSkillsHubSources(): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/sources")
|
||||
|
||||
/**
|
||||
* Spawns `hermes skills install <identifier>` server-side and returns
|
||||
* `{ok, pid}` immediately — the install completes in the background, so
|
||||
* callers should message "started" and refresh the skills list later.
|
||||
*/
|
||||
suspend fun installSkillsHub(identifier: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/skills/hub/install",
|
||||
payload = buildJsonObject { put("identifier", identifier) },
|
||||
)
|
||||
|
||||
/** Async spawn like install; takes the installed skill *name*, not the hub identifier. */
|
||||
suspend fun uninstallSkillsHub(name: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/skills/hub/uninstall",
|
||||
payload = buildJsonObject { put("name", name) },
|
||||
)
|
||||
|
||||
/** Async spawn of `hermes skills update` for all hub-installed skills. */
|
||||
suspend fun updateSkillsHub(): Result<JsonObject> =
|
||||
postJsonObject("/api/skills/hub/update")
|
||||
|
||||
// --- Profiles (write surface) ---
|
||||
|
||||
/** Full SOUL.md text — upstream returns the complete file, safe for round-trip editing. */
|
||||
suspend fun putProfileSoul(name: String, content: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/soul",
|
||||
payload = buildJsonObject { put("content", content) },
|
||||
)
|
||||
|
||||
suspend fun createProfile(
|
||||
name: String,
|
||||
cloneFromDefault: Boolean = true,
|
||||
description: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/profiles",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put("clone_from_default", cloneFromDefault)
|
||||
if (!description.isNullOrBlank()) put("description", description)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun setProfileDescription(name: String, description: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/description",
|
||||
payload = buildJsonObject { put("description", description) },
|
||||
)
|
||||
|
||||
suspend fun setProfileModel(
|
||||
name: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/model",
|
||||
payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("model", model)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun toggleSkill(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/skills/toggle",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put("enabled", enabled)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun pauseCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/pause${profileQuery(profile)}")
|
||||
|
||||
suspend fun resumeCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/resume${profileQuery(profile)}")
|
||||
|
||||
suspend fun triggerCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/trigger${profileQuery(profile)}")
|
||||
|
||||
suspend fun getCronJobRuns(
|
||||
jobId: String,
|
||||
profile: String? = null,
|
||||
limit: Int = 20,
|
||||
): Result<JsonObject> =
|
||||
getJsonObject("/api/cron/jobs/${pathSegment(jobId)}/runs${profileLimitQuery(profile, limit)}")
|
||||
|
||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
||||
payload = buildJsonObject { put("enabled", enabled) },
|
||||
)
|
||||
|
||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
||||
|
||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
name: String,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
enable: Boolean = true,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/mcp/catalog/install",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put(
|
||||
"env",
|
||||
buildJsonObject {
|
||||
env.forEach { (key, value) -> put(key, value) }
|
||||
},
|
||||
)
|
||||
put("enable", enable)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun setActiveProfile(name: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/profiles/active",
|
||||
payload = buildJsonObject { put("name", name) },
|
||||
)
|
||||
|
||||
suspend fun getProfileSoul(name: String): Result<JsonObject> =
|
||||
getJsonObject("/api/profiles/${pathSegment(name)}/soul")
|
||||
|
||||
suspend fun deleteProfile(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/profiles/${pathSegment(name)}")
|
||||
|
||||
suspend fun loginPassword(
|
||||
provider: String = "basic",
|
||||
username: String,
|
||||
password: String,
|
||||
next: String = "/",
|
||||
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
|
||||
val payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("next", next)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/auth/password-login")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
executeJson(request, "Dashboard sign-in").mapCatching { root ->
|
||||
DashboardLoginResponse(
|
||||
ok = root.booleanField("ok") ?: true,
|
||||
next = root.stringField("next"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentSession(): Result<DashboardAuthSession> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/auth/me")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return@withContext Result.success(DashboardAuthSession(authenticated = false))
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "Dashboard session"))
|
||||
}
|
||||
val root = response.readJsonObject(json)
|
||||
Result.success(parseAuthSession(root))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this dashboard build exposes the hermes-desktop voice routes
|
||||
* (`/api/audio/transcribe` + `/api/audio/speak`). HEAD on a POST-only
|
||||
* FastAPI route returns 405 when the path exists and 404 when it doesn't;
|
||||
* an auth-gated 401/403 also proves the route is registered.
|
||||
*/
|
||||
suspend fun audioRoutesPresent(): Boolean = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/transcribe")
|
||||
.head()
|
||||
.build()
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { it.code != 404 }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestWsTicket(): Result<DashboardWsTicket> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/auth/ws-ticket")
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
|
||||
executeJson(request, "Dashboard websocket ticket").mapCatching { root ->
|
||||
val ticket = root.stringField("ticket")
|
||||
?: root.stringField("ws_ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
DashboardWsTicket(
|
||||
ticket = ticket,
|
||||
ttlSeconds = root.intField("ttl_seconds") ?: root.intField("ttl"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun authLoginUrl(provider: String, next: String = "/"): String =
|
||||
authLoginUrl(baseUrl = baseUrl, provider = provider, next = next)
|
||||
|
||||
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
|
||||
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
|
||||
|
||||
fun shutdown() {
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
okHttpClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
private suspend fun getJson(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$path")
|
||||
.get()
|
||||
.build()
|
||||
executeJson(request, path)
|
||||
}
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
Result.success(response.readJsonObject(json))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeJsonElement(request: Request, operation: String): Result<JsonElement> {
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
Result.success(response.readJsonElement(json))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
fun pathSegment(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
private fun queryValue(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
fun authLoginUrl(baseUrl: String, provider: String, next: String = "/"): String {
|
||||
val root = baseUrl.trim().trimEnd('/')
|
||||
return "$root/auth/login?provider=${queryValue(provider)}&next=${queryValue(next)}"
|
||||
}
|
||||
|
||||
fun authLandingPath(baseUrl: String): String {
|
||||
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return "/"
|
||||
val basePath = httpUrl.encodedPath.trimEnd('/')
|
||||
return when {
|
||||
basePath.isBlank() || basePath == "/" -> "/"
|
||||
else -> "$basePath/"
|
||||
}
|
||||
}
|
||||
|
||||
fun gatewayWebSocketUrl(baseUrl: String, ticket: String, path: String = "/api/ws"): String? {
|
||||
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return null
|
||||
val websocketPrefix = when (httpUrl.scheme) {
|
||||
"https" -> "wss://"
|
||||
"http" -> "ws://"
|
||||
else -> return null
|
||||
}
|
||||
val normalizedPath = if (path.startsWith("/")) path else "/$path"
|
||||
val basePath = httpUrl.encodedPath.trimEnd('/')
|
||||
val encodedPath = when {
|
||||
basePath.isBlank() || basePath == "/" -> normalizedPath
|
||||
else -> "$basePath$normalizedPath"
|
||||
}
|
||||
val url = httpUrl.newBuilder()
|
||||
.encodedPath(encodedPath)
|
||||
.addQueryParameter("ticket", ticket)
|
||||
.build()
|
||||
.toString()
|
||||
return websocketPrefix + url.substringAfter("://")
|
||||
}
|
||||
|
||||
private fun profileQuery(profile: String?): String {
|
||||
val trimmed = profile?.trim().orEmpty()
|
||||
return if (trimmed.isBlank()) "" else "?profile=${pathSegment(trimmed)}"
|
||||
}
|
||||
|
||||
private fun profileLimitQuery(profile: String?, limit: Int): String {
|
||||
val params = buildList {
|
||||
val trimmed = profile?.trim().orEmpty()
|
||||
if (trimmed.isNotBlank()) add("profile=${pathSegment(trimmed)}")
|
||||
add("limit=${limit.coerceIn(1, 100)}")
|
||||
}
|
||||
return params.joinToString(prefix = "?", separator = "&")
|
||||
}
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
val providersElement = root["auth_providers"]
|
||||
?: root["providers"]
|
||||
?: authObject?.get("providers")
|
||||
val providers = parseProviders(providersElement)
|
||||
return DashboardStatus(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAuthSession(root: JsonObject): DashboardAuthSession {
|
||||
val user = root["user"] as? JsonObject
|
||||
val session = root["session"] as? JsonObject
|
||||
val explicitAuthenticated = root.booleanField("authenticated")
|
||||
?: root.booleanField("ok")
|
||||
val flatIdentityPresent =
|
||||
root.stringField("user_id") != null ||
|
||||
root.stringField("email") != null ||
|
||||
root.stringField("display_name") != null ||
|
||||
root.stringField("provider") != null ||
|
||||
root["expires_at"] != null
|
||||
val authenticated = explicitAuthenticated
|
||||
?: (user != null || session != null || flatIdentityPresent)
|
||||
return DashboardAuthSession(
|
||||
authenticated = authenticated,
|
||||
username = root.stringField("username")
|
||||
?: root.stringField("display_name")
|
||||
?: root.stringField("email")
|
||||
?: root.stringField("user_id")
|
||||
?: user.stringField("username")
|
||||
?: user.stringField("name")
|
||||
?: session.stringField("username"),
|
||||
provider = root.stringField("provider")
|
||||
?: session.stringField("provider")
|
||||
?: user.stringField("provider"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseProviders(element: JsonElement?): List<DashboardAuthProvider> {
|
||||
return when (element) {
|
||||
is JsonArray -> element.mapNotNull { provider(it) }
|
||||
is JsonObject -> element.entries.mapNotNull { (key, value) ->
|
||||
val name = key.trim().takeIf { it.isNotBlank() }
|
||||
if (name != null && value is JsonObject) {
|
||||
provider(name, value)
|
||||
} else {
|
||||
provider(value) ?: name?.let {
|
||||
DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}.distinctBy { it.name }
|
||||
}
|
||||
|
||||
private fun provider(element: JsonElement?): DashboardAuthProvider? {
|
||||
return when (element) {
|
||||
is JsonPrimitive -> element.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it)) }
|
||||
is JsonObject -> {
|
||||
val name = element.stringField("id")
|
||||
?: element.stringField("name")
|
||||
?: element.stringField("provider")
|
||||
?: element.stringField("type")
|
||||
name?.let { provider(it, element) }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun provider(name: String, element: JsonObject): DashboardAuthProvider =
|
||||
DashboardAuthProvider(
|
||||
name = name,
|
||||
displayName = element.stringField("display_name")
|
||||
?: element.stringField("label")
|
||||
?: element.stringField("title"),
|
||||
supportsPassword = element.booleanField("supports_password")
|
||||
?: isPasswordProvider(name),
|
||||
)
|
||||
|
||||
private fun isPasswordProvider(name: String): Boolean =
|
||||
name.equals("basic", ignoreCase = true) ||
|
||||
name.equals("password", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
class InMemoryDashboardCookieStore : DashboardCookieStore {
|
||||
private val lock = Any()
|
||||
private var cookies: List<StoredDashboardCookie> = emptyList()
|
||||
|
||||
override fun load(): List<StoredDashboardCookie> = synchronized(lock) { cookies }
|
||||
|
||||
override fun save(cookies: List<StoredDashboardCookie>) {
|
||||
synchronized(lock) {
|
||||
this.cookies = cookies
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
synchronized(lock) {
|
||||
cookies = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EncryptedDashboardCookieStore(
|
||||
context: Context,
|
||||
connectionId: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : DashboardCookieStore {
|
||||
private val serializer = ListSerializer(StoredDashboardCookie.serializer())
|
||||
private val appContext = context.applicationContext
|
||||
private val prefsName = prefsName(connectionId)
|
||||
|
||||
// DEFERRED on purpose. Building the Keystore-backed prefs takes 1-4s
|
||||
// on StrongBox devices and serializes through a process-GLOBAL Tink
|
||||
// lock (AndroidKeysetManager.Builder.build) — eager construction here
|
||||
// froze the main thread for ~11s at app start when several stores were
|
||||
// built concurrently (frozen-sphere incident, 2026-06-11). Construction
|
||||
// is now free on any thread; the expensive build happens on the first
|
||||
// actual cookie access, which is always an OkHttp/IO thread.
|
||||
private val store: SessionTokenStore by lazy {
|
||||
KeystoreTokenStore.tryCreate(appContext, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, prefsName)
|
||||
}
|
||||
|
||||
override fun load(): List<StoredDashboardCookie> {
|
||||
val raw = store.getString(KEY_COOKIES) ?: return emptyList()
|
||||
return runCatching { json.decodeFromString(serializer, raw) }
|
||||
.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
override fun save(cookies: List<StoredDashboardCookie>) {
|
||||
store.putString(KEY_COOKIES, json.encodeToString(serializer, cookies))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(KEY_COOKIES)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_COOKIES = "dashboard_cookies_json"
|
||||
|
||||
fun prefsName(connectionId: String): String =
|
||||
"hermes_dashboard_${connectionId.take(8)}"
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardCookieJar(
|
||||
private val store: DashboardCookieStore,
|
||||
private val clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
) : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val now = clockMillis()
|
||||
val incoming = cookies.map { StoredDashboardCookie.fromCookie(it) }
|
||||
.filterNot { it.isExpired(now) }
|
||||
val retained = store.load()
|
||||
.filterNot { it.isExpired(now) }
|
||||
.filterNot { old -> incoming.any { it.key == old.key } }
|
||||
store.save(retained + incoming)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val now = clockMillis()
|
||||
val stored = store.load().filterNot { it.isExpired(now) }
|
||||
if (stored.size != store.load().size) {
|
||||
store.save(stored)
|
||||
}
|
||||
return stored.mapNotNull { it.toCookie() }
|
||||
.filter { it.matches(url) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that resolves the backing per-connection store at request time.
|
||||
*
|
||||
* Long-lived OkHttpClients (e.g. the standard voice client, remembered once
|
||||
* per process in RelayApp) can't bind a fixed [DashboardCookieStore] because
|
||||
* the active Connection — and therefore the encrypted cookie file — changes
|
||||
* when the user switches connections. A null store (no active connection)
|
||||
* degrades to an empty jar rather than failing the request.
|
||||
*/
|
||||
class DynamicDashboardCookieJar(
|
||||
private val storeProvider: () -> DashboardCookieStore?,
|
||||
) : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val store = storeProvider() ?: return
|
||||
DashboardCookieJar(store).saveFromResponse(url, cookies)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val store = storeProvider() ?: return emptyList()
|
||||
return DashboardCookieJar(store).loadForRequest(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun importDashboardCookieHeader(
|
||||
store: DashboardCookieStore,
|
||||
url: String,
|
||||
cookieHeader: String?,
|
||||
clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
): Int {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return 0
|
||||
val raw = cookieHeader?.trim().orEmpty()
|
||||
if (raw.isBlank()) return 0
|
||||
|
||||
val now = clockMillis()
|
||||
// CookieManager.getCookie(url) returns only "name=value" pairs; it does
|
||||
// not expose the original Set-Cookie Path attribute. Store imported
|
||||
// WebView auth cookies at root so a cookie observed on /auth/callback is
|
||||
// still sent to /api/auth/me during native session verification.
|
||||
val cookiePath = "/"
|
||||
val imported = raw.split(";")
|
||||
.mapNotNull { part ->
|
||||
val index = part.indexOf('=')
|
||||
if (index <= 0) return@mapNotNull null
|
||||
val name = part.substring(0, index).trim()
|
||||
val value = part.substring(index + 1).trim()
|
||||
if (name.isBlank()) return@mapNotNull null
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = Long.MAX_VALUE,
|
||||
domain = httpUrl.host,
|
||||
path = cookiePath,
|
||||
secure = httpUrl.isHttps,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
persistent = false,
|
||||
)
|
||||
}
|
||||
.filterNot { it.isExpired(now) }
|
||||
if (imported.isEmpty()) return 0
|
||||
|
||||
val retained = store.load()
|
||||
.filterNot { it.isExpired(now) }
|
||||
.filterNot { old -> imported.any { it.key == old.key } }
|
||||
store.save(retained + imported)
|
||||
return imported.size
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StoredDashboardCookie(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
) {
|
||||
val key: String
|
||||
get() = "${name.lowercase()}|${domain.lowercase()}|$path"
|
||||
|
||||
fun isExpired(nowMillis: Long): Boolean =
|
||||
persistent && expiresAt <= nowMillis
|
||||
|
||||
fun toCookie(): Cookie? {
|
||||
return runCatching {
|
||||
val builder = Cookie.Builder()
|
||||
.name(name)
|
||||
.value(value)
|
||||
.path(path)
|
||||
if (hostOnly) {
|
||||
builder.hostOnlyDomain(domain)
|
||||
} else {
|
||||
builder.domain(domain)
|
||||
}
|
||||
if (persistent) {
|
||||
builder.expiresAt(expiresAt)
|
||||
}
|
||||
if (secure) builder.secure()
|
||||
if (httpOnly) builder.httpOnly()
|
||||
builder.build()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCookie(cookie: Cookie): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = cookie.name,
|
||||
value = cookie.value,
|
||||
expiresAt = cookie.expiresAt,
|
||||
domain = cookie.domain,
|
||||
path = cookie.path,
|
||||
secure = cookie.secure,
|
||||
httpOnly = cookie.httpOnly,
|
||||
hostOnly = cookie.hostOnly,
|
||||
persistent = cookie.persistent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Response.readJsonObject(json: Json): JsonObject {
|
||||
val raw = body.string()
|
||||
if (raw.isBlank()) return JsonObject(emptyMap())
|
||||
return json.parseToJsonElement(raw).jsonObject
|
||||
}
|
||||
|
||||
private fun Response.readJsonElement(json: Json): JsonElement {
|
||||
val raw = body.string()
|
||||
if (raw.isBlank()) return JsonObject(emptyMap())
|
||||
return json.parseToJsonElement(raw)
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val bodyDetail = runCatching { response.body.string() }.getOrDefault("")
|
||||
val detail = bodyDetail.take(240).ifBlank { response.message }
|
||||
return IOException("$operation failed - HTTP ${response.code}: $detail")
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(name: String): String? =
|
||||
((this?.get(name) as? JsonPrimitive)?.contentOrNull)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun JsonObject?.booleanField(name: String): Boolean? =
|
||||
(this?.get(name) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject?.intField(name: String): Int? =
|
||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
||||
@@ -0,0 +1,421 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Last observed probe result for a single [EndpointCandidate], keyed by
|
||||
* [EndpointResolver.cacheKey] in [EndpointResolver.probeOutcomes]. Unlike the
|
||||
* probe *cache* (a short-TTL "don't re-ask the network" optimization), this is
|
||||
* a UI-facing record of what actually happened — it survives [EndpointResolver
|
||||
* .clearCache] so the Routes card can keep showing the most recent
|
||||
* reachability verdict between probes.
|
||||
*/
|
||||
data class RouteProbeOutcome(
|
||||
val reachable: Boolean,
|
||||
/** Short human-readable failure reason; null when [reachable]. */
|
||||
val detail: String? = null,
|
||||
/** Resolver-clock timestamp of when the probe finished. */
|
||||
val atMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks the highest-priority **reachable** [EndpointCandidate] from a
|
||||
* per-device list, driven by ADR 24 "Multi-endpoint pairing + network-aware
|
||||
* switching" (2026-04-19).
|
||||
*
|
||||
* ### Semantics (locked by ADR 24)
|
||||
*
|
||||
* * **Strict priority.** `priority = 0` is highest. If a priority-0
|
||||
* candidate is reachable we use it; reachability never promotes a lower
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
|
||||
* per-candidate timeout. Positive results are cached longer than negative
|
||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||
* transient handoff misses do not pin a good fallback offline.
|
||||
* * **Network-change re-evaluate.** `ConnectionManager`'s network callback
|
||||
* bumps the caller into `resolve()` again on `onAvailable`, and marks the
|
||||
* active endpoint unreachable on `onLost` via [markUnreachable].
|
||||
*
|
||||
* The resolver is pure: no Context, no DataStore, no coroutine scope of its
|
||||
* own. Callers pass the pre-loaded [EndpointCandidate] list (from
|
||||
* `PairingPreferences.getDeviceEndpoints`), we run the probes, we return the
|
||||
* winner. That keeps the resolver testable from plain JUnit with a
|
||||
* MockWebServer stand-in.
|
||||
*
|
||||
* The resolver is **thread-safe** — the probe cache is a
|
||||
* [ConcurrentHashMap] so parallel probes from a race group don't tear it.
|
||||
*/
|
||||
class EndpointResolver(
|
||||
/**
|
||||
* OkHttp client used for probes. Callers pass the shared relay-side
|
||||
* client so TLS trust + DNS cache + cert-pinner state is consistent with
|
||||
* the eventual WSS connect. Internally the resolver applies its own
|
||||
* 2-second timeouts per call via [OkHttpClient.newBuilder], so the input
|
||||
* client's timeouts don't leak into probe behavior.
|
||||
*/
|
||||
private val httpClient: OkHttpClient,
|
||||
/**
|
||||
* Swappable "now" for tests. Production uses [System.currentTimeMillis];
|
||||
* tests feed a mutable clock to exercise the 30-second TTL.
|
||||
*/
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
|
||||
/**
|
||||
* Cached probe result. [expiresAt] is `clock()` + [CACHE_TTL_MS] when the
|
||||
* entry was written; after expiry the entry is re-probed.
|
||||
*/
|
||||
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
|
||||
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
||||
|
||||
/**
|
||||
* Last probe verdict per candidate, keyed by [cacheKey]. Drives the
|
||||
* per-row reachability line in the Routes card. Deliberately NOT wiped by
|
||||
* [clearCache] — the cache controls when we re-ask the network; this
|
||||
* records what the network last said.
|
||||
*/
|
||||
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
|
||||
|
||||
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
|
||||
_probeOutcomes.update { outcomes ->
|
||||
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
|
||||
reachable = reachable,
|
||||
detail = detail,
|
||||
atMillis = clock(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EndpointResolver"
|
||||
/**
|
||||
* Per-candidate HEAD probe timeout. ADR 24 speced 2s which was
|
||||
* tight — LTE hand-off and slow hotel Wi-Fi routinely blew past
|
||||
* 2s on the first packet and got candidates marked unreachable
|
||||
* spuriously. 4s preserves "fast-fail on real outage" while
|
||||
* surviving the flaky-network case.
|
||||
*/
|
||||
const val PROBE_TIMEOUT_MS = 4_000L
|
||||
/**
|
||||
* Successful probe-result cache TTL. Widened from ADR 24's 30s to 60s for
|
||||
* two reasons: (1) HEAD /health on every tab open was burning
|
||||
* battery unnecessarily on mobile, (2) NetworkCallback's
|
||||
* onAvailable / onLost invalidates the cache on real network
|
||||
* changes anyway, so a 60s idle cache is functionally
|
||||
* equivalent. Manual probes (EndpointsCard → "Probe now")
|
||||
* bypass the cache.
|
||||
*/
|
||||
const val CACHE_TTL_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Failed probe-result cache TTL. Keep this intentionally short:
|
||||
* Android may report a new cellular/VPN network before Tailscale has
|
||||
* finished routing, so a single early ConnectException must not keep a
|
||||
* viable fallback route suppressed through the voice resume window.
|
||||
*/
|
||||
const val NEGATIVE_CACHE_TTL_MS = 2_000L
|
||||
|
||||
/** Shared timeout wording so HEAD-timeout and socket-timeout read the same. */
|
||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
* but hostnames are lowercased — two roles pointing at the same
|
||||
* host:port share reachability state.
|
||||
*/
|
||||
internal fun cacheKey(candidate: EndpointCandidate): String =
|
||||
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the resolver against [candidates].
|
||||
*
|
||||
* 1. Group by `priority` ascending.
|
||||
* 2. For each priority group, race a HEAD /health probe against every
|
||||
* candidate in the group (2 s per candidate). First 2xx wins; ties
|
||||
* broken by whichever response lands first.
|
||||
* 3. If the entire group is unreachable, fall through to the next
|
||||
* priority group.
|
||||
* 4. If no candidate is reachable, return `null` — the caller falls back
|
||||
* to its legacy single-URL path.
|
||||
*
|
||||
* Candidates with an invalid api URL are skipped without affecting the
|
||||
* priority-group decision (a bad record shouldn't starve out the rest of
|
||||
* its tier). An empty [candidates] list returns null immediately without
|
||||
* touching the network.
|
||||
*/
|
||||
suspend fun resolve(candidates: List<EndpointCandidate>): EndpointCandidate? {
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
// Strict priority: sort ascending so priority-0 lands first. Grouping
|
||||
// preserves emitted order within a priority class (DNS SRV parity).
|
||||
val groups = candidates.groupBy { it.priority }.toSortedMap()
|
||||
|
||||
for ((priority, group) in groups) {
|
||||
Log.d(TAG, "probing priority=$priority group (size=${group.size})")
|
||||
val winner = raceGroup(group)
|
||||
if (winner != null) {
|
||||
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
||||
"api=${winner.api.host}:${winner.api.port} priority=$priority")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Endpoint selected",
|
||||
detail = "priority=$priority",
|
||||
endpointRole = winner.role,
|
||||
url = winner.relay.url,
|
||||
)
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "resolve: no reachable candidate across ${candidates.size} record(s)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "No reachable endpoint",
|
||||
detail = "${candidates.size} configured route(s) failed health probes",
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Race all candidates in [group] (same priority tier) in parallel. First
|
||||
* candidate that reports reachable — whether from cache or a fresh probe
|
||||
* — wins. Null when the entire group is unreachable.
|
||||
*
|
||||
* We **don't** await all probes before picking a winner: the spec calls
|
||||
* for "first 2xx wins" so latency matters. The losing probes' results
|
||||
* still land in the cache, though, so the next call benefits.
|
||||
*/
|
||||
private suspend fun raceGroup(group: List<EndpointCandidate>): EndpointCandidate? {
|
||||
if (group.isEmpty()) return null
|
||||
if (group.size == 1) {
|
||||
val only = group.first()
|
||||
return if (isReachable(only)) only else null
|
||||
}
|
||||
|
||||
// Fast-path: any cached-reachable candidate wins immediately without
|
||||
// touching the network.
|
||||
for (candidate in group) {
|
||||
val cached = probeCache[cacheKey(candidate)]
|
||||
if (cached != null && cached.expiresAt > clock() && cached.reachable) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return coroutineScope {
|
||||
val deferred = group.map { candidate ->
|
||||
async(Dispatchers.IO) {
|
||||
if (isReachable(candidate)) candidate else null
|
||||
}
|
||||
}
|
||||
// Collect results in arrival order: iterate through awaitAll +
|
||||
// pick the first non-null. awaitAll preserves input order, which
|
||||
// means a slow-but-reachable priority-0 candidate would block a
|
||||
// fast-and-reachable sibling. But HEAD /health against a healthy
|
||||
// API route replies in <100ms and the timeout caps stragglers at 2s,
|
||||
// so this is acceptable in practice. A true "first to arrive"
|
||||
// would need kotlinx.coroutines Channel plumbing that's not
|
||||
// worth the weight here.
|
||||
deferred.awaitAll().firstOrNull { it != null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-aware reachability check for a single candidate. Consults
|
||||
* [probeCache] first; on miss or expiry, runs a HEAD /health probe and
|
||||
* records the result.
|
||||
*/
|
||||
private suspend fun isReachable(candidate: EndpointCandidate): Boolean {
|
||||
val key = cacheKey(candidate)
|
||||
val now = clock()
|
||||
val cached = probeCache[key]
|
||||
if (cached != null && cached.expiresAt > now) {
|
||||
Log.d(TAG, "cache hit for $key reachable=${cached.reachable}")
|
||||
return cached.reachable
|
||||
}
|
||||
|
||||
val reachable = probe(candidate)
|
||||
val ttl = if (reachable) CACHE_TTL_MS else NEGATIVE_CACHE_TTL_MS
|
||||
probeCache[key] = CacheEntry(expiresAt = now + ttl, reachable = reachable)
|
||||
return reachable
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot HEAD /health probe against a candidate. 2-second timeout,
|
||||
* no retries — callers that need retry semantics can re-invoke after
|
||||
* the cache expires.
|
||||
*
|
||||
* Returns false on any failure (timeout, I/O, non-2xx, invalid URL).
|
||||
* We never raise: a bad record shouldn't crash the connect loop.
|
||||
*/
|
||||
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
||||
val startedAtMs = clock()
|
||||
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Endpoint probe invalid",
|
||||
detail = "Invalid API URL",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.head()
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
fastClient.newCall(request).execute().use { resp ->
|
||||
val ok = resp.isSuccessful
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
|
||||
title = if (ok) "Endpoint probe ok" else "Endpoint probe failed",
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from /health",
|
||||
)
|
||||
ok
|
||||
}
|
||||
} ?: run {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a probe exception to a short, actionable string for the Routes
|
||||
* card. The TLS case is the headline: a route saved with `https://`
|
||||
* against a plain-HTTP Hermes API server fails its handshake on every
|
||||
* probe and previously surfaced as a silent "never switches" mystery.
|
||||
*/
|
||||
private fun humanProbeFailure(e: Exception): String = when (e) {
|
||||
is SSLException -> "TLS failed — server may be http://, not https://"
|
||||
is ConnectException -> "Connection refused"
|
||||
is UnknownHostException -> "Host not found"
|
||||
is SocketTimeoutException -> PROBE_TIMEOUT_DETAIL
|
||||
is NoRouteToHostException -> "No route to host"
|
||||
else -> e.javaClass.simpleName
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
* skips the dead endpoint without waiting for its probe to time out.
|
||||
*
|
||||
* The entry is still TTL'd with the short negative TTL so a network-change
|
||||
* transition can skip the known-dead active route without suppressing a
|
||||
* valid fallback for the whole positive cache window.
|
||||
*/
|
||||
fun markUnreachable(candidate: EndpointCandidate) {
|
||||
val key = cacheKey(candidate)
|
||||
probeCache[key] = CacheEntry(
|
||||
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the probe cache so the next resolve runs fresh probes. Called on
|
||||
* "the world changed" triggers — NetworkCallback events, manual "Probe
|
||||
* now", and [refreshActiveEndpoint][ConnectionManager.refreshActiveEndpoint]
|
||||
* with `clearProbeCache = true` — where a positive entry for a
|
||||
* just-died route must not outlive the handoff.
|
||||
*/
|
||||
internal fun clearCache() {
|
||||
probeCache.clear()
|
||||
}
|
||||
|
||||
/** Test-only: snapshot the current cache for assertion purposes. */
|
||||
internal fun cacheSnapshot(): Map<String, Pair<Long, Boolean>> =
|
||||
probeCache.mapValues { (_, v) -> v.expiresAt to v.reachable }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.network.models.CreateSessionRequest
|
||||
import com.hermesandroid.relay.network.models.HermesSseEvent
|
||||
@@ -19,11 +20,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -50,6 +52,110 @@ enum class ChatMode {
|
||||
DISCONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-endpoint capability snapshot. Populated by [HermesApiClient.probeCapabilities].
|
||||
*
|
||||
* The Android client uses this to pick the best chat path automatically when
|
||||
* `streamingEndpoint = "auto"`. The bootstrap-injected vanilla-upstream case
|
||||
* is the interesting one: `sessionsApi=true` (we injected it) but
|
||||
* `sessionsChatStream=false` (the chat handler is absent). The auto-resolver
|
||||
* now picks OpenAI-compatible chat completions for that case because the route
|
||||
* returns an SSE stream, while `/v1/runs` may be an async JSON run-start API.
|
||||
*/
|
||||
data class ServerCapabilities(
|
||||
/** `/api/sessions` (CRUD) — true on native upstream, fork, OR bootstrap-injected older builds. */
|
||||
val sessionsApi: Boolean,
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true on native upstream or legacy fork builds. */
|
||||
val sessionsChatStream: Boolean,
|
||||
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
|
||||
val runs: Boolean,
|
||||
/** `/v1/chat/completions` — OpenAI-compatible fallback. */
|
||||
val portable: Boolean,
|
||||
/** `/health` — basic reachability. */
|
||||
val healthy: Boolean,
|
||||
) {
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
sessionsChatStream -> "sessions"
|
||||
portable -> "completions"
|
||||
runs -> "runs"
|
||||
else -> "sessions" // last-resort: try sessions, will surface a clear error
|
||||
}
|
||||
|
||||
fun toChatMode(): ChatMode = when {
|
||||
!healthy -> ChatMode.DISCONNECTED
|
||||
sessionsApi -> ChatMode.ENHANCED_HERMES
|
||||
portable || runs -> ChatMode.PORTABLE
|
||||
else -> ChatMode.DISCONNECTED
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DISCONNECTED = ServerCapabilities(
|
||||
sessionsApi = false,
|
||||
sessionsChatStream = false,
|
||||
runs = false,
|
||||
portable = false,
|
||||
healthy = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.childObject(key: String): JsonObject? = this[key] as? JsonObject
|
||||
|
||||
private fun JsonObject.booleanFlag(key: String): Boolean =
|
||||
(this[key] as? JsonPrimitive)?.booleanOrNull == true
|
||||
|
||||
private fun JsonObject.hasEndpoint(key: String): Boolean {
|
||||
val path = ((this[key] as? JsonObject)?.get("path") as? JsonPrimitive)?.contentOrNull
|
||||
return !path.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities? {
|
||||
val root = try {
|
||||
json.decodeFromString<JsonObject>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val features = root.childObject("features")
|
||||
val endpoints = root.childObject("endpoints")
|
||||
if (features == null && endpoints == null) return null
|
||||
|
||||
fun feature(name: String): Boolean = features?.booleanFlag(name) == true
|
||||
fun endpoint(name: String): Boolean = endpoints?.hasEndpoint(name) == true
|
||||
|
||||
return ServerCapabilities(
|
||||
sessionsApi = feature("session_resources") ||
|
||||
endpoint("sessions") ||
|
||||
endpoint("session_create"),
|
||||
sessionsChatStream = feature("session_chat_streaming") ||
|
||||
endpoint("session_chat_stream"),
|
||||
runs = feature("run_events_sse") || endpoint("run_events"),
|
||||
portable = feature("chat_completions_streaming") ||
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal val HERMES_SKILL_ENDPOINTS = listOf("/v1/skills", "/api/skills")
|
||||
|
||||
internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items ?: parsed.data
|
||||
if (skills != null) return skills
|
||||
} catch (_: Exception) {
|
||||
// Fall through to direct-array compatibility below.
|
||||
}
|
||||
|
||||
try {
|
||||
return json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -145,43 +251,109 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> = withContext(Dispatchers.IO) {
|
||||
suspend fun checkSessionsAuthDetailed(): HealthCheckResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=1").get().build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
parsed.items ?: parsed.sessions ?: emptyList()
|
||||
when {
|
||||
response.isSuccessful -> HealthCheckResult.Healthy
|
||||
response.code == 401 || response.code == 403 ->
|
||||
HealthCheckResult.Unhealthy("API reachable, but sessions auth failed - check your API key")
|
||||
response.code == 404 ->
|
||||
HealthCheckResult.Unhealthy("API reachable, but /api/sessions is unavailable")
|
||||
else ->
|
||||
HealthCheckResult.Unhealthy("Sessions check returned HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: javax.net.ssl.SSLException) {
|
||||
if (baseUrl.startsWith("https://", ignoreCase = true)) {
|
||||
HealthCheckResult.Unhealthy("TLS handshake failed - try http:// if your server doesn't use HTTPS")
|
||||
} else {
|
||||
HealthCheckResult.Unhealthy("SSL error: ${e.message}")
|
||||
}
|
||||
} catch (e: java.net.ConnectException) {
|
||||
HealthCheckResult.Unhealthy("Connection refused - check the URL and port")
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
HealthCheckResult.Unhealthy("Server not found - check the hostname")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
HealthCheckResult.Unhealthy("Connection timed out - is the server running?")
|
||||
} catch (e: IOException) {
|
||||
HealthCheckResult.Unhealthy("Connection failed: ${e.message ?: "I/O error"}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
emptyList()
|
||||
HealthCheckResult.Unhealthy("Unexpected error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSession(title: String? = null): SessionItem? = withContext(Dispatchers.IO) {
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessionsResult(limit: Int = 50): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(CreateSessionRequest(title = title))
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(
|
||||
title: String? = null,
|
||||
profileName: String? = null,
|
||||
model: String? = null,
|
||||
): Result<SessionItem> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(
|
||||
CreateSessionRequest(
|
||||
title = title,
|
||||
model = model,
|
||||
profile = AgentDisplay.profileRequestName(profileName),
|
||||
),
|
||||
)
|
||||
val request = authRequest("$baseUrl/api/sessions")
|
||||
.post(reqBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val body = response.body?.string() ?: return@withContext null
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "Create session"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Create session returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionResponse>(body)
|
||||
parsed.session ?: parsed.id?.let {
|
||||
val session = parsed.session ?: parsed.id?.let {
|
||||
SessionItem(id = it, title = parsed.title, model = parsed.model)
|
||||
}
|
||||
session?.let { Result.success(it) }
|
||||
?: Result.failure(IOException("Create session response missing session id"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create session: ${e.message}")
|
||||
null
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSession(
|
||||
title: String? = null,
|
||||
profileName: String? = null,
|
||||
model: String? = null,
|
||||
): SessionItem? =
|
||||
createSessionResult(title, profileName, model).getOrNull()
|
||||
|
||||
suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId")
|
||||
@@ -216,7 +388,7 @@ class HermesApiClient(
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<MessageListResponse>(body)
|
||||
parsed.items ?: parsed.messages ?: emptyList()
|
||||
parsed.data ?: parsed.items ?: parsed.messages ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get messages: ${e.message}")
|
||||
@@ -227,27 +399,21 @@ class HermesApiClient(
|
||||
// --- Skills ---
|
||||
|
||||
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/skills").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
// Try structured response: { "skills": [...] } or { "items": [...] }
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items
|
||||
for (endpoint in HERMES_SKILL_ENDPOINTS) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl$endpoint").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
val body = response.body?.string() ?: return@use
|
||||
val skills = parseSkillListBody(json, body)
|
||||
if (skills != null) return@withContext skills
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
// Try direct array: [...]
|
||||
try {
|
||||
return@withContext json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills from $endpoint: ${e.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
@@ -287,10 +453,22 @@ class HermesApiClient(
|
||||
key to ((value as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "")
|
||||
} ?: emptyMap()
|
||||
|
||||
// Default personality: config.display.personality
|
||||
// Default display identity. Upstream Hermes currently uses
|
||||
// config.display.personality for the active persona and often
|
||||
// mirrors the same identity through skin. Accept name-style
|
||||
// aliases too so older or profile-specific configs don't make
|
||||
// Android fall back to the literal "Hermes" label.
|
||||
val display = config["display"] as? JsonObject
|
||||
val defaultName = (display?.get("personality") as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.content ?: ""
|
||||
val defaultPersonality = display.stringField("personality")
|
||||
val defaultName = firstNonBlank(
|
||||
defaultPersonality.takeUnless { it.equals("default", ignoreCase = true) },
|
||||
display.stringField("agent_name"),
|
||||
display.stringField("assistant_name"),
|
||||
display.stringField("display_name"),
|
||||
display.stringField("name"),
|
||||
display.stringField("skin"),
|
||||
defaultPersonality,
|
||||
)
|
||||
|
||||
PersonalityConfig(
|
||||
names = prompts.keys.toList(),
|
||||
@@ -307,11 +485,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,
|
||||
@@ -322,24 +530,24 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildSessionChatStreamPayload(
|
||||
message = message,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/chat/stream")
|
||||
@@ -535,13 +743,21 @@ class HermesApiClient(
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
// --- Run streaming via /v1/runs ---
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
|
||||
fun sendRunStream(
|
||||
/**
|
||||
* Stream chat through the OpenAI-compatible chat completions endpoint.
|
||||
*
|
||||
* This is the portable SSE fallback for servers that expose
|
||||
* `/v1/chat/completions` but where `/v1/runs` is an async JSON run-start
|
||||
* API rather than an EventSource-compatible stream.
|
||||
*/
|
||||
fun sendChatCompletionsStream(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
@@ -552,26 +768,207 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("model", model ?: "default")
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatCompletionsStream: modelOverride=$modelOverride (profile pick, was model=$model)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatCompletionsStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildChatCompletionsStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/chat/completions")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val event = json.decodeFromString<JsonObject>(data)
|
||||
openAiErrorMessage(event)?.let { msg ->
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
openAiUsage(event)?.let { usage ->
|
||||
mainHandler.post { onUsage(usage) }
|
||||
}
|
||||
|
||||
if (messageStarted.compareAndSet(false, true)) {
|
||||
openAiMessageId(event)?.let { messageId ->
|
||||
mainHandler.post { onMessageStarted(messageId) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiReasoningDelta(event)?.let { reasoning ->
|
||||
if (reasoning.isNotEmpty()) {
|
||||
mainHandler.post { onThinkingDelta(reasoning) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiTextDelta(event)?.let { delta ->
|
||||
if (delta.isNotEmpty()) {
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
|
||||
val finishReason = openAiFinishReason(event)
|
||||
if (!finishReason.isNullOrBlank() && completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unparseable chat completion SSE event ($type): ${e.message}\nRaw: $data")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
(event["choices"] as? JsonArray)
|
||||
?.firstOrNull()
|
||||
?.let { it as? JsonObject }
|
||||
|
||||
private fun openAiDelta(event: JsonObject): JsonObject? =
|
||||
openAiChoice(event)?.get("delta") as? JsonObject
|
||||
|
||||
private fun openAiTextDelta(event: JsonObject): String? =
|
||||
(openAiDelta(event)?.get("content") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiReasoningDelta(event: JsonObject): String? {
|
||||
val delta = openAiDelta(event) ?: return null
|
||||
return (delta["reasoning_content"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (delta["reasoning"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (delta["thinking"] as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
|
||||
private fun openAiFinishReason(event: JsonObject): String? =
|
||||
(openAiChoice(event)?.get("finish_reason") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiMessageId(event: JsonObject): String? =
|
||||
(event["id"] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiErrorMessage(event: JsonObject): String? {
|
||||
val error = event["error"] ?: return null
|
||||
return when (error) {
|
||||
is JsonPrimitive -> error.contentOrNull
|
||||
is JsonObject -> (error["message"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (error["error"] as? JsonPrimitive)?.contentOrNull
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAiUsage(event: JsonObject): UsageInfo? =
|
||||
(event["usage"] as? JsonObject)?.let { usage ->
|
||||
runCatching { json.decodeFromJsonElement<UsageInfo>(usage) }.getOrNull()
|
||||
}
|
||||
|
||||
// --- Run streaming via /v1/runs ---
|
||||
|
||||
/**
|
||||
* Stream a run via `/v1/runs`.
|
||||
*
|
||||
* @param model Caller's default model selection (nullable). When
|
||||
* [modelOverride] is null/blank this is used as the `model` field,
|
||||
* or `"default"` if both are null — preserving the pre-profile
|
||||
* behaviour exactly.
|
||||
* @param modelOverride When non-null and non-blank, wins over [model]
|
||||
* and is injected as the top-level `"model"` field in the run
|
||||
* request body. Used by the agent-profile picker so an explicit
|
||||
* user-selected profile model takes precedence over any implicit
|
||||
* caller default. When null/blank this parameter is ignored and
|
||||
* [model] drives selection as before.
|
||||
*/
|
||||
fun sendRunStream(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
/** See [sendChatStream]'s `voiceIntentMessages` doc — same semantics. */
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
onThinkingDelta: (String) -> Unit,
|
||||
onToolCallStart: (String, String) -> Unit,
|
||||
onToolCallDone: (String, String?) -> Unit,
|
||||
onToolCallFailed: (String, String?) -> Unit,
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendRunStream: modelOverride=$modelOverride (profile pick, was model=$model)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendRunStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildRunStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/runs")
|
||||
@@ -770,37 +1167,117 @@ class HermesApiClient(
|
||||
|
||||
/**
|
||||
* Probe the server to determine which chat API is available.
|
||||
* Checks /health, then /api/sessions (enhanced), then /v1/models (portable).
|
||||
* Convenience wrapper around [probeCapabilities] that collapses the
|
||||
* per-endpoint result into the older 3-state ChatMode enum for callers
|
||||
* that don't need the detail.
|
||||
*/
|
||||
suspend fun detectChatMode(): ChatMode = withContext(Dispatchers.IO) {
|
||||
// 1. Basic connectivity
|
||||
try {
|
||||
val healthReq = authRequest("$baseUrl/health").get().build()
|
||||
client.newCall(healthReq).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext ChatMode.DISCONNECTED
|
||||
suspend fun detectChatMode(): ChatMode = probeCapabilities().toChatMode()
|
||||
|
||||
/**
|
||||
* Probe each endpoint we care about and return a per-route capability
|
||||
* snapshot. This is the source of truth for "which chat path should we
|
||||
* use" — see [ServerCapabilities.preferredChatEndpoint].
|
||||
*
|
||||
* Probe order:
|
||||
* 1. `/health` — if this fails, everything else is moot.
|
||||
* 2. `GET /v1/capabilities` — native upstream feature + endpoint map.
|
||||
* 3. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork,
|
||||
* native upstream, OR bootstrap-injected older upstream).
|
||||
* 4. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* presence. The handler only accepts POST, so HEAD returns 405
|
||||
* (Method Not Allowed) when the route is registered. 404 means
|
||||
* the route doesn't exist at all.
|
||||
* 5. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 6. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* when the response explicitly advertises event-stream compatibility.
|
||||
*
|
||||
* **Why HEAD instead of OPTIONS:** The hermes-agent gateway runs CORS
|
||||
* middleware (`security_headers_middleware`) that intercepts OPTIONS
|
||||
* preflight requests and returns 403 for both existing AND missing
|
||||
* paths — making OPTIONS useless as a probe. HEAD bypasses the CORS
|
||||
* middleware path and surfaces the actual router status (200/401/405
|
||||
* for present, 404 for missing). Verified empirically against the
|
||||
* production hermes-agent gateway on 2026-04-12.
|
||||
*
|
||||
* **Route presence criterion:** for sessions and completions, any HTTP
|
||||
* response code that isn't 404 means the route is registered. We accept
|
||||
* 200, 204, 401, 403, 405, 415, etc. as positive because the alternative
|
||||
* (404) is the only signal that means "no such path." `/v1/runs` is
|
||||
* stricter: route presence alone is not enough because async runs can
|
||||
* return `202 application/json`; auto only uses it if event-stream support
|
||||
* is explicitly advertised.
|
||||
*
|
||||
* Network errors (connection refused, DNS failure, etc.) count as
|
||||
* "missing" since we can't differentiate from a server-down case.
|
||||
*/
|
||||
suspend fun probeCapabilities(): ServerCapabilities = withContext(Dispatchers.IO) {
|
||||
// 1. Health
|
||||
val healthy = try {
|
||||
val req = authRequest("$baseUrl/health").get().build()
|
||||
client.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
null
|
||||
} else {
|
||||
parseCapabilitiesBody(json, response.body.string())
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return@withContext ChatMode.DISCONNECTED
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
// Dispatchers.IO context from the outer withContext, so the
|
||||
// blocking OkHttp calls are safe here.
|
||||
fun routeExists(path: String): Boolean = try {
|
||||
val req = authRequest("$baseUrl$path").head().build()
|
||||
client.newCall(req).execute().use { response -> response.code != 404 }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
// 2. Try enhanced sessions API
|
||||
try {
|
||||
val sessionsReq = authRequest("$baseUrl/api/sessions?limit=1").get().build()
|
||||
client.newCall(sessionsReq).execute().use { response ->
|
||||
if (response.isSuccessful) return@withContext ChatMode.ENHANCED_HERMES
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
fun Response.advertisesEventStream(): Boolean {
|
||||
val contentType = header("Content-Type").orEmpty()
|
||||
val streamMode = header("X-Hermes-Stream-Mode").orEmpty()
|
||||
val runStreaming = header("X-Hermes-Run-Streaming").orEmpty()
|
||||
return contentType.contains("text/event-stream", ignoreCase = true) ||
|
||||
streamMode.equals("sse", ignoreCase = true) ||
|
||||
runStreaming.equals("sse", ignoreCase = true)
|
||||
}
|
||||
|
||||
// 3. Try OpenAI-compatible models endpoint
|
||||
try {
|
||||
val modelsReq = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(modelsReq).execute().use { response ->
|
||||
if (response.isSuccessful) return@withContext ChatMode.PORTABLE
|
||||
fun routeExplicitlySupportsEventStream(path: String): Boolean = try {
|
||||
val req = authRequest("$baseUrl$path")
|
||||
.head()
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
response.code != 404 && response.advertisesEventStream()
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
// Server is reachable but neither API is available
|
||||
ChatMode.DISCONNECTED
|
||||
val sessionsApi = routeExists("/api/sessions?limit=1")
|
||||
val sessionsChatStream = routeExists("/api/sessions/probe/chat/stream")
|
||||
val portable = routeExists("/v1/chat/completions")
|
||||
val runs = routeExplicitlySupportsEventStream("/v1/runs")
|
||||
|
||||
ServerCapabilities(
|
||||
sessionsApi = sessionsApi,
|
||||
sessionsChatStream = sessionsChatStream,
|
||||
runs = runs,
|
||||
portable = portable,
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
@@ -824,4 +1301,20 @@ class HermesApiClient(
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val detail = response.message.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty()
|
||||
val message = when (response.code) {
|
||||
401, 403 -> "$operation unauthorized - check your API key"
|
||||
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
|
||||
else -> "$operation failed - HTTP ${response.code}$detail"
|
||||
}
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(name: String): String =
|
||||
((this?.get(name) as? JsonPrimitive)?.contentOrNull ?: "").trim()
|
||||
|
||||
private fun firstNonBlank(vararg values: String?): String =
|
||||
values.firstOrNull { !it.isNullOrBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
internal fun buildSessionChatStreamPayload(
|
||||
message: String,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
put("model", modelOverride)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildRunStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildChatCompletionsStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("stream", true)
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
putJsonArray("messages") {
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
addJsonObject {
|
||||
put("role", "system")
|
||||
put("content", systemMessage)
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
voiceIntentMessages.forEach { add(it) }
|
||||
}
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { it.isImage }) {
|
||||
put("content", buildJsonArray {
|
||||
addJsonObject {
|
||||
put("type", "text")
|
||||
put("text", message)
|
||||
}
|
||||
attachments.filter { it.isImage }.forEach { att ->
|
||||
addJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:${att.contentType};base64,${att.content}")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
put("content", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { !it.isImage }) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.filter { !it.isImage }.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkAddress
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.Inet4Address
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class HermesLanDiscoveryResult(
|
||||
val host: String,
|
||||
val apiUrl: String,
|
||||
val dashboardUrl: String?,
|
||||
val apiReachable: Boolean,
|
||||
val dashboardReachable: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-triggered local-network discovery for standard Hermes setup.
|
||||
*
|
||||
* This deliberately scans only the active RFC1918/link-local LAN around the
|
||||
* phone, never broad public or Tailscale ranges. Tailscale/public routes still
|
||||
* belong in the explicit advanced fields where the user controls the URL.
|
||||
*/
|
||||
object HermesLanDiscovery {
|
||||
private const val TAG = "HermesLanDiscovery"
|
||||
private const val MAX_HOSTS = 254
|
||||
private const val MAX_CONCURRENT_PROBES = 32
|
||||
private const val PROBE_TIMEOUT_MS = 650L
|
||||
private const val IPV4_MASK = 0xFFFF_FFFFL
|
||||
|
||||
suspend fun scan(
|
||||
context: Context,
|
||||
apiPort: Int = 8642,
|
||||
dashboardPort: Int = 9119,
|
||||
): List<HermesLanDiscoveryResult> = withContext(Dispatchers.IO) {
|
||||
val hosts = localLanHosts(context.applicationContext)
|
||||
if (hosts.isEmpty()) return@withContext emptyList()
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
coroutineScope {
|
||||
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
|
||||
hosts.map { host ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
probeHost(client, host, apiPort, dashboardPort)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
.filterNotNull()
|
||||
.sortedWith(
|
||||
compareByDescending<HermesLanDiscoveryResult> { it.dashboardReachable }
|
||||
.thenByDescending { it.apiReachable }
|
||||
.thenBy { it.host },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeHost(
|
||||
client: OkHttpClient,
|
||||
host: String,
|
||||
apiPort: Int,
|
||||
dashboardPort: Int,
|
||||
): HermesLanDiscoveryResult? {
|
||||
val apiUrl = "http://$host:$apiPort"
|
||||
val dashboardUrl = "http://$host:$dashboardPort"
|
||||
val dashboardReachable = probe(
|
||||
client = client,
|
||||
url = "$dashboardUrl/api/status",
|
||||
expectedBody = ::looksLikeDashboardStatus,
|
||||
)
|
||||
val apiReachable = probe(
|
||||
client = client,
|
||||
url = "$apiUrl/health",
|
||||
expectedBody = ::looksLikeApiHealth,
|
||||
)
|
||||
if (!dashboardReachable && !apiReachable) return null
|
||||
return HermesLanDiscoveryResult(
|
||||
host = host,
|
||||
apiUrl = apiUrl,
|
||||
dashboardUrl = dashboardUrl.takeIf { dashboardReachable },
|
||||
apiReachable = apiReachable,
|
||||
dashboardReachable = dashboardReachable,
|
||||
)
|
||||
}
|
||||
|
||||
private fun probe(
|
||||
client: OkHttpClient,
|
||||
url: String,
|
||||
expectedBody: (String, String) -> Boolean,
|
||||
): Boolean {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return false
|
||||
val request = Request.Builder()
|
||||
.url(httpUrl)
|
||||
.get()
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return true
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return false
|
||||
}
|
||||
val contentType = response.header("Content-Type").orEmpty()
|
||||
val body = response.body.string().take(2_048)
|
||||
expectedBody(body, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
|
||||
val lower = body.lowercase()
|
||||
return contentType.contains("json", ignoreCase = true) && (
|
||||
lower.contains("auth_required") ||
|
||||
lower.contains("auth_providers") ||
|
||||
lower.contains("authenticated") ||
|
||||
lower.contains("hermes")
|
||||
)
|
||||
}
|
||||
|
||||
private fun looksLikeApiHealth(body: String, contentType: String): Boolean {
|
||||
if (contentType.contains("json", ignoreCase = true)) return true
|
||||
if (contentType.contains("text/plain", ignoreCase = true)) return true
|
||||
return body.isBlank() || body.trimStart().startsWith("{")
|
||||
}
|
||||
|
||||
private fun localLanHosts(context: Context): List<String> {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
?: return emptyList()
|
||||
val networks = buildList {
|
||||
connectivityManager.activeNetwork?.let(::add)
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
if (!contains(network)) add(network)
|
||||
}
|
||||
}
|
||||
|
||||
val hosts = linkedSetOf<String>()
|
||||
for (network in networks) {
|
||||
val linkProperties = connectivityManager.getLinkProperties(network) ?: continue
|
||||
for (linkAddress in linkProperties.linkAddresses) {
|
||||
addHostsForLink(linkAddress, hosts)
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
return hosts.take(MAX_HOSTS)
|
||||
}
|
||||
|
||||
private fun addHostsForLink(linkAddress: LinkAddress, hosts: MutableSet<String>) {
|
||||
val address = linkAddress.address as? Inet4Address ?: return
|
||||
if (address.isLoopbackAddress || address.isMulticastAddress) return
|
||||
|
||||
val local = ipv4ToLong(address)
|
||||
if (!isScannableLanAddress(local)) return
|
||||
|
||||
val scanPrefix = when (linkAddress.prefixLength) {
|
||||
in 24..30 -> linkAddress.prefixLength
|
||||
else -> 24
|
||||
}
|
||||
val mask = subnetMask(scanPrefix)
|
||||
val network = local and mask
|
||||
val broadcast = network or (mask.inv() and IPV4_MASK)
|
||||
val first = network + 1
|
||||
val last = broadcast - 1
|
||||
if (first > last) return
|
||||
|
||||
for (candidate in first..last) {
|
||||
if (candidate == local) continue
|
||||
hosts.add(longToIpv4(candidate))
|
||||
if (hosts.size >= MAX_HOSTS) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun subnetMask(prefixLength: Int): Long {
|
||||
return (IPV4_MASK shl (32 - prefixLength)) and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun ipv4ToLong(address: Inet4Address): Long {
|
||||
return address.address.fold(0L) { acc, byte ->
|
||||
(acc shl 8) or (byte.toInt() and 0xFF).toLong()
|
||||
} and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun longToIpv4(value: Long): String {
|
||||
return listOf(
|
||||
(value shr 24) and 0xFF,
|
||||
(value shr 16) and 0xFF,
|
||||
(value shr 8) and 0xFF,
|
||||
value and 0xFF,
|
||||
).joinToString(".") { it.toString() }
|
||||
}
|
||||
|
||||
private fun isScannableLanAddress(value: Long): Boolean {
|
||||
val first = ((value shr 24) and 0xFF).toInt()
|
||||
val second = ((value shr 16) and 0xFF).toInt()
|
||||
return when {
|
||||
first == 10 -> true
|
||||
first == 172 && second in 16..31 -> true
|
||||
first == 192 && second == 168 -> true
|
||||
first == 169 && second == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||
*
|
||||
* Relays and Hermes gateways often bind profile API servers to loopback or
|
||||
* 0.0.0.0 on the host machine. Those addresses are correct for the relay
|
||||
* process but wrong on Android, where 127.0.0.1 means the phone. When the
|
||||
* active connection uses a reachable LAN/Tailscale host, replace loopback
|
||||
* profile hosts with that same host while preserving the profile port.
|
||||
*/
|
||||
object ProfileApiUrlResolver {
|
||||
fun normalize(url: String?): String? =
|
||||
url?.trim()?.takeIf { it.isNotBlank() }?.trimEnd('/')
|
||||
|
||||
fun resolveForConnection(profileApiUrl: String?, baseApiUrl: String?): String? {
|
||||
val profile = normalize(profileApiUrl) ?: return null
|
||||
val base = normalize(baseApiUrl) ?: return profile
|
||||
|
||||
val profileUri = runCatching { URI(profile) }.getOrNull() ?: return profile
|
||||
val profileHost = profileUri.host?.takeIf { it.isNotBlank() } ?: return profile
|
||||
if (!isLocalBindHost(profileHost)) return profile
|
||||
|
||||
val baseUri = runCatching { URI(base) }.getOrNull() ?: return profile
|
||||
val baseHost = baseUri.host?.takeIf { it.isNotBlank() } ?: return profile
|
||||
if (isLocalBindHost(baseHost)) return profile
|
||||
|
||||
val scheme = baseUri.scheme?.takeIf { it.isNotBlank() }
|
||||
?: profileUri.scheme?.takeIf { it.isNotBlank() }
|
||||
?: return profile
|
||||
val hostPart = if (baseHost.contains(":") && !baseHost.startsWith("[")) {
|
||||
"[$baseHost]"
|
||||
} else {
|
||||
baseHost
|
||||
}
|
||||
val portPart = profileUri.port.takeIf { it != -1 }?.let { ":$it" }.orEmpty()
|
||||
val pathPart = profileUri.rawPath?.takeIf { it.isNotBlank() && it != "/" }.orEmpty()
|
||||
val queryPart = profileUri.rawQuery?.let { "?$it" }.orEmpty()
|
||||
val fragmentPart = profileUri.rawFragment?.let { "#$it" }.orEmpty()
|
||||
|
||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
||||
}
|
||||
|
||||
private fun isLocalBindHost(host: String): Boolean {
|
||||
return when (host.lowercase().trim('[', ']')) {
|
||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
@@ -579,7 +582,10 @@ class RelayHttpClient(
|
||||
* human-readable message on any failure (network, non-200, bad body,
|
||||
* doesn't-look-like-hermes-relay).
|
||||
*/
|
||||
suspend fun probeHealth(relayUrl: String): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
suspend fun probeHealth(
|
||||
relayUrl: String,
|
||||
logSuccess: Boolean = true,
|
||||
): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
val trimmed = relayUrl.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
@@ -591,10 +597,18 @@ class RelayHttpClient(
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val startedAtMs = System.currentTimeMillis()
|
||||
|
||||
val url = try {
|
||||
"$httpBase/health".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay URL invalid",
|
||||
detail = e.message,
|
||||
url = relayUrl,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
@@ -606,6 +620,7 @@ class RelayHttpClient(
|
||||
.connectTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
@@ -617,12 +632,28 @@ class RelayHttpClient(
|
||||
try {
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "HTTP ${response.code}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay responded HTTP ${response.code}")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Empty response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
@@ -632,18 +663,42 @@ class RelayHttpClient(
|
||||
val parsed: Map<String, kotlinx.serialization.json.JsonElement> = try {
|
||||
sessionsJson.parseToJsonElement(body).jsonObject
|
||||
} catch (e: Exception) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Non-JSON response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
val status = (parsed["status"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (status != "ok") {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "status=${status ?: "missing"}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
|
||||
)
|
||||
}
|
||||
val version = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (version.isNullOrBlank()) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Missing version field",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
|
||||
)
|
||||
@@ -652,19 +707,61 @@ class RelayHttpClient(
|
||||
?.content?.toIntOrNull() ?: 0
|
||||
val sessions = (parsed["sessions"] as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.content?.toIntOrNull() ?: 0
|
||||
if (logSuccess) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay health ok",
|
||||
detail = "version=$version clients=$clients sessions=$sessions",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
}
|
||||
Result.success(RelayHealth(version = version, clients = clients, sessions = sessions))
|
||||
}
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
Log.w(TAG, "probeHealth timeout: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health timeout",
|
||||
detail = "No HTTP response in 3s",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Relay is not responding (3s timeout)"))
|
||||
} catch (e: java.net.ConnectException) {
|
||||
Log.w(TAG, "probeHealth connect refused: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay connection refused",
|
||||
detail = e.message,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "probeHealth IO error: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = e.message ?: "Network error",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "probeHealth unexpected error: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay health failed",
|
||||
detail = e.message ?: e.javaClass.simpleName,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.ProfileConfigResponse
|
||||
import com.hermesandroid.relay.data.ProfileMemoryResponse
|
||||
import com.hermesandroid.relay.data.ProfileSkillsResponse
|
||||
import com.hermesandroid.relay.data.ProfileSoulResponse
|
||||
import com.hermesandroid.relay.data.ProfileSoulUpdateResponse
|
||||
import com.hermesandroid.relay.data.ProfileMemoryUpdateResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* HTTP client for the read-only **Profile Inspector** endpoints added in
|
||||
* the v0.7.0 relay:
|
||||
*
|
||||
* - `GET /api/profiles/{name}/config` (already live)
|
||||
* - `GET /api/profiles/{name}/skills` (already live)
|
||||
* - `GET /api/profiles/{name}/soul` (Python worker)
|
||||
* - `GET /api/profiles/{name}/memory` (Python worker)
|
||||
*
|
||||
* Mirrors the constructor shape of [RelayHttpClient] — same OkHttpClient,
|
||||
* the same `wss://` → `https://` URL-flipping trick, and the same lazy
|
||||
* session-token provider so a paired bearer token from EncryptedSharedPrefs
|
||||
* is only read when actually needed.
|
||||
*
|
||||
* All IO hops over [Dispatchers.IO] — we had a `NetworkOnMainThreadException`
|
||||
* during v0.6.0 development when an earlier client went straight from a
|
||||
* Composable effect to OkHttp without a dispatcher hop, so every path here
|
||||
* starts with `withContext(Dispatchers.IO) { ... }`.
|
||||
*
|
||||
* Profile names are URL-encoded before being spliced into the path so
|
||||
* names containing spaces or non-ASCII characters don't produce malformed
|
||||
* URLs.
|
||||
*/
|
||||
class RelayProfileInspectorClient(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val relayUrlProvider: () -> String?,
|
||||
private val sessionTokenProvider: suspend () -> String?,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayProfileInspector"
|
||||
|
||||
/**
|
||||
* Server-side upload ceiling for SOUL.md and memory entries
|
||||
* (1 MiB). Kept as a named constant so the matching wire limit
|
||||
* in the Python worker can be moved in lockstep. We use it to
|
||||
* translate a generic 413 into a friendlier error message.
|
||||
*/
|
||||
private const val SOUL_MAX_BYTES: Long = 1024L * 1024L
|
||||
|
||||
/**
|
||||
* JSON media type used for all PUT requests. Hoisted to a
|
||||
* constant so we don't re-parse it on every write.
|
||||
*/
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// Lenient + ignore unknown keys so if the Python worker adds a
|
||||
// field later we don't fail to deserialize the whole payload.
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/config`. */
|
||||
suspend fun fetchConfig(profileName: String): Result<ProfileConfigResponse> =
|
||||
get(profileName, "config", ProfileConfigResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/skills`. */
|
||||
suspend fun fetchSkills(profileName: String): Result<ProfileSkillsResponse> =
|
||||
get(profileName, "skills", ProfileSkillsResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/soul`. */
|
||||
suspend fun fetchSoul(profileName: String): Result<ProfileSoulResponse> =
|
||||
get(profileName, "soul", ProfileSoulResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/memory`. */
|
||||
suspend fun fetchMemory(profileName: String): Result<ProfileMemoryResponse> =
|
||||
get(profileName, "memory", ProfileMemoryResponse.serializer())
|
||||
|
||||
/**
|
||||
* `PUT /api/profiles/{name}/soul` with body `{"content": "..."}`.
|
||||
*
|
||||
* Server-side contract:
|
||||
* - 200: content written; returns [ProfileSoulUpdateResponse]
|
||||
* - 404: profile not found
|
||||
* - 413: body exceeds 1 MiB size limit
|
||||
* - 401/403: unauthorized — re-pair
|
||||
*
|
||||
* The [content] may be any UTF-8 string including empty (to blank the
|
||||
* file) — the server does not enforce non-emptiness. A `null` content
|
||||
* would be a protocol violation; we send empty-string for an empty
|
||||
* SOUL.
|
||||
*/
|
||||
suspend fun updateSoul(
|
||||
profileName: String,
|
||||
content: String,
|
||||
): Result<ProfileSoulUpdateResponse> = withContext(Dispatchers.IO) {
|
||||
val bodyPayload = buildJsonObject { put("content", content) }
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
bodyPayload,
|
||||
)
|
||||
put(
|
||||
profileName = profileName,
|
||||
segment = "soul",
|
||||
body = bodyJson,
|
||||
deserializer = ProfileSoulUpdateResponse.serializer(),
|
||||
maxBytesHint = SOUL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /api/profiles/{name}/memory/{filename}` with body
|
||||
* `{"content": "..."}`.
|
||||
*
|
||||
* Filenames are validated both locally (by the caller — we expect
|
||||
* `.md` suffix, no traversal) and server-side. A bad filename yields
|
||||
* a 400 from the relay.
|
||||
*
|
||||
* Used for both creating a new memory entry (the relay writes the
|
||||
* file if missing) and updating an existing entry.
|
||||
*/
|
||||
suspend fun updateMemoryEntry(
|
||||
profileName: String,
|
||||
filename: String,
|
||||
content: String,
|
||||
): Result<ProfileMemoryUpdateResponse> = withContext(Dispatchers.IO) {
|
||||
val bodyPayload = buildJsonObject { put("content", content) }
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
bodyPayload,
|
||||
)
|
||||
val encodedFilename = URLEncoder.encode(filename, "UTF-8").replace("+", "%20")
|
||||
put(
|
||||
profileName = profileName,
|
||||
segment = "memory/$encodedFilename",
|
||||
body = bodyJson,
|
||||
deserializer = ProfileMemoryUpdateResponse.serializer(),
|
||||
// Memory entries share the same 1MiB ceiling server-side —
|
||||
// no documented difference, so we report the same soft hint
|
||||
// in the error message.
|
||||
maxBytesHint = SOUL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared PUT-body-and-parse path for the two update endpoints
|
||||
* (SOUL + memory). Centralized so we reuse the same URL builder,
|
||||
* session-token plumbing, and error mapping. Kept separate from
|
||||
* [get] rather than generalized over the HTTP method because the
|
||||
* body/response semantics (413, 400 invalid filename) are specific
|
||||
* to the update side.
|
||||
*/
|
||||
private suspend fun <T> put(
|
||||
profileName: String,
|
||||
segment: String,
|
||||
body: String,
|
||||
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
|
||||
maxBytesHint: Long = SOUL_MAX_BYTES,
|
||||
): Result<T> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val encodedName = URLEncoder.encode(profileName, "UTF-8").replace("+", "%20")
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/profiles/$encodedName/$segment".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.put(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
400 -> {
|
||||
// Relay emits 400 for invalid filename or
|
||||
// malformed JSON. Surface the response body
|
||||
// when present so the user sees the specific
|
||||
// validation error.
|
||||
val bodyText = response.body?.string().orEmpty()
|
||||
if (bodyText.isNotBlank()) {
|
||||
"Invalid request: ${extractErrorDetail(bodyText)}"
|
||||
} else {
|
||||
"Invalid request"
|
||||
}
|
||||
}
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
404 -> "Profile '$profileName' not found on relay"
|
||||
413 -> "Content too large — max ${maxBytesHint / 1024} KiB"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
|
||||
val bodyText = response.body?.string().orEmpty()
|
||||
if (bodyText.isBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = try {
|
||||
json.decodeFromString(deserializer, bodyText)
|
||||
} catch (e: SerializationException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Malformed response from relay: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "$segment write failed for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$segment write unexpected error for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /api/skills/toggle` with body `{"name": "...", "enabled": true/false}`.
|
||||
*
|
||||
* Current relay stubs this out — returns 501 with
|
||||
* `{"error": "skill_toggle_not_implemented", "detail": "..."}`. The
|
||||
* UI uses the distinctive 501 to show a "not supported on this
|
||||
* server" snackbar and ghost out the toggle. When the real
|
||||
* implementation lands server-side, this method needs no change.
|
||||
*/
|
||||
suspend fun updateSkillToggle(
|
||||
skillName: String,
|
||||
enabled: Boolean,
|
||||
): Result<SkillToggleResult> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/skills/toggle".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("name", skillName)
|
||||
put("enabled", enabled)
|
||||
}
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.put(bodyJson.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
in 200..299 -> Result.success(SkillToggleResult.Ok)
|
||||
501 -> Result.success(SkillToggleResult.NotImplemented)
|
||||
401, 403 -> Result.failure(
|
||||
IOException("Unauthorized — re-pair with the relay")
|
||||
)
|
||||
else -> Result.failure(
|
||||
IOException("Relay returned HTTP ${response.code}")
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "skill toggle failed for $skillName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability probe for the skill-toggle endpoint — HEAD / OPTIONS
|
||||
* would be the ideal choice but we need to know specifically if the
|
||||
* server responds 501 vs 200, which is only visible on PUT. We
|
||||
* send a no-op PUT with `enabled = true` against a placeholder
|
||||
* skill name that the server treats as a probe ping — relays that
|
||||
* implement the endpoint accept it; stubbed relays return 501.
|
||||
*
|
||||
* In practice we don't want this probe to have side effects, so we
|
||||
* use the HTTP OPTIONS verb instead and treat a 501 response as
|
||||
* "not implemented" and any 2xx as "supported". The relay serves
|
||||
* OPTIONS via aiohttp's CORS handling by default.
|
||||
*/
|
||||
suspend fun probeSkillToggleSupported(): Boolean = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) return@withContext false
|
||||
val sessionToken = sessionTokenProvider() ?: return@withContext false
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/skills/toggle".toHttpUrl()
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
// OPTIONS probe. Relays that don't mount the handler return 404
|
||||
// or the default 405 method-not-allowed; stubbed-implementation
|
||||
// relays return 501 from the PUT handler but allow OPTIONS.
|
||||
// A 2xx/3xx OPTIONS does NOT confirm PUT works (the server
|
||||
// might still 501 on the real call), so a successful OPTIONS
|
||||
// here means "worth trying". A 501 response on OPTIONS (rare)
|
||||
// is definitive "not supported".
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.method("OPTIONS", null)
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
501 -> false
|
||||
404, 405 -> false
|
||||
// 401/403 — we don't know; default to true (don't
|
||||
// ghost the toggle over an auth problem, let the
|
||||
// PUT fail and snackbar through the normal path).
|
||||
401, 403 -> true
|
||||
else -> response.isSuccessful
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// Network / offline — assume supported; PUT will surface
|
||||
// the real failure.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a skill-toggle PUT. Kept as a small sealed class so
|
||||
* the caller can distinguish "server accepted it" from "server
|
||||
* answered 501 — not implemented yet" without inventing magic
|
||||
* error strings.
|
||||
*/
|
||||
sealed class SkillToggleResult {
|
||||
data object Ok : SkillToggleResult()
|
||||
data object NotImplemented : SkillToggleResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort pull of a `detail` or `error` string out of a relay
|
||||
* 400 body. Falls back to the first 120 chars of the payload when
|
||||
* the response isn't JSON-shaped.
|
||||
*/
|
||||
private fun extractErrorDetail(body: String): String {
|
||||
return try {
|
||||
val obj = json.parseToJsonElement(body) as? kotlinx.serialization.json.JsonObject
|
||||
?: return body.take(120)
|
||||
val detail = (obj["detail"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
val error = (obj["error"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
detail ?: error ?: body.take(120)
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared GET-and-parse path for all four endpoints. Centralizing here
|
||||
* keeps the error-mapping consistent (404 profile-not-found, 401
|
||||
* re-pair, 5xx server error, etc.) without four near-identical copies.
|
||||
*/
|
||||
private suspend fun <T> get(
|
||||
profileName: String,
|
||||
segment: String,
|
||||
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
|
||||
): Result<T> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
// Percent-encode the profile name for splicing into the path —
|
||||
// profile names are typically ASCII identifiers but nothing
|
||||
// structurally forbids spaces or non-ASCII.
|
||||
// URLEncoder encodes spaces as `+` which is wrong for paths; swap
|
||||
// back to `%20` after encoding.
|
||||
val encodedName = URLEncoder.encode(profileName, "UTF-8").replace("+", "%20")
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/profiles/$encodedName/$segment".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
404 -> "Profile '$profileName' not found on relay"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = try {
|
||||
json.decodeFromString(deserializer, body)
|
||||
} catch (e: SerializationException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Malformed response from relay: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "$segment fetch failed for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$segment fetch unexpected error for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Derives the conventional Hermes-Relay WSS/WS URL from a Hermes API URL.
|
||||
*
|
||||
* Hermes API and Relay are separate processes, but normal installs expose
|
||||
* them on the same host with API on 8642 and Relay on 8767. Keeping this
|
||||
* logic centralized lets setup flows treat the Relay URL as "Auto" by
|
||||
* default while still allowing a manual override for custom reverse proxies.
|
||||
*/
|
||||
object RelayUrlDeriver {
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
fun deriveFromApiUrl(apiUrl: String, relayPort: Int = DEFAULT_RELAY_PORT): String? {
|
||||
val trimmed = apiUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val relayScheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "ws"
|
||||
"https" -> "wss"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$relayScheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedRelayUrl(relayUrl: String, apiUrl: String): Boolean {
|
||||
val trimmed = relayUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return true
|
||||
if (isDefaultLocalRelayUrl(trimmed)) return true
|
||||
|
||||
val derived = deriveFromApiUrl(apiUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isDefaultLocalRelayUrl(relayUrl: String): Boolean {
|
||||
val uri = runCatching { URI(relayUrl.trim()) }.getOrNull() ?: return false
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme != "ws" && scheme != "wss") return false
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
val port = if (uri.port == -1) {
|
||||
when (scheme) {
|
||||
"ws" -> 80
|
||||
"wss" -> 443
|
||||
else -> -1
|
||||
}
|
||||
} else {
|
||||
uri.port
|
||||
}
|
||||
return port == DEFAULT_RELAY_PORT &&
|
||||
(host == "localhost" || host == "127.0.0.1" || host == "::1")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface VoiceAudioClient {
|
||||
val route: VoiceAudioRoute
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
}
|
||||
|
||||
class RelayVoiceAudioClientAdapter(
|
||||
private val relayVoiceClient: RelayVoiceClient,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Relay
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
relayVoiceClient.transcribe(audioFile)
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
relayVoiceClient.synthesize(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes each STT/TTS call to the Standard (dashboard) or Relay voice client.
|
||||
*
|
||||
* Auto preference order is **Relay first, then Standard**: a paired Relay is
|
||||
* the purpose-built mobile facade — profile-aware voice config, no dashboard
|
||||
* sign-in dependency — so users who installed the plugin keep the richer
|
||||
* path. Standard is the zero-plugin route for vanilla Hermes installs and is
|
||||
* used whenever Relay isn't configured/paired (or fails mid-call). Power
|
||||
* users can force either route in Voice Settings.
|
||||
*/
|
||||
class AutoVoiceAudioClient(
|
||||
private val standardClient: VoiceAudioClient,
|
||||
private val relayClient: VoiceAudioClient,
|
||||
private val routeProvider: () -> VoiceAudioRoute,
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
runWithSelectedRoute { it.transcribe(audioFile) }
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
IllegalStateException(
|
||||
"Standard Hermes voice is not available — check dashboard sign-in in Manage",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
block(standardClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Relay -> {
|
||||
if (!relayReadyProvider()) {
|
||||
Result.failure(IllegalStateException("Relay voice is not available"))
|
||||
} else {
|
||||
block(relayClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Auto -> runAuto(block)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> runAuto(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
var relayFailure: Result<T>? = null
|
||||
if (relayReadyProvider()) {
|
||||
val result = block(relayClient)
|
||||
if (result.isSuccess || !standardReadyProvider()) return result
|
||||
relayFailure = result
|
||||
}
|
||||
if (standardReadyProvider()) {
|
||||
val result = block(standardClient)
|
||||
if (result.isSuccess) return result
|
||||
return relayFailure ?: result
|
||||
}
|
||||
return relayFailure ?: Result.failure(
|
||||
IllegalStateException("Voice needs a reachable Hermes dashboard or Relay voice route"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard (no-plugin) voice client — speaks the upstream **dashboard web
|
||||
* server** contract that hermes-desktop's voice mode uses:
|
||||
*
|
||||
* POST {dashboard}/api/audio/transcribe {data_url, mime_type} → {ok, transcript}
|
||||
* POST {dashboard}/api/audio/speak {text} → {ok, data_url, mime_type}
|
||||
*
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md §6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
},
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
|
||||
val dataUrl = buildAudioDataUrl(audioFile)
|
||||
val payload = buildJsonObject {
|
||||
put("data_url", dataUrl)
|
||||
put("mime_type", mediaTypeForAudioFile(audioFile))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/transcribe")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
if (transcript.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio transcribe returned an empty transcript")
|
||||
}
|
||||
transcript
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
}
|
||||
|
||||
val payload = buildJsonObject { put("text", cleanText) }
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/speak")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
}
|
||||
val mimeType = root.stringField("mime_type")
|
||||
?: root.stringField("mimeType")
|
||||
?: mimeTypeFromDataUrl(dataUrl)
|
||||
?: "audio/mpeg"
|
||||
val bytes = decodeDataUrl(dataUrl)
|
||||
if (bytes.isEmpty()) throw IOException("Hermes audio speak returned empty audio")
|
||||
|
||||
val extension = extensionForMimeType(mimeType)
|
||||
File(context.cacheDir, "hermes_voice_${System.currentTimeMillis()}.$extension")
|
||||
.also { it.writeBytes(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return Result.failure(IOException("$operation returned an empty response"))
|
||||
}
|
||||
val root = json.decodeFromString<JsonObject>(body)
|
||||
val ok = (root["ok"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toBooleanStrictOrNull()
|
||||
if (ok == false) {
|
||||
val message = root.stringField("message")
|
||||
?: root.stringField("error")
|
||||
?: "$operation failed"
|
||||
return Result.failure(IOException(message))
|
||||
}
|
||||
Result.success(root)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "network error"}", e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "parse error"}", e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val body = runCatching { response.body.string() }.getOrDefault("")
|
||||
val detail = body.takeIf { it.isNotBlank() } ?: response.message
|
||||
val message = when (response.code) {
|
||||
401, 403 -> "$operation needs dashboard sign-in - open Manage to sign in"
|
||||
404 -> "$operation unavailable on this Hermes build - update hermes-agent or use Relay"
|
||||
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
|
||||
else -> "$operation failed - HTTP ${response.code}: $detail"
|
||||
}
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun buildAudioDataUrl(audioFile: File): String {
|
||||
val mimeType = mediaTypeForAudioFile(audioFile)
|
||||
val encoded = Base64.getEncoder().encodeToString(audioFile.readBytes())
|
||||
return "data:$mimeType;base64,$encoded"
|
||||
}
|
||||
|
||||
private fun mediaTypeForAudioFile(file: File): String =
|
||||
when (file.extension.lowercase()) {
|
||||
"wav" -> "audio/wav"
|
||||
"m4a", "mp4" -> "audio/mp4"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"ogg" -> "audio/ogg"
|
||||
"webm" -> "audio/webm"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
private fun decodeDataUrl(dataUrl: String): ByteArray {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
val payload = if (comma >= 0) dataUrl.substring(comma + 1) else dataUrl
|
||||
return Base64.getDecoder().decode(payload)
|
||||
}
|
||||
|
||||
private fun mimeTypeFromDataUrl(dataUrl: String): String? {
|
||||
if (!dataUrl.startsWith("data:", ignoreCase = true)) return null
|
||||
val semi = dataUrl.indexOf(';')
|
||||
if (semi <= "data:".length) return null
|
||||
return dataUrl.substring("data:".length, semi).takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun extensionForMimeType(mimeType: String): String =
|
||||
when (mimeType.lowercase().substringBefore(';')) {
|
||||
"audio/wav", "audio/wave", "audio/x-wav" -> "wav"
|
||||
"audio/mp4", "audio/aac", "audio/m4a" -> "m4a"
|
||||
"audio/ogg" -> "ogg"
|
||||
"audio/webm" -> "webm"
|
||||
else -> "mp3"
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
val JSON_MEDIA = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user