chore: fold parallel main-worktree WIP into bridge feature branch

Folds an in-progress parallel reorg from the main worktree into the
v0.4 bridge feature branch so it ships as one release. The WIP was
never committed on main; merged via stash → feature branch.

Major surface-area changes:
- pairing UX rewrite: ConnectionWizard (+597), QrPairingScanner (+494),
  OnboardingScreen/Page (+281), new PairScreen.kt
- ScreenCapture rework (+369) — kept compatible with v0.4 smoke-tested
  bridge handlers (verified 14/16 paths green on hermes-host)
- BridgeForegroundService rework (+164)
- AuthManager additions (+82)
- new ComposeArrWorkaround util + hookup in BridgeStatusOverlay
  (additive — the v0.4 SavedStateRegistryOwner fix is preserved)
- new .githooks/ scripts, CONTRIBUTING.md, hermes-relay-doctor skill
- AGENTS.md doc rewrite (+428), assorted user-docs touch-ups
- removed legacy plugin/skills/android/SKILL.md (replaced by
  skills/devops/hermes-relay-doctor)

Conflict resolution: 4 files (README.md, install.sh, two user-docs
pages) had overlapping edits between this WIP and the feature branch.
Resolved to feature-branch HEAD (Updated upstream) per Bailey — keeps
v0.4 install.sh refspec-widening + branch-flag ergonomics + the
detailed v0.4 README capability list.

AGENTS.md.bak intentionally left untracked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-04-14 20:12:08 -04:00
co-authored by Claude Opus 4.6
parent 63a60ea077
commit 5cc8036f3c
34 changed files with 2866 additions and 635 deletions
+40
View File
@@ -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
+22
View File
@@ -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
+127
View File
@@ -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');
+372 -40
View File
@@ -1,54 +1,386 @@
# hermes-relay
<!-- @subframe-version 0.15.1-beta -->
<!-- @subframe-managed -->
# hermes-android - SubFrame Project
## Overview
This extension adds Android device control to hermes-agent via the `android` toolset.
It communicates with the Hermes-Relay app running on an Android device over WSS.
This project is managed with **SubFrame**. AI assistants should follow the rules below to keep documentation up to date.
## Setup
> **Note:** This file is named `AGENTS.md` to be AI-tool agnostic. CLAUDE.md and GEMINI.md contain a reference to this file.
### Quick start (canonical installer)
---
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
## Core Working Principle
**Only do what the user asks.** Do not go beyond the scope of the request.
- Implement exactly what the user requested — nothing more, nothing less.
- Do not change business logic, flow, or architecture unless the user explicitly asks for it.
- If a user asks for a design change, only change the design. Do not refactor, restructure, or modify functionality alongside it.
- If you have additional suggestions or improvements, **present them as suggestions** to the user. Never implement them without approval.
- The user's request must be completed first. Additional ideas come after, as proposals.
---
## Relationship to Native AI Tools
SubFrame **enhances** native AI coding tools — it does not replace them.
**Claude Code** works exactly as normal. Built-in features (`/init`, `/commit`, `/review-pr`, `/compact`, `/memory`, CLAUDE.md) are fully supported. CLAUDE.md is Claude Code's native instruction file — users can add their own tool-specific instructions freely. SubFrame adds a small backlink reference pointing to this AGENTS.md file using HTML comment markers (`<!-- SUBFRAME:BEGIN -->` / `<!-- SUBFRAME:END -->`). SubFrame will never overwrite user content in CLAUDE.md.
**Gemini CLI** works exactly as normal. Built-in features (`/init`, `/model`, `/memory`, `/compress`, `/settings`, GEMINI.md) are fully supported. GEMINI.md is Gemini CLI's native instruction file — same backlink approach as CLAUDE.md. Users can add their own instructions freely and SubFrame won't overwrite them.
**Codex CLI** gets SubFrame context via a wrapper script at `.subframe/bin/codex` that injects AGENTS.md as an initial prompt.
**This file (AGENTS.md)** contains SubFrame-specific rules that apply across all tools:
- Sub-Task management (`.subframe/tasks/*.md`, index at `.subframe/tasks.json`)
- Codebase mapping (`.subframe/STRUCTURE.json`)
- Context preservation (`.subframe/PROJECT_NOTES.md`)
- Internal docs and changelog (`.subframe/docs-internal/`)
- Session notes and decision tracking
---
## Session Start
**Read these files at the start of each session:**
1. **`.subframe/STRUCTURE.json`** — Module map, file locations, architecture notes
2. **`.subframe/PROJECT_NOTES.md`** — Project vision, past decisions, session notes
3. **`.subframe/tasks.json`** — Sub-task index (pending, in-progress, completed)
This gives you full project context before making any changes. The session-start hook (if configured) automatically injects pending/in-progress sub-tasks into your context, but you should still read these files for deeper understanding.
### Concurrent Work & Worktrees
Before making changes, check whether other AI sessions or agent teams are already working on this repository. Signs of concurrent work include:
- In-progress sub-tasks you didn't start (check `.subframe/tasks.json`)
- Recent uncommitted changes in `git status` that aren't yours
- Lock files or active worktrees (`git worktree list`)
**If concurrent work is detected**, ask the user: "Another session appears to be working on this project. Should I use a git worktree to avoid conflicts?"
**Git worktrees** create an isolated copy of the repo on a separate branch, allowing parallel work without merge conflicts:
- Each worktree has its own working directory and branch
- Changes in one worktree don't affect others until merged
- Use worktrees when multiple agents or sessions work on different features simultaneously
**When to suggest a worktree:**
- Agent teams spawning multiple workers on the same repo
- User asks to work on a feature while another is in progress
- The session-start hook flags concurrent sessions
**When worktrees are NOT needed:**
- Single-session work with no concurrent agents
- Read-only exploration or research tasks
- Quick fixes that won't conflict with in-progress work
---
## Hooks (Automatic Awareness)
SubFrame can configure project-level hooks that automate sub-task awareness. These hooks fire automatically — no manual intervention needed.
| Hook | When it fires | What it does |
|------|---------------|--------------|
| **SessionStart** | Startup, resume, after compaction | Injects pending/in-progress sub-tasks into context |
| **UserPromptSubmit** | Each user prompt | Fuzzy-matches prompt against pending sub-tasks, suggests starting a match |
| **Stop** | When AI finishes responding | Reminds about in-progress sub-tasks; flags untracked work if source files changed |
| **PreToolUse** | Before tool execution | Project-specific guardrails (if configured) |
| **PostToolUse** | After tool execution | Project-specific follow-ups (if configured) |
These hooks ensure sub-task awareness even after context compaction. Hook configuration lives in `.claude/settings.json`.
---
## Skills (Slash Commands)
SubFrame provides optional slash commands for AI coding tools that support them (e.g., Claude Code):
| Skill | Purpose |
|-------|---------|
| `/sub-tasks` | Interactive sub-task management — list, start, complete, add, archive |
| `/sub-docs` | Sync all SubFrame documentation after feature work (changelog, CLAUDE.md, PROJECT_NOTES, STRUCTURE) |
| `/sub-audit` | Code review + documentation audit on recent changes |
| `/onboard` | Bootstrap SubFrame files from existing codebase context |
Skills are deployed to `.claude/skills/` and enhance the workflow — but direct file editing always works as a fallback. If your AI tool doesn't support skills, follow the manual instructions in each section below.
---
## Sub-Task Management
> **Terminology:** "Sub-Tasks" are SubFrame's project task tracking system. The name plays on "Sub" from SubFrame and disambiguates from Claude Code's internal todo tools. When the user says "sub-task", they mean this system.
### Sub-Task File Format
Each sub-task lives in its own markdown file at `.subframe/tasks/<id>.md` with YAML frontmatter:
```yaml
---
id: task-abc12345
title: Short and clear title (max 60 characters)
status: pending | in_progress | completed
priority: high | medium | low
category: feature | fix | refactor | docs | test | chore
description: AI's detailed explanation — what, how, which files affected
userRequest: User's original prompt/request — copy exactly
acceptanceCriteria: When is this task done? Concrete testable criteria
blockedBy: [] # task IDs this depends on
blocks: [] # task IDs that depend on this
createdAt: ISO timestamp
updatedAt: ISO timestamp
completedAt: ISO timestamp | null
---
## Notes
[YYYY-MM-DD] Session notes, alternatives considered, dependencies.
## Steps
- [x] Completed step
- [ ] Pending step
```
This clones the repo to `~/.hermes/hermes-relay/`, `pip install -e`s the package into the hermes-agent venv, registers the clone's `skills/` directory in `~/.hermes/config.yaml` under `skills.external_dirs`, symlinks the plugin into `~/.hermes/plugins/hermes-relay`, and installs a `hermes-pair` shell shim into `~/.local/bin/`. Restart hermes-agent and everything is live. Updates are a `git pull` inside `~/.hermes/hermes-relay/`.
A generated index is kept at `.subframe/tasks.json` for hooks and quick lookups. After creating or modifying task `.md` files, regenerate the index by reading all `.subframe/tasks/*.md` files (excluding `archive/`) and building the JSON with tasks grouped by status.
See [docs/relay-server.md](docs/relay-server.md) for Docker, systemd, TLS, and configuration options.
### Sub-Task Recognition Rules
### Full setup
1. Install the Hermes-Relay APK on the Android device (build via `scripts/dev.bat build`)
2. Grant the app Accessibility Service permission in Settings > Accessibility
3. Grant SYSTEM_ALERT_WINDOW permission
4. Run the installer (above) and restart hermes-agent
5. Start the relay server if you need terminal/bridge: `python -m plugin.relay --no-ssl`
6. Pair the phone: type `/hermes-relay-pair` in any Hermes chat surface, or run `hermes-pair` from a shell. **Note:** the top-level `hermes pair` sub-command is not currently exposed — upstream argparser doesn't forward to plugin CLI dicts. Use the slash command or the dashed shim.
**These ARE SUB-TASKS:**
- When the user requests a feature or change
- Decisions like "Let's do this", "Let's add this", "Improve this"
- Deferred work: "We'll do this later", "Let's leave it for now"
- Gaps or improvement opportunities discovered while coding
- Situations requiring bug fixes
## Tool usage patterns
**These are NOT SUB-TASKS:**
- Error messages and debugging sessions
- Questions, explanations, information exchange
- Temporary experiments and tests
- Work already completed and closed
- Instant fixes (like typo fixes)
### Read before act
ALWAYS call android_read_screen before tapping. Never guess coordinates.
### Sub-Task Creation Flow
### Prefer text over coordinates
Use android_tap_text("Continue") over android_tap(x=540, y=1200).
1. Detect sub-task patterns during conversation
2. **Check existing sub-tasks first** — read `.subframe/tasks.json` to avoid duplicates
3. Ask the user: "I identified these sub-tasks from our conversation, should I add them?"
4. If approved, create `.subframe/tasks/<id>.md` with all required frontmatter fields
5. Regenerate the `.subframe/tasks.json` index
### Wait after navigation
After opening an app or tapping a button that triggers loading,
always call android_wait with expected text before next action.
### Sub-Task Content Rules
### Confirmation pattern for destructive actions
Before confirming a purchase, ride, or send action — always report
to the user what you're about to do and wait for approval.
Example: "I'm about to confirm an Uber ride to [destination] for [price].
Reply 'yes' to confirm."
**title:** Short, action-oriented
- OK: "Add tasks button to terminal toolbar"
- Bad: "Tasks"
## Common package names
- com.ubercab — Uber
- com.bolt.client — Bolt
- com.whatsapp — WhatsApp
- com.spotify.music — Spotify
- com.google.android.apps.maps — Google Maps
- com.android.chrome — Chrome
- com.google.android.gm — Gmail
- com.instagram.android — Instagram
- com.twitter.android — X/Twitter
**description:** AI's detailed technical explanation
- What will be done, how, which files affected
- Minimum 2-3 sentences
**userRequest:** User's original words — copy verbatim for context preservation
**acceptanceCriteria:** Concrete, testable completion criteria
### Sub-Task Status Updates
**Before starting any work**, check `.subframe/tasks.json` for an existing sub-task that matches. If found, set it to `in_progress` — do not create a duplicate.
- `pending` → `in_progress` — immediately when you begin working (update `updatedAt`)
- `in_progress` → `completed` — when done and verified (set `completedAt`, update `updatedAt`)
- `completed` → `pending` — when reopening, add a note explaining why
- After commit: check and update the status of all related sub-tasks
- **Incomplete work:** If partially done at session end, leave as `in_progress` and add a notes entry
### Sub-Task Lifecycle
- If a sub-task grows beyond its original scope, split it — create new sub-tasks and reference the parent ID in notes
- Cross-reference relevant commit hashes or PR numbers in notes
- Update the description if the approach changes significantly
### Priority Guidelines
- **high** — Blocking other work or explicitly flagged as urgent by the user
- **medium** — Normal feature work and standard bug fixes
- **low** — Nice-to-have improvements, deferred items, minor polish
---
## .subframe/PROJECT_NOTES.md Rules
### When to Update?
- When an important architectural decision is made
- When a technology choice is made
- When an important problem is solved and the solution method is noteworthy
- When an approach is determined together with the user
### Format
Free format. Date + title is sufficient:
```markdown
### [YYYY-MM-DD] Topic title
Conversation/decision as is, with its context...
```
### Update Flow
- Update immediately after a decision is made
- You can add without asking the user (for important decisions)
- You can accumulate small decisions and add them in bulk
### Organization Rules
- Keep **"Project Vision"** at the top, then **"Session Notes"** in chronological order
- Notes should capture the **why** (decisions, trade-offs, alternatives rejected), not the **what** (code structure belongs in STRUCTURE.json)
- When the same topic spans multiple sessions, consolidate related notes under the original heading rather than creating duplicates
- When notes grow beyond ~500 lines, consider archiving older session notes or grouping by month
---
## Context Preservation (Automatic Note Taking)
SubFrame's core purpose is to prevent context loss. Capture important moments and ask the user.
### When to Ask?
Ask the user: **"Should I add this to .subframe/PROJECT_NOTES.md?"** when:
- A sub-task is successfully completed
- An important architectural/technical decision is made
- A bug is fixed and the solution method is noteworthy
- "Let's do this later" is said (also add as a sub-task)
- A new pattern or best practice is discovered
### Importance Threshold
**Would it take more than 5 minutes to re-derive or re-explain in a future session?** If yes, capture it.
**Always capture:** Architecture decisions, technology choices, approach changes, user preferences discovered during work.
**Never capture:** Routine debugging steps, simple config changes, typo fixes.
**Note failed approaches too** — a brief "We tried X, it didn't work because Y" prevents future re-exploration of dead ends.
### Completion Detection
Pay attention to these signals:
- User approval: "okay", "done", "it worked", "nice", "fixed", "yes"
- Moving from one topic to another
- User continuing after build/run succeeds
### How to Add?
1. **DON'T write a summary** — Add the conversation as is, with its context
2. **Add date** — In `### [YYYY-MM-DD] Title` format
3. **Add to Session Notes section** — At the end of PROJECT_NOTES.md
### When NOT to Ask
- For every small change (it becomes spam)
- Typo fixes, simple corrections
- If the user already said "no" or "not needed", don't ask again for that topic
### If User Says "No"
No problem, continue. The user can also say what they consider important themselves: "add this to notes"
---
## .subframe/STRUCTURE.json Rules
**This file is the map of the codebase.**
### When to Update?
- When a new file/folder is created
- When a file/folder is deleted or moved
- When module dependencies change
- When an IPC channel is added or changed
- When an important architectural pattern is discovered (architectureNotes)
### Full Schema
```json
{
"modules": {
"main/moduleName": {
"file": "src/main/moduleName.ts",
"description": "What this module does",
"exports": ["init", "loadData"],
"depends": ["fs", "path", "shared/ipcChannels"],
"functions": {
"init": { "line": 15 },
"loadData": { "line": 42 }
}
}
},
"ipcChannels": {
"CHANNEL_NAME": {
"direction": "renderer → main",
"handler": "main/moduleName"
}
},
"architectureNotes": {
"topicName": {
"issue": "Description of the pattern or concern",
"solution": "How it was resolved"
}
}
}
```
### Update Rules
- The pre-commit hook (if configured) auto-updates STRUCTURE.json when source files in `src/` are committed
- When deleting files, remove their entries from `modules` and update any `depends` arrays that referenced them
- When adding IPC channels, also add them to the `ipcChannels` section with `direction` and `handler`
- `architectureNotes` is for **structural patterns** (e.g., circular dependency workarounds, init ordering). Use PROJECT_NOTES.md for **decisions and session context**
- If function line numbers drift significantly after edits, re-run the pre-commit hook or update manually
---
## .subframe/docs-internal/ Directory
This directory holds project documentation that doesn't belong in the root:
| File | Purpose |
|------|---------|
| `changelog.md` | Track changes under `## [Unreleased]`, grouped by Added/Changed/Fixed/Removed |
| `*.md` (ADRs) | Architecture Decision Records for significant design choices |
**What goes here:** Changelog entries, architecture decision records, internal reference docs.
**What does NOT go here:** User-facing docs (those go in `docs/` or project root), task files (those go in `.subframe/tasks/`).
---
## .subframe/QUICKSTART.md Rules
### When to Update?
- When installation steps change
- When new requirements are added
- When important commands change
---
## Before Ending Work
After significant work (code changes, architecture decisions), verify SubFrame files are in sync:
1. **Sub-Tasks** — Was this work tracked? Check `.subframe/tasks.json` → create/complete as needed
2. **PROJECT_NOTES.md** — Any decisions worth preserving? Ask the user
3. **Changelog** — Does `.subframe/docs-internal/changelog.md` reflect the changes?
4. **STRUCTURE.json** — Source files changed? The pre-commit hook handles this automatically if configured; otherwise update manually
The stop hook (if configured) will flag untracked work automatically.
---
## General Rules
1. **Language:** Write documentation in English (except code examples)
2. **Date Format:** ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)
3. **After Commit:** Check sub-tasks (`.subframe/tasks/*.md`) and `.subframe/STRUCTURE.json`
4. **Session Start:** Read STRUCTURE.json, PROJECT_NOTES.md, and tasks.json before making changes
5. **Don't Duplicate:** Always check existing sub-tasks before creating new ones
---
*This file was automatically created by SubFrame.*
*Creation date: 2026-04-14*
<!-- subframe-template-version: 1 -->
+9 -9
View File
@@ -107,12 +107,12 @@ dialing, and location awareness.
### Added
**Phase 3 — Bridge channel (the big one)** — the agent can now read the
phone's screen, tap, type, swipe, and take screenshots. Gated behind a
**Bridge channel (the big one)** — the agent can now read the phone's
screen, tap, type, swipe, and take screenshots. Gated behind a
deliberate in-app master toggle, per-channel session grants, Android
Accessibility Service permission, MediaProjection consent, and Tier 5
safety rails (blocklist, destructive-verb confirmation modal, idle
auto-disable timer, optional persistent status overlay).
Accessibility Service permission, MediaProjection consent, and the
safety rails system (blocklist, destructive-verb confirmation modal,
idle auto-disable timer, optional persistent status overlay).
- **`HermesAccessibilityService`** — Android `AccessibilityService`
subclass that reads the active window's UI tree, dispatches taps /
@@ -121,7 +121,7 @@ auto-disable timer, optional persistent status overlay).
- **`ScreenCapture.kt`** — `MediaProjection` → `VirtualDisplay` →
`ImageReader` → PNG bytes, uploaded to the relay via `/media/upload`
- **`BridgeCommandHandler`** — routes inbound `bridge.command` envelopes
to the executor, with the three-stage Tier 5 safety check
to the executor, with the three-stage safety check
(blocklist → destructive-verb confirmation → auto-disable reschedule)
- **`BridgeSafetyManager`** — process-wide safety enforcement singleton
with DataStore-backed blocklist (30 default banking/payments/2FA
@@ -157,7 +157,7 @@ auto-disable timer, optional persistent status overlay).
info, Test Voice)
- New relay endpoints — `POST /voice/transcribe`, `POST /voice/synthesize`,
`GET /voice/config`
- **Voice-to-bridge intent routing** (sideload track only, Tier 3) —
- **Voice-to-bridge intent routing** (sideload track only) —
spoken commands like "text Mom saying on my way" route to the bridge
channel instead of the chat channel, with destructive-verb
confirmation flow
@@ -205,7 +205,7 @@ badges showed stale Connected/Disconnected for 30s after foregrounding.
**Two build flavors** — `googlePlay` (Play Store track, conservative
Accessibility use case) and `sideload` (`.sideload` applicationId
suffix, full Phase 3 tiers including voice-to-bridge and
suffix, full feature set including voice-to-bridge intents and
`android_navigate`). `sideload` shows as "Hermes Dev" in the launcher
for side-by-side disambiguation.
@@ -307,7 +307,7 @@ picker.
- Voice messages appear as normal chat messages in session history
- **Reactive layered-sine waveform** — three overlapping waves with amplitude-driven phase velocity (`withFrameNanos` ticker), pill-shaped edge merge (geometric `sin(πt)` taper + `BlendMode.DstIn` gradient mask), color-keyed to voice state
- **Enter/exit voice chimes** — synthesized 200ms PCM sweeps via AudioTrack (440→660 Hz enter, mirror exit)
- **Terminal (Phase 2)** — tmux-backed persistent shells with tabs, scrollback search, and session info sheet
- **Terminal (preview)** — tmux-backed persistent shells with tabs, scrollback search, and session info sheet
- **Session TTL picker** — choose 1d / 7d / 30d / 90d / 1y / Never at pair time
- **Per-channel grants** — control terminal/bridge access per paired device
- **Android Keystore token storage** — StrongBox-preferred hardware-backed encrypted storage with TEE fallback
+109
View File
@@ -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 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.
## Commit Conventions
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
Feature branches are the house style — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — merged into `main` via `--no-ff` merge commits so the per-branch history stays visible in `git log --graph`. Straight-to-`main` is reserved for single-file typo fixes.
Release-prep commits (version bump + tag) are allowed to push directly to `main` via a branch-protection carve-out — see [RELEASE.md](RELEASE.md) for the full release process.
## Testing
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
CI (`.github/workflows/ci.yml`) runs lint, Android build, Android unit tests, and a Python relay syntax check on every push.
## 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.
+10 -10
View File
@@ -1,9 +1,9 @@
# Hermes-Relay v0.3.0
**Release Date:** April 13, 2026
**Since v0.2.0:** 56 commits · 8 Phase 3 feature merges · 2 new build flavors · 1 new agent-control channel
**Since v0.2.0:** 56 commits · 8 major feature merges · 2 new build flavors · 1 new agent-control channel
> **Phase 3 — Bridge.** The agent can now read your screen, tap, type, swipe, and take screenshots on your phone — gated behind a five-stage safety-rails system, a master toggle, the Android Accessibility Service, MediaProjection consent, and per-channel session grants. Plus full voice-mode polish, a notification companion, and two new agent-introspection tools so the agent stops flying blind.
> **Bridge channel.** The agent can now read your screen, tap, type, swipe, and take screenshots on your phone — gated behind a five-stage safety rails system, a master toggle, the Android Accessibility Service, MediaProjection consent, and per-channel session grants. Plus full voice-mode polish, a notification companion, and two new agent-introspection tools so the agent stops flying blind.
---
@@ -13,20 +13,20 @@ v0.3.0 ships in **two build flavors**. APK filenames are version-tagged, so ever
| Flavor | File | Who it's for |
|---|---|---|
| **sideload** (recommended) | `hermes-relay-0.3.0-sideload-release.apk` | Full Phase 3 stack — bridge channel, voice-to-bridge intents (Tier 3), vision-driven `android_navigate` (Tier 4). Installs alongside the Play Store build with a `.sideload` applicationId. |
| **Google Play** | `hermes-relay-0.3.0-googlePlay-release.aab` | Uploaded to Play Console for Internal testing. Conservative Tier 1+2+5 feature set (chat, voice, safety rails) to match Play Store's Accessibility policy. |
| **sideload** (recommended) | `hermes-relay-0.3.0-sideload-release.apk` | Full feature set — bridge channel, voice-to-bridge intents, vision-driven `android_navigate`. Installs alongside the Play Store build with a `.sideload` applicationId. |
| **Google Play** | `hermes-relay-0.3.0-googlePlay-release.aab` | Uploaded to Play Console for Internal testing. Conservative feature set (chat, voice, safety rails — no agent device control) to match Play Store's Accessibility policy. |
| googlePlay APK | `hermes-relay-0.3.0-googlePlay-release.apk` | Parity + diff tooling — not the primary download. |
| sideload AAB | `hermes-relay-0.3.0-sideload-release.aab` | Parity + diff tooling — not the primary download. |
**Verify integrity** with `SHA256SUMS.txt` from the same release before installing. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for the step-by-step install walkthrough.
> **Why two flavors?** The `googlePlay` build stays inside Play Store's Accessibility Service policy review. The `sideload` build unlocks the full Phase 3 stack and installs with a `.sideload` applicationId suffix so both can coexist on the same device — the sideload launcher is labelled **"Hermes Dev"** for disambiguation.
> **Why two flavors?** The `googlePlay` build stays inside Play Store's Accessibility Service policy review. The `sideload` build unlocks the full agent-control feature set and installs with a `.sideload` applicationId suffix so both can coexist on the same device — the sideload launcher is labelled **"Hermes Dev"** for disambiguation. For a capability-by-capability breakdown, see the [Release tracks comparison](https://codename-11.github.io/hermes-relay/guide/release-tracks.html).
---
## ✨ Highlights
- **Phase 3 Bridge Channel** — The agent can read the phone's screen, tap, type, swipe, and take screenshots via a new `HermesAccessibilityService` + `MediaProjection` pipeline. Five independent safety gates must all be green before a single command executes: session grant → master toggle → Accessibility permission → MediaProjection consent → Tier 5 safety rails.
- **Bridge Channel** — The agent can read the phone's screen, tap, type, swipe, and take screenshots via a new `HermesAccessibilityService` + `MediaProjection` pipeline. Five independent safety gates must all be green before a single command executes: session grant → master toggle → Accessibility permission → MediaProjection consent → safety rails.
- **Voice Mode polish** — Full-screen voice UI with an ASCII morphing sphere (Listening = blue/purple, Speaking = green/teal), reactive layered-sine waveform visualizer, pill-edge merge, tap/hold/continuous interaction modes, and sentence-boundary streaming through a TTS queue. Backed by three new relay endpoints — `POST /voice/transcribe`, `POST /voice/synthesize`, `GET /voice/config` — with 6 TTS and 5 STT providers available via `~/.hermes/config.yaml`.
@@ -46,7 +46,7 @@ v0.3.0 ships in **two build flavors**. APK filenames are version-tagged, so ever
---
## 📱 Phase 3 Bridge Channel
## 📱 Bridge Channel
The headline feature. Everything in this section is gated behind the five-stage safety system documented above.
@@ -57,7 +57,7 @@ The headline feature. Everything in this section is gated behind the five-stage
- **Activity log** — tap-to-expand entries with timestamps, status, result text, and optional screenshot tokens (capped at 100 entries)
- **Safety summary card** — live countdown to auto-disable, blocklist/verb counts at a glance
### Tier 5 Safety Rails
### Safety Rails
- **App blocklist** — 30 default banking / payments / password-manager / 2FA apps pre-seeded; searchable `PackageManager.queryIntentActivities(CATEGORY_LAUNCHER)` picker for custom entries
- **Destructive-verb confirmation modal** — word-boundary regex match against `/tap_text` + `/type` payloads. Default verbs: `send`, `pay`, `delete`, `transfer`, `confirm`, `submit`, `post`, `publish`, `buy`, `purchase`, `charge`, `withdraw`. Modal rendered via a `WindowManager` overlay so it's visible even when Hermes isn't in the foreground.
- **Idle auto-disable** — 5-120 min slider. Any command resets the timer; process death clears state so a stale grant can't survive a crash.
@@ -150,7 +150,7 @@ The headline feature. Everything in this section is gated behind the five-stage
## 📚 Documentation & Skills
- **Installer README + DEVLOG update** — canonical update cycle documented top-to-bottom
- **`docs/spec.md` + `docs/decisions.md`** — Phase 3 pipeline, safety rails architecture, two-flavor rationale
- **`docs/spec.md` + `docs/decisions.md`** — bridge pipeline, safety rails architecture, two-flavor rationale
- **`/hermes-relay-self-setup` skill** — single-source agent-readable install recipe (dual-mode: pre-install via raw URL, post-install via slash command)
- **`/hermes-relay-pair` skill** — canonical category layout (`devops`), matches `metadata.hermes.category` frontmatter
- **`user-docs` flavor comparison** — "Which build should I pick?" decision guide on the Release Tracks page
@@ -160,7 +160,7 @@ The headline feature. Everything in this section is gated behind the five-stage
## 👥 Contributors
Primary development by **@Codename-11** (Bailey Dixon). Agent-team branches (Phase 3 α–θ) were implemented by Claude Code working on isolated feature branches with `--no-ff` merges — each branch name encodes which agent shipped it.
Primary development by **@Codename-11** (Bailey Dixon). Implementation assisted by Claude Code on isolated feature branches with `--no-ff` merges to preserve the per-component history.
Dependency bumps via Dependabot: markdown-renderer, gradle-wrapper, haze, camera, kotlinx-coroutines-test.
-64
View File
@@ -1,4 +1,3 @@
import java.io.File
import java.util.Properties
plugins {
@@ -245,66 +244,3 @@ dependencies {
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")
}
}
@@ -1,13 +1,31 @@
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.data.AppAnalytics
import com.hermesandroid.relay.power.WakeLockManager
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
@@ -15,9 +15,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.hermesandroid.relay.accessibility.MediaProjectionHolder
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
import com.hermesandroid.relay.bridge.BridgeForegroundService
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.ComposeArrWorkaround
import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -31,9 +32,15 @@ class MainActivity : ComponentActivity() {
// declared as a property (registerForActivityResult is safe to call
// from a property initializer on ComponentActivity).
//
// The result is forwarded to MediaProjectionHolder.onGranted, which
// wraps the Intent in a MediaProjection instance and stores it for
// ScreenCapture.kt to consume on the next /screenshot bridge command.
// We do NOT call MediaProjectionHolder directly from here. On Android
// 14+, getMediaProjection() must run from inside a foreground service
// that has already called startForeground(type=mediaProjection), and
// that startForeground call must happen AFTER consent. So we hand the
// result off to BridgeForegroundService, which:
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
// 3. Stores the projection in the holder's StateFlow
// BridgeViewModel observes that flow and refreshes the UI immediately.
//
// ScreenCaptureRequester is a process-singleton rendezvous so the
// BridgeViewModel (which has no Activity reference) can ask us to
@@ -41,10 +48,13 @@ class MainActivity : ComponentActivity() {
private val mediaProjectionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val granted = MediaProjectionHolder.onGranted(
this, result.resultCode, result.data
)
Log.i(TAG, "MediaProjection consent result: granted=$granted")
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
Log.i(TAG, "MediaProjection consent granted — handing off to FGS")
BridgeForegroundService.grantMediaProjection(this, result.resultCode, data)
} else {
Log.i(TAG, "MediaProjection consent rejected (resultCode=${result.resultCode})")
}
}
// === END PHASE3-bridge-ui-followup ===
@@ -99,6 +109,9 @@ class MainActivity : ComponentActivity() {
setContent {
RelayApp()
}
window.decorView.post {
ComposeArrWorkaround.disableForViewTree(window.decorView)
}
}
override fun onNewIntent(intent: Intent) {
@@ -16,7 +16,7 @@ import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
@@ -26,9 +26,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
/**
* Phase 3 — accessibility `accessibility-runtime`
@@ -109,18 +107,78 @@ class ScreenCapture(
/** PNG quality is a no-op for PNG, but Bitmap.compress expects the arg. */
private const val PNG_QUALITY = 100
/** ImageReader buffer count — 2 is enough for our one-shot-at-a-time use. */
/**
* ImageReader buffer count — we only need the latest frame, but the
* reader requires at least 2 slots so the producer (VirtualDisplay)
* can keep writing while we acquire the previous one.
*/
private const val MAX_IMAGES = 2
/** Capture timeout — if no frame arrives in this window, fail loudly. */
private const val CAPTURE_TIMEOUT_MS = 2_500L
}
// === PHASE3-bridge-ui-followup: MediaProjection reuse fix ===
//
// Starting in Android 14 (API 34), each MediaProjection instance supports
// exactly ONE createVirtualDisplay() call per session. Calling it a
// second time throws with the error:
// "Don't re-use the resultData... Don't take multiple captures by
// invoking MediaProjection#createVirtualDisplay multiple times on
// the same instance."
//
// The old implementation built a fresh VirtualDisplay + ImageReader on
// EVERY screenshot call and released it after, which worked on Android
// 13 and below but breaks the second /screenshot request on 14+.
//
// Fix: keep the VirtualDisplay + ImageReader + HandlerThread alive
// across captures, keyed by the MediaProjection instance. Rebuild only
// when the projection reference changes (fresh consent grant) or the
// dimensions change (orientation flip). The ImageReader's
// setOnImageAvailableListener drains the buffer continuously; each
// captureAndUpload() installs a one-shot [pendingCapture] callback
// that fires on the next frame.
//
// Thread model:
// - `captureMutex` serializes concurrent captureAndUpload() calls
// - `cacheLock` protects the cached-state fields against the listener
// thread (which runs on `captureThread.looper`) racing with rebuild
// - The listener always acquires the latest frame; the pendingCapture
// deferred is completed with the encoded PNG bytes inside the
// listener callback on the capture thread.
private val captureMutex = kotlinx.coroutines.sync.Mutex()
private val cacheLock = Any()
private var cachedProjection: MediaProjection? = null
private var cachedReader: ImageReader? = null
private var cachedDisplay: VirtualDisplay? = null
private var cachedThread: HandlerThread? = null
private var cachedHandler: Handler? = null
private var cachedWidth: Int = 0
private var cachedHeight: Int = 0
private var cachedDensity: Int = 0
/**
* Pending capture request, populated on [captureAndUpload] entry and
* completed by the persistent ImageReader listener on the next frame.
* `@Volatile` so the listener thread sees assignments made from the
* capture coroutine. AtomicReference-style swap semantics via
* [pendingCaptureRef] avoid a stale completion racing a new request.
*/
private val pendingCaptureRef = java.util.concurrent.atomic.AtomicReference<
kotlinx.coroutines.CompletableDeferred<ByteArray>?
>(null)
// === END PHASE3-bridge-ui-followup ===
/**
* Build the consent intent that `BridgeScreen` launches via an
* `ActivityResultLauncher`. Callers should launch the intent with
* `StartActivityForResult` and on success call
* [MediaProjectionHolder.onGranted] with the result code + data Intent.
* `StartActivityForResult` and on success route the result to
* `BridgeForegroundService.grantMediaProjection(...)`, which handles
* the Android 14+ FGS-type-upgrade dance and stores the projection
* inside the holder. Calling
* [MediaProjectionHolder.acceptGrantInsideForegroundService] from
* outside a foreground service is a known footgun — see that method's
* docstring for the full explanation.
*/
fun createConsentIntent(): Intent =
(context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager)
@@ -147,10 +205,16 @@ class ScreenCapture(
)
)
// Serialize concurrent capture requests so only one pendingCapture
// is in flight at a time. The bridge command handler is the usual
// caller and it's single-threaded per /screenshot request, but the
// mutex keeps us honest if anything ever parallelizes.
val pngBytes = try {
captureOnce(projection)
captureMutex.withLock {
captureFrame(projection)
}
} catch (e: Exception) {
Log.w(TAG, "captureOnce failed: ${e.message}")
Log.w(TAG, "captureFrame failed: ${e.message}")
return@withContext Result.failure(e)
}
@@ -158,72 +222,149 @@ class ScreenCapture(
}
/**
* Synchronously (inside a suspendCancellableCoroutine) capture exactly
* one frame from a freshly-built VirtualDisplay + ImageReader, encode
* it to PNG, and return the bytes.
* Release any cached VirtualDisplay / ImageReader / HandlerThread. Call
* this when the MediaProjection is revoked (the holder's `onStop`
* callback, or an explicit revoke) so a subsequent grant starts with
* a clean slate. Safe to call multiple times.
*
* NOTE: this does NOT stop the MediaProjection itself — that's the
* holder's responsibility. We only own the capture pipeline built on
* top of the projection.
*/
private suspend fun captureOnce(projection: MediaProjection): ByteArray =
suspendCancellableCoroutine { cont ->
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.defaultDisplay.getRealMetrics(metrics)
fun releaseCache() {
synchronized(cacheLock) {
runCatching { cachedDisplay?.release() }
runCatching { cachedReader?.close() }
runCatching { cachedThread?.quitSafely() }
cachedDisplay = null
cachedReader = null
cachedThread = null
cachedHandler = null
cachedProjection = null
cachedWidth = 0
cachedHeight = 0
cachedDensity = 0
}
// Fail any pending capture with a descriptive error so the caller
// doesn't hang for the timeout.
pendingCaptureRef.getAndSet(null)?.takeIf { it.isActive }?.completeExceptionally(
IOException("capture pipeline released before frame arrived")
)
}
val width = metrics.widthPixels
val height = metrics.heightPixels
val densityDpi = metrics.densityDpi
/**
* Capture one frame from the cached VirtualDisplay + ImageReader,
* rebuilding them if the projection reference changed or dimensions
* drifted (orientation flip). Returns the PNG-encoded bytes.
*
* The ImageReader's persistent listener is set up once inside
* [ensureCacheFor]. Each call here installs a fresh
* [pendingCaptureRef] deferred that the listener completes on the
* next frame; the listener drains non-waiting frames so the buffer
* doesn't back up while nothing is asking for screenshots.
*/
private suspend fun captureFrame(projection: MediaProjection): ByteArray {
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.defaultDisplay.getRealMetrics(metrics)
val width = metrics.widthPixels
val height = metrics.heightPixels
val densityDpi = metrics.densityDpi
ensureCacheFor(projection, width, height, densityDpi)
val deferred = kotlinx.coroutines.CompletableDeferred<ByteArray>()
// Replace any stale pending capture (shouldn't exist because of
// the mutex, but defensive). If there's a previous one, fail it
// so nobody ends up stuck.
val previous = pendingCaptureRef.getAndSet(deferred)
if (previous != null && previous.isActive) {
previous.completeExceptionally(
IOException("capture superseded by a newer request")
)
}
return try {
kotlinx.coroutines.withTimeout(CAPTURE_TIMEOUT_MS) { deferred.await() }
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
pendingCaptureRef.compareAndSet(deferred, null)
throw IOException("screen capture timed out")
} catch (t: Throwable) {
pendingCaptureRef.compareAndSet(deferred, null)
throw t
}
}
/**
* Build (or reuse) the cached VirtualDisplay + ImageReader + HandlerThread
* for this projection. Rebuilds when:
*
* - The projection reference has changed (new consent grant landed)
* - The captured dimensions don't match the current display (orientation
* flipped, foldable opened/closed, display switched)
*
* Must be called while [captureMutex] is held so the cached fields
* aren't racing another capture.
*/
private fun ensureCacheFor(
projection: MediaProjection,
width: Int,
height: Int,
densityDpi: Int,
) {
synchronized(cacheLock) {
val projectionChanged = cachedProjection !== projection
val dimensionsChanged = width != cachedWidth || height != cachedHeight
if (!projectionChanged && !dimensionsChanged && cachedDisplay != null && cachedReader != null) {
return
}
// Tear down any stale cache before building fresh.
runCatching { cachedDisplay?.release() }
runCatching { cachedReader?.close() }
runCatching { cachedThread?.quitSafely() }
val thread = HandlerThread("HermesScreenCapture").apply { start() }
val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, MAX_IMAGES
)
val captureThread = HandlerThread("HermesScreenCapture").apply { start() }
val captureHandler = Handler(captureThread.looper)
var virtualDisplay: VirtualDisplay? = null
var resolved = false
fun cleanup() {
try { virtualDisplay?.release() } catch (_: Throwable) {}
try { reader.close() } catch (_: Throwable) {}
try { captureThread.quitSafely() } catch (_: Throwable) {}
}
val postedTimeout = Handler(captureThread.looper)
postedTimeout.postDelayed({
if (!resolved) {
resolved = true
cleanup()
if (cont.isActive) {
cont.resumeWith(
Result.failure(IOException("screen capture timed out"))
)
}
}
}, CAPTURE_TIMEOUT_MS)
// Persistent listener — fires on every frame the VirtualDisplay
// produces. If there's a pending capture request, we encode
// the frame and complete it; otherwise we just drain the image
// so the ImageReader buffer stays clear.
reader.setOnImageAvailableListener({ r ->
if (resolved) return@setOnImageAvailableListener
val waiter = pendingCaptureRef.get()
if (waiter == null || !waiter.isActive) {
// Drain-and-drop — nobody's asking for a screenshot
// right now but frames are still arriving.
runCatching { r.acquireLatestImage() }.getOrNull()?.close()
return@setOnImageAvailableListener
}
var image: Image? = null
try {
image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
image = r.acquireLatestImage()
?: return@setOnImageAvailableListener
val png = imageToPngBytes(image, width, height)
resolved = true
cleanup()
if (cont.isActive) cont.resume(png)
// Only complete the EXACT deferred we latched onto,
// so a stale listener firing after supersession doesn't
// resolve a new request.
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.complete(png)
}
} catch (t: Throwable) {
resolved = true
cleanup()
if (cont.isActive) {
cont.resumeWith(Result.failure(t))
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.completeExceptionally(t)
}
} finally {
try { image?.close() } catch (_: Throwable) {}
runCatching { image?.close() }
}
}, captureHandler)
}, handler)
try {
virtualDisplay = projection.createVirtualDisplay(
val display = try {
projection.createVirtualDisplay(
"hermes-bridge-capture",
width,
height,
@@ -231,23 +372,42 @@ class ScreenCapture(
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
reader.surface,
null,
captureHandler
handler,
)
} catch (t: Throwable) {
resolved = true
cleanup()
if (cont.isActive) {
cont.resumeWith(Result.failure(t))
}
// Build failed — roll back so the next attempt tries fresh.
runCatching { reader.close() }
runCatching { thread.quitSafely() }
throw t
}
cont.invokeOnCancellation {
if (!resolved) {
resolved = true
cleanup()
}
// Register the MediaProjection.Callback so if the system stops
// this projection out from under us, we release our cache
// instead of holding dead handles. The holder's own callback
// is separate — it clears projectionFlow; ours clears the
// capture pipeline. Both are safe and complementary.
try {
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
releaseCache()
}
}, handler)
} catch (_: Throwable) {
// Some OEMs log but don't throw if the callback is already
// registered by another party (e.g. the holder). Ignore.
}
cachedProjection = projection
cachedReader = reader
cachedDisplay = display
cachedThread = thread
cachedHandler = handler
cachedWidth = width
cachedHeight = height
cachedDensity = densityDpi
Log.i(TAG, "screen capture pipeline built ${width}x$height dpi=$densityDpi")
}
}
/**
* Convert an [Image] from `ImageReader` into a PNG byte array. The
@@ -379,25 +539,65 @@ class ScreenCapture(
}
/**
* Holds the per-session [MediaProjection] grant. The Bridge UI (Agent bridge-ui)
* calls [onGranted] from its `ActivityResultLauncher` callback; [ScreenCapture]
* reads [projection] through the lambda passed to its constructor.
* Holds the per-session [MediaProjection] grant.
*
* # Android 14+ rule
*
* `MediaProjectionManager.getMediaProjection()` MUST be called only after a
* foreground service has called `startForeground()` with type
* `FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION`, and that call must happen
* AFTER the user has granted the consent dialog. Calling it before — even
* if you're inside the launcher result callback — gives you a projection
* that the system auto-revokes within a frame, with no error visible to
* the app. Symptom: consent dialog appears, user allows, dialog closes,
* grant evaporates. Sample-tested on Samsung S24 / Android 14, 2026-04-12.
*
* Because of that rule, this holder no longer constructs the projection
* itself — it can only be populated from inside a foreground service that
* has already called `startForeground(type=mediaProjection)`. The phone-side
* entry point is `BridgeForegroundService.handleGrantedIntent`, which is
* dispatched from `MainActivity.mediaProjectionLauncher`.
*
* The projection state is exposed as a [StateFlow] so the UI can react to
* grants/revocations without polling. [BridgeViewModel] observes this and
* calls `refreshPermissionStatus()` on every emission, so the green check
* lights up immediately rather than waiting for the next lifecycle resume.
*
* Cleared on [revoke] (user disabled screenshots) or when the projection's
* own `onStop` callback fires (system revoked it).
*/
object MediaProjectionHolder {
@Volatile
private var _projection: MediaProjection? = null
val projection: MediaProjection? get() = _projection
private val _projectionFlow = kotlinx.coroutines.flow.MutableStateFlow<MediaProjection?>(null)
/**
* Call from the Bridge UI's `ActivityResultLauncher` callback.
* Returns true on success, false on user-rejected consent.
* Reactive view of the current projection. Emits a fresh value every
* time the holder is populated or cleared; null means "no active grant."
*/
fun onGranted(context: Context, resultCode: Int, data: Intent?): Boolean {
val projectionFlow: kotlinx.coroutines.flow.StateFlow<MediaProjection?> = _projectionFlow
/**
* Synchronous read used by [ScreenCapture] on each capture call. Always
* matches the latest [projectionFlow] value.
*/
val projection: MediaProjection? get() = _projectionFlow.value
/**
* Build a [MediaProjection] from a consent intent result and store it.
* **Caller must already be inside a foreground service that has called
* `startForeground(type=mediaProjection)`** — otherwise Android 14+ will
* silently auto-revoke the projection. The canonical caller is
* [com.hermesandroid.relay.bridge.BridgeForegroundService.handleGrantedIntent].
*
* Returns true on success, false on user-rejected consent or any
* downstream API error.
*/
fun acceptGrantInsideForegroundService(
context: Context,
resultCode: Int,
data: Intent?,
): Boolean {
if (resultCode != android.app.Activity.RESULT_OK || data == null) {
Log.i("MediaProjectionHolder", "consent rejected (resultCode=$resultCode)")
return false
}
val manager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE)
@@ -405,22 +605,27 @@ object MediaProjectionHolder {
val newProjection = try {
manager.getMediaProjection(resultCode, data)
} catch (t: Throwable) {
Log.w("MediaProjectionHolder", "getMediaProjection threw: ${t.message}")
Log.w(
"MediaProjectionHolder",
"getMediaProjection threw: ${t.message} — is this called inside a " +
"foreground service that already did startForeground(mediaProjection)?"
)
null
} ?: return false
newProjection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
_projection = null
_projectionFlow.value = null
}
}, Handler(android.os.Looper.getMainLooper()))
_projection = newProjection
_projectionFlow.value = newProjection
Log.i("MediaProjectionHolder", "MediaProjection grant accepted and stored")
return true
}
fun revoke() {
try { _projection?.stop() } catch (_: Throwable) {}
_projection = null
try { _projectionFlow.value?.stop() } catch (_: Throwable) {}
_projectionFlow.value = null
}
}
@@ -56,8 +56,10 @@ object ScreenCaptureRequester {
* Activity is alive (caller should fall back to "open the app first").
*
* The actual grant arrives asynchronously via the launcher's result
* callback — see `MainActivity.mediaProjectionLauncher` →
* [MediaProjectionHolder.onGranted].
* callback — see `MainActivity.mediaProjectionLauncher`, which hands
* the result to `BridgeForegroundService.grantMediaProjection` so the
* grant lands inside a foreground service that's already running with
* `startForeground(type=mediaProjection)` (Android 14+ requirement).
*/
fun request(): Boolean {
val action = launchAction ?: return false
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.auth
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.models.Envelope
@@ -60,6 +61,7 @@ class AuthManager(
) : ChannelMultiplexer.ChannelHandler {
companion object {
private const val TAG = "AuthManager"
private const val KEY_SESSION_TOKEN = "session_token"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_API_KEY = "api_server_key"
@@ -227,6 +229,13 @@ class AuthManager(
if (existingToken != null) {
_authState.value = AuthState.Paired(existingToken)
_currentPairedSession.value = loadStoredMetadata(existingToken)
Log.i(
TAG,
"init: hydrated existing session_token=${existingToken.take(8)}… " +
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
)
} else {
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
}
_apiKeyPresent.value = !s.getString(KEY_API_KEY).isNullOrBlank()
}
@@ -354,6 +363,10 @@ class AuthManager(
val deviceId = getDeviceId()
val payload = when (currentState) {
is AuthState.Paired -> {
Log.i(
TAG,
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…)"
)
buildJsonObject {
put("session_token", currentState.token)
put("device_id", deviceId)
@@ -363,6 +376,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,11 +435,20 @@ 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)
@@ -432,6 +460,7 @@ 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)
@@ -483,10 +512,19 @@ 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)
_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
@@ -546,13 +584,51 @@ class AuthManager(
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")
_authState.value = AuthState.Failed(humanized)
} catch (e: Exception) {
Log.w(TAG, "handleAuthFail: exception parsing payload", e)
_authState.value = AuthState.Failed("Authentication failed")
}
}
/**
* 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() }
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
import com.hermesandroid.relay.accessibility.MediaProjectionHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -70,6 +71,27 @@ class BridgeForegroundService : Service() {
const val ACTION_DISABLE = "com.hermesandroid.relay.bridge.DISABLE"
const val ACTION_OPEN_SETTINGS = "com.hermesandroid.relay.bridge.OPEN_SETTINGS"
// === PHASE3-bridge-ui-followup: MediaProjection upgrade action ===
// Fired by MainActivity.mediaProjectionLauncher after the user has
// granted the system consent dialog. Carries the resultCode + data
// Intent the launcher received. The service handles this by:
// 1. Calling startForeground AGAIN with the dual SPECIAL_USE |
// MEDIA_PROJECTION type bitmask (legal NOW because consent
// has been granted).
// 2. Calling MediaProjectionHolder.acceptGrantInsideForegroundService
// to construct and store the projection.
// Splitting the FGS type slot out of the initial start-up is
// critical: Android 14+ silently auto-revokes any projection created
// by a service that called startForeground(type=mediaProjection)
// BEFORE the consent dialog was granted. The previous code had
// mediaProjection in the initial type bitmask the moment the master
// toggle flipped on, which is exactly that violation. Symptom:
// "I tap Allow but the row never turns green" — Bailey, 2026-04-13.
const val ACTION_GRANT_PROJECTION = "com.hermesandroid.relay.bridge.GRANT_PROJECTION"
const val EXTRA_RESULT_CODE = "com.hermesandroid.relay.bridge.RESULT_CODE"
const val EXTRA_RESULT_DATA = "com.hermesandroid.relay.bridge.RESULT_DATA"
// === END PHASE3-bridge-ui-followup ===
fun start(context: Context) {
val intent = Intent(context.applicationContext, BridgeForegroundService::class.java)
.setAction(ACTION_START)
@@ -81,20 +103,80 @@ class BridgeForegroundService : Service() {
}
fun stop(context: Context) {
// Use stopService() rather than startService(ACTION_STOP) — on
// Android 15+ (target SDK 35), the system routes ANY intent to
// a service with foregroundServiceType through the foreground
// watchdog and demands a startForeground call within 5s, even
// if the intent is an internal "please shut down" message. By
// going through stopService() we bypass onStartCommand entirely
// and call onDestroy directly — clean shutdown, no watchdog.
val intent = Intent(context.applicationContext, BridgeForegroundService::class.java)
.setAction(ACTION_STOP)
context.applicationContext.startService(intent)
context.applicationContext.stopService(intent)
}
/**
* Fire-and-forget: hand the consent result off to the foreground
* service so it can upgrade its FGS type to include MEDIA_PROJECTION
* and construct the projection. Called from
* `MainActivity.mediaProjectionLauncher` immediately after the user
* grants the system consent dialog.
*
* The service must already be running (master toggle on) — that is
* guaranteed by `BridgeViewModel.requestScreenCapture()` which gates
* the consent flow on the master toggle being on.
*/
fun grantMediaProjection(context: Context, resultCode: Int, data: Intent) {
val intent = Intent(context.applicationContext, BridgeForegroundService::class.java)
.setAction(ACTION_GRANT_PROJECTION)
.putExtra(EXTRA_RESULT_CODE, resultCode)
.putExtra(EXTRA_RESULT_DATA, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
context.applicationContext.startService(intent)
}
}
}
// True after we've successfully called startForeground with the
// mediaProjection type slot (i.e. after consent + grant). Drives the
// type bitmask passed to subsequent startForeground calls so we don't
// accidentally drop the slot on a re-start.
private var hasMediaProjectionType: Boolean = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand action=${intent?.action} flags=$flags startId=$startId")
// === PHASE3-bridge-ui-followup: always startForeground first ===
// CRITICAL: on Android 15+ (target SDK 35), ANY intent delivered to
// a service that declares foregroundServiceType in the manifest —
// including intents dispatched via Context.startService() — gets
// tracked by the system's foreground-service watchdog. The service
// has 5 seconds to call startForeground() or the system throws
// ForegroundServiceDidNotStartInTimeException and crashes the app.
//
// Symptom we hit on 2026-04-13: opening the Bridge tab → BridgeViewModel
// collector fires masterToggle (initial value false) → calls
// BridgeForegroundService.stop(ctx) → startService(ACTION_STOP) →
// service onStartCommand handles ACTION_STOP, calls stopForeground
// and stopSelf without ever calling startForeground → 5s later the
// system kills the process.
//
// The fix: ALWAYS call startForeground at the top of onStartCommand,
// before any action branching. The brief notification flash for
// stop-only paths is acceptable; the alternative (using a bound
// service or broadcast receiver for control commands) is a much
// bigger refactor for the same outcome.
startForegroundNotification()
// === END PHASE3-bridge-ui-followup ===
when (intent?.action) {
ACTION_STOP -> {
Log.i(TAG, "ACTION_STOP → stopping foreground service")
hasMediaProjectionType = false
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
@@ -127,13 +209,56 @@ class BridgeForegroundService : Service() {
runCatching { startActivity(launch) }
return START_STICKY
}
ACTION_GRANT_PROJECTION -> {
// === PHASE3-bridge-ui-followup: post-consent FGS type upgrade ===
// The launcher result has just landed in MainActivity. The
// top-of-onStartCommand call already brought us into the
// foreground (with SPECIAL_USE only). Now flip the type
// flag and call startForeground AGAIN to upgrade to
// SPECIAL_USE | MEDIA_PROJECTION — legal NOW because the
// consent has been granted — then construct the projection
// from inside the foreground state. Two startForeground
// calls on the same service is well-supported; the second
// just changes the type bitmask.
Log.i(TAG, "ACTION_GRANT_PROJECTION → upgrading FGS type and accepting grant")
hasMediaProjectionType = true
startForegroundNotification()
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
val data: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(EXTRA_RESULT_DATA)
}
val accepted = MediaProjectionHolder.acceptGrantInsideForegroundService(
this, resultCode, data
)
if (!accepted) {
// Rolling back the type slot keeps us honest: if the
// user actually denied (or the API failed), we shouldn't
// claim a grant we don't have. Re-run startForeground
// with SPECIAL_USE only so the FGS type matches reality.
Log.w(TAG, "grant not accepted — reverting FGS to SPECIAL_USE only")
hasMediaProjectionType = false
startForegroundNotification()
}
return START_STICKY
// === END PHASE3-bridge-ui-followup ===
}
}
startForegroundNotification()
// Fall-through for ACTION_START / null intent — startForegroundNotification
// was already called at the top of this method.
return START_STICKY
}
override fun onDestroy() {
// Reset state so a fresh service instance starts in the
// SPECIAL_USE-only configuration. Also drop any held MediaProjection
// — a projection without an active bridge is meaningless and the
// next bridge enable should always prompt for fresh consent.
hasMediaProjectionType = false
runCatching { MediaProjectionHolder.revoke() }
scope.cancel()
super.onDestroy()
}
@@ -143,21 +268,30 @@ class BridgeForegroundService : Service() {
val notification = buildNotification()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// === PHASE3-bridge-ui-followup: Android 14+ MediaProjection FGS ===
// OR both type slots:
// - SPECIAL_USE for the persistent "bridge active" indicator
// - MEDIA_PROJECTION so MediaProjectionManager.getMediaProjection()
// can actually return a usable projection. Without this slot
// declared at startForeground time, Android 14+ silently
// auto-revokes the projection within frames of the consent
// dialog closing. Symptom: "I tapped Allow but the grant
// never sticks" — exactly what tripped us up on 2026-04-12.
// === PHASE3-bridge-ui-followup: gated MediaProjection type slot ===
// CRITICAL: on Android 14+, startForeground(type=mediaProjection)
// is only legal AFTER the user has granted the system consent
// dialog. Calling it before — even if you intend to "wait
// until consent arrives" — makes the eventual projection
// get auto-revoked by the system within a frame, with no
// app-visible error.
//
// So we start with SPECIAL_USE only when the bridge first
// comes up (master toggle on, no projection yet), and the
// ACTION_GRANT_PROJECTION handler upgrades us to
// SPECIAL_USE | MEDIA_PROJECTION right after consent and
// before getMediaProjection. That's why this method reads
// [hasMediaProjectionType] instead of always OR-ing both.
//
// Both subtypes share this single notification + this single
// service. Manifest must list both in `foregroundServiceType`.
val combinedType =
// service. Manifest lists both in `foregroundServiceType`.
val typeMask = if (hasMediaProjectionType) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
startForeground(NOTIFICATION_ID, notification, combinedType)
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
}
startForeground(NOTIFICATION_ID, notification, typeMask)
// === END PHASE3-bridge-ui-followup ===
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Q..U: the type arg is required on Q+ too, but
@@ -25,6 +25,7 @@ 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
/**
@@ -129,6 +130,7 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
Log.w(TAG, "addView(chip) failed", it)
return
}
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
chipView = compose
}
@@ -185,6 +187,7 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
onResult(false)
return
}
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
activeConfirmations[request.id] = compose
}
@@ -204,10 +204,12 @@ class ConnectionManager(
.url(url)
.build()
Log.i(TAG, "doConnect: opening WSS to $url")
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
reconnectAttempt = 0
_connectionState.value = ConnectionState.Connected
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
// TOFU: record the peer cert fingerprint if we don't have one
// yet. OkHttp populates response.handshake when the connection
@@ -239,17 +241,19 @@ class ConnectionManager(
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosing: code=$code reason=$reason")
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosed: code=$code reason=$reason")
_connectionState.value = ConnectionState.Disconnected
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=${response?.code})")
_connectionState.value = ConnectionState.Disconnected
t.printStackTrace()
scheduleReconnect()
}
})
@@ -123,6 +123,11 @@ sealed class Screen(
// Non-bottom-nav destinations — reached by explicit navigation, not the
// NavigationBar. Paired Devices is opened from Settings → Connection.
data object PairedDevices : Screen("paired_devices", "Paired Devices", Icons.Filled.Settings)
// Full-screen pair wizard route. Replaces the old in-Settings Dialog
// launch so the chooser + Confirm + Verify steps + the camera viewport
// get a real fullscreen surface (the Dialog wasn't actually filling the
// window — Settings cards were leaking through behind it).
data object Pair : Screen("pair", "Pair", Icons.Filled.Settings)
data object VoiceSettings : Screen("voice_settings", "Voice", Icons.Filled.Settings)
// === PHASE3-notif-listener-followup ===
data object NotificationCompanionSettings :
@@ -437,7 +442,18 @@ fun RelayApp() {
// so the callback collapses to "mark complete + navigate
// to chat". The legacy 4-arg signature was discarding the
// relay block entirely.
//
// CRITICAL: pass the Activity-scoped connectionViewModel
// explicitly instead of letting OnboardingScreen fetch
// its own via `viewModel()`. A bare `viewModel()` call
// inside a `composable(...)` block binds to the
// NavBackStackEntry's store, so the onboarding VM gets
// destroyed by `popUpTo(Onboarding) { inclusive = true }`
// on navigation to Chat — taking the freshly-minted
// session token with it. See the full writeup on the
// OnboardingScreen function definition.
OnboardingScreen(
connectionViewModel = connectionViewModel,
onComplete = {
connectionViewModel.completeOnboarding()
navController.navigate(Screen.Chat.route) {
@@ -566,9 +582,19 @@ fun RelayApp() {
onBack = { navController.popBackStack() },
onNavigateToPairedDevices = {
navController.navigate(Screen.PairedDevices.route)
},
onNavigateToPair = {
navController.navigate(Screen.Pair.route)
}
)
}
composable(Screen.Pair.route) {
com.hermesandroid.relay.ui.screens.PairScreen(
connectionViewModel = connectionViewModel,
onComplete = { navController.popBackStack() },
onCancel = { navController.popBackStack() }
)
}
composable(Screen.ChatSettings.route) {
ChatSettingsScreen(
connectionViewModel = connectionViewModel,
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.ui.components
import android.Manifest
import android.content.ClipData
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -9,34 +10,44 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.PhonelinkLock
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -45,36 +56,53 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.ClipEntry
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
/**
* Shared three-step pairing wizard used by both onboarding (first run) and
* Settings → Connection (re-pair / change server). Replaces the old split
* between [com.hermesandroid.relay.ui.onboarding.OnboardingScreen]'s
* `ConnectPage` (which discarded relay credentials) and the dialog-based
* `PairingWalkthroughDialog` so there is one canonical pair flow that always
* applies the full QR payload — relay code, TTL, grants, cert pin, the lot.
* Settings → Connection (re-pair / change server). One canonical pair flow
* exposing every supported pairing method, so first-run and re-pair stay in
* lockstep.
*
* Steps:
*
* 1. **Scan** — camera + QR scanner. Single primary action; falls through
* to manual entry if the user can't scan.
* 2. **Confirm** — show what was scanned, transport security badge, TTL
* picker (radio list), and an inline insecure note when the relay is
* plain `ws://`. Tap Pair to apply.
* 1. **Method** — pick how to pair. Three tiles:
* - **Scan QR**: opens camera + scanner. On success → Confirm.
* - **Enter code**: server already minted a code via `hermes-pair
* --register-code` or `/hermes-relay-pair`. → ManualEntry.
* - **Show code** (relay-gated): phone displays a generated 6-char
* code + the host command to run. → ShowCode.
* 2. **Path-specific middle step**:
* - QR path → **Confirm**: shows what was scanned, transport security
* badge, TTL picker, insecure note when the relay is plain `ws://`.
* Tap Pair to apply the full payload (URLs, code, grants, cert pin).
* - Enter code path → **ManualEntry**: API URL + Relay URL + code
* fields. Tap Pair to persist the URLs and connect with the typed
* code as the server-issued code.
* - Show code path → **ShowCode**: API URL + Relay URL fields, the
* phone-generated code (with copy + regen), the
* `hermes-pair --register-code <code>` command (with copy), and a
* Connect button to fire the pair once the operator has registered
* the code on the host.
* 3. **Verify** — runs the pair, observes [AuthState], surfaces errors
* with a Retry affordance. On success, calls [onComplete].
*
@@ -104,14 +132,26 @@ fun ConnectionWizard(
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val pairingCode by connectionViewModel.pairingCode.collectAsState()
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
var step by remember { mutableStateOf(WizardStep.Scan) }
var step by remember { mutableStateOf(WizardStep.Method) }
var chosenMethod by remember { mutableStateOf(PairMethod.Scan) }
var pendingPayload by remember { mutableStateOf<HermesPairingPayload?>(null) }
var ttlSeconds by remember { mutableStateOf(PairingPreferencesDefault) }
var showQrScanner by remember { mutableStateOf(false) }
var verifyError by remember { mutableStateOf<String?>(null) }
var verifyAttempt by remember { mutableStateOf(0) }
// Manual-path field state. Pre-fill from whatever the VM already knows
// so re-pair from Settings keeps the previously-configured URLs.
var manualApiUrl by remember(currentApiUrl) { mutableStateOf(currentApiUrl) }
var manualRelayUrl by remember(currentRelayUrl) { mutableStateOf(currentRelayUrl) }
var manualCode by remember { mutableStateOf("") }
// Camera permission gate. We don't keep the launcher result around — the
// showQrScanner flag is the persistent state.
val cameraPermissionLauncher = rememberLauncherForActivityResult(
@@ -131,27 +171,79 @@ fun ConnectionWizard(
// Watch the auth state once verify starts. Resolves Paired → onComplete,
// Failed → surface error + let user retry, timeout → same. Cancelled
// automatically when verifyAttempt changes (re-tries are a fresh attempt).
//
// CRITICAL: snapshot the current authState at the start of the attempt
// and require a TRANSITION away from it before accepting Paired/Failed.
// Without this, a stale `AuthState.Paired(token)` left in the keystore
// from a previous install (or pair) races the new pair attempt and the
// first {} predicate matches the stale value immediately — onComplete()
// fires before the new WSS handshake has even started, the wizard
// navigates to chat, and the user lands "in app" with only the API
// configured. Snapshot+transition closes the race even when the
// synchronous authState reset in applyPairingPayload didn't run (e.g.
// QRs without a relay block, or relay blocks with empty code).
LaunchedEffect(verifyAttempt) {
if (verifyAttempt == 0) return@LaunchedEffect
verifyError = null
val snapshot = connectionViewModel.authState.value
android.util.Log.i(
"ConnectionWizard",
"verify[$verifyAttempt] snapshot=${snapshot::class.simpleName} — waiting for transition to Paired|Failed"
)
try {
val terminal = withTimeout(15_000) {
connectionViewModel.authState.first {
it is AuthState.Paired || it is AuthState.Failed
connectionViewModel.authState.first { current ->
val match = current != snapshot &&
(current is AuthState.Paired || current is AuthState.Failed)
android.util.Log.d(
"ConnectionWizard",
"verify[$verifyAttempt] emission=${current::class.simpleName} " +
"differs=${current != snapshot} match=$match"
)
match
}
}
when (terminal) {
is AuthState.Paired -> onComplete()
is AuthState.Failed -> verifyError =
"Server rejected the pairing: ${terminal.reason}"
is AuthState.Paired -> {
android.util.Log.i(
"ConnectionWizard",
"verify[$verifyAttempt] terminal=Paired → onComplete()"
)
onComplete()
}
is AuthState.Failed -> {
android.util.Log.w(
"ConnectionWizard",
"verify[$verifyAttempt] terminal=Failed reason=${terminal.reason}"
)
verifyError = terminal.reason
}
else -> verifyError = "Pairing did not complete"
}
} catch (_: TimeoutCancellationException) {
android.util.Log.w(
"ConnectionWizard",
"verify[$verifyAttempt] TIMEOUT after 15s (current=${connectionViewModel.authState.value::class.simpleName})"
)
verifyError = "Timed out waiting for the relay. " +
"Check that the relay is running and the URL is correct."
}
}
// Shared launcher for the manual paths — persists URLs, applies the
// server-issued code, drops any stale session, and reconnects. Used by
// both ManualEntry (typed code) and ShowCode (phone-generated code).
val launchManualPair: (String) -> Unit = { code ->
connectionViewModel.updateApiServerUrl(manualApiUrl.trim())
connectionViewModel.updateRelayUrl(manualRelayUrl.trim())
connectionViewModel.authManager
.applyServerIssuedCodeAndReset(code.trim().uppercase())
connectionViewModel.disconnectRelay()
connectionViewModel.connectRelay(manualRelayUrl.trim())
step = WizardStep.Verify
verifyAttempt += 1
}
Column(
modifier = modifier
.fillMaxWidth()
@@ -159,7 +251,7 @@ fun ConnectionWizard(
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
WizardStepIndicator(currentStep = step.ordinal, totalSteps = WizardStep.entries.size)
WizardStepIndicator(currentStep = step.indicatorIndex, method = chosenMethod)
AnimatedContent(
targetState = step,
@@ -167,10 +259,21 @@ fun ConnectionWizard(
label = "wizard-step",
) { current ->
when (current) {
WizardStep.Scan -> ScanStep(
onLaunchScanner = {
WizardStep.Method -> MethodStep(
relayEnabled = relayEnabled,
onPickScan = {
chosenMethod = PairMethod.Scan
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
onPickEnterCode = {
chosenMethod = PairMethod.EnterCode
manualCode = ""
step = WizardStep.ManualEntry
},
onPickShowCode = {
chosenMethod = PairMethod.ShowCode
step = WizardStep.ShowCode
},
onSkip = if (showSkip) onCancel else null,
)
@@ -179,8 +282,8 @@ fun ConnectionWizard(
if (payload == null) {
// Defensive — shouldn't happen because we only enter
// Confirm after a successful scan, but if it does,
// bounce back to Scan instead of crashing.
LaunchedEffect(Unit) { step = WizardStep.Scan }
// bounce back to the chooser instead of crashing.
LaunchedEffect(Unit) { step = WizardStep.Method }
} else {
ConfirmStep(
payload = payload,
@@ -189,7 +292,7 @@ fun ConnectionWizard(
isTailscaleDetected = isTailscaleDetected,
onBack = {
pendingPayload = null
step = WizardStep.Scan
step = WizardStep.Method
},
onConfirm = {
connectionViewModel.applyPairingPayload(payload, ttlSeconds)
@@ -200,20 +303,49 @@ fun ConnectionWizard(
}
}
WizardStep.ManualEntry -> ManualEntryStep(
apiUrl = manualApiUrl,
onApiUrlChange = { manualApiUrl = it },
relayUrl = manualRelayUrl,
onRelayUrlChange = { manualRelayUrl = it },
code = manualCode,
onCodeChange = { manualCode = it.uppercase() },
onBack = { step = WizardStep.Method },
onSubmit = { launchManualPair(manualCode) },
)
WizardStep.ShowCode -> ShowCodeStep(
apiUrl = manualApiUrl,
onApiUrlChange = { manualApiUrl = it },
relayUrl = manualRelayUrl,
onRelayUrlChange = { manualRelayUrl = it },
pairingCode = pairingCode,
onRegenerate = { connectionViewModel.regeneratePairingCode() },
onBack = { step = WizardStep.Method },
onConnect = { launchManualPair(pairingCode) },
)
WizardStep.Verify -> VerifyStep(
authState = authState,
error = verifyError,
onRetry = {
verifyError = null
pendingPayload?.let {
connectionViewModel.applyPairingPayload(it, ttlSeconds)
verifyAttempt += 1
when (chosenMethod) {
PairMethod.Scan -> pendingPayload?.let {
connectionViewModel.applyPairingPayload(it, ttlSeconds)
verifyAttempt += 1
}
PairMethod.EnterCode -> launchManualPair(manualCode)
PairMethod.ShowCode -> launchManualPair(pairingCode)
}
},
onBack = {
// User wants to re-scan or pick a new TTL.
verifyError = null
step = WizardStep.Confirm
step = when (chosenMethod) {
PairMethod.Scan -> WizardStep.Confirm
PairMethod.EnterCode -> WizardStep.ManualEntry
PairMethod.ShowCode -> WizardStep.ShowCode
}
},
onCancel = onCancel,
)
@@ -241,10 +373,27 @@ fun ConnectionWizard(
private val PairingPreferencesDefault: Long =
com.hermesandroid.relay.data.PairingPreferences.DEFAULT_TTL_SECONDS
private enum class WizardStep { Scan, Confirm, Verify }
private enum class WizardStep {
Method,
Confirm,
ManualEntry,
ShowCode,
Verify;
/** Slot index in the 3-dot indicator regardless of which path is active. */
val indicatorIndex: Int
get() = when (this) {
Method -> 0
Confirm, ManualEntry, ShowCode -> 1
Verify -> 2
}
}
private enum class PairMethod { Scan, EnterCode, ShowCode }
@Composable
private fun WizardStepIndicator(currentStep: Int, totalSteps: Int) {
private fun WizardStepIndicator(currentStep: Int, method: PairMethod) {
val totalSteps = 3
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -301,8 +450,12 @@ private fun WizardStepIndicator(currentStep: Int, totalSteps: Int) {
Spacer(Modifier.height(4.dp))
Text(
text = when (currentStep) {
0 -> "Step 1 of 3 — Scan pairing QR"
1 -> "Step 2 of 3 — Confirm pairing"
0 -> "Step 1 of 3 — Choose how to pair"
1 -> when (method) {
PairMethod.Scan -> "Step 2 of 3 — Confirm pairing"
PairMethod.EnterCode -> "Step 2 of 3 — Enter pairing code"
PairMethod.ShowCode -> "Step 2 of 3 — Show code on host"
}
else -> "Step 3 of 3 — Verify"
},
style = MaterialTheme.typography.labelMedium,
@@ -311,8 +464,11 @@ private fun WizardStepIndicator(currentStep: Int, totalSteps: Int) {
}
@Composable
private fun ScanStep(
onLaunchScanner: () -> Unit,
private fun MethodStep(
relayEnabled: Boolean,
onPickScan: () -> Unit,
onPickEnterCode: () -> Unit,
onPickShowCode: () -> Unit,
onSkip: (() -> Unit)?,
) {
Column(
@@ -324,23 +480,34 @@ private fun ScanStep(
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Run /hermes-relay-pair in any Hermes chat, or hermes-pair on your server, " +
"to generate a pairing QR. One scan configures chat, the relay, and your session.",
text = "Run /hermes-relay-pair in any Hermes chat, or hermes-pair on your " +
"server, to start pairing. Pick the method that fits your setup.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onLaunchScanner,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(18.dp),
MethodTile(
icon = Icons.Filled.QrCodeScanner,
title = "Scan QR code",
subtitle = "Recommended — one scan configures chat, the relay, and your session",
onClick = onPickScan,
isPrimary = true,
)
MethodTile(
icon = Icons.Filled.Keyboard,
title = "Enter a code",
subtitle = "The host already printed a 6-character code — type it in",
onClick = onPickEnterCode,
)
if (relayEnabled) {
MethodTile(
icon = Icons.Filled.PhonelinkLock,
title = "Show a code on this phone",
subtitle = "No camera or QR? Display a code here and register it on the host",
onClick = onPickShowCode,
)
Spacer(Modifier.width(8.dp))
Text("Scan QR Code")
}
if (onSkip != null) {
@@ -354,6 +521,342 @@ private fun ScanStep(
}
}
@Composable
private fun MethodTile(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
isPrimary: Boolean = false,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isPrimary) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (isPrimary) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (isPrimary) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = if (isPrimary) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = if (isPrimary) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
@Composable
private fun ManualEntryStep(
apiUrl: String,
onApiUrlChange: (String) -> Unit,
relayUrl: String,
onRelayUrlChange: (String) -> Unit,
code: String,
onCodeChange: (String) -> Unit,
onBack: () -> Unit,
onSubmit: () -> Unit,
) {
val trimmedCode = code.trim().uppercase()
val codeValid = trimmedCode.length in 4..12 && trimmedCode.all { it.isLetterOrDigit() }
val canSubmit = codeValid && relayUrl.isNotBlank() && apiUrl.isNotBlank()
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Enter pairing code",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Type the code printed by hermes-pair or /hermes-relay-pair, plus your " +
"API server and relay URLs. We'll persist them and pair in one shot.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = apiUrl,
onValueChange = onApiUrlChange,
label = { Text("API server URL") },
placeholder = { Text("http://your-server:8642") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = relayUrl,
onValueChange = onRelayUrlChange,
label = { Text("Relay URL") },
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = code,
onValueChange = onCodeChange,
label = { Text("Pairing code") },
placeholder = { Text("e.g. ABC123") },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Go,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onGo = { if (canSubmit) onSubmit() },
),
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedButton(
onClick = onBack,
modifier = Modifier.weight(1f),
) {
Text("Back")
}
Button(
onClick = onSubmit,
enabled = canSubmit,
modifier = Modifier.weight(1f),
) {
Text("Pair")
}
}
}
}
@Composable
private fun ShowCodeStep(
apiUrl: String,
onApiUrlChange: (String) -> Unit,
relayUrl: String,
onRelayUrlChange: (String) -> Unit,
pairingCode: String,
onRegenerate: () -> Unit,
onBack: () -> Unit,
onConnect: () -> Unit,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val canConnect = pairingCode.isNotBlank() &&
relayUrl.isNotBlank() &&
apiUrl.isNotBlank()
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Show code on host",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Use this when you can't scan the pairing QR. Set your URLs, then " +
"register the code below on the host running Hermes-Relay.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = apiUrl,
onValueChange = onApiUrlChange,
label = { Text("API server URL") },
placeholder = { Text("http://your-server:8642") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = relayUrl,
onValueChange = onRelayUrlChange,
label = { Text("Relay URL") },
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
// Step 1 — show the code
Text(
text = "1. Copy this code",
style = MaterialTheme.typography.titleSmall,
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
) {
Text(
text = pairingCode.ifBlank { "------" },
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.15,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
if (pairingCode.isNotBlank()) {
scope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText("Pairing code", pairingCode)
)
)
}
}
}) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy pairing code",
)
}
IconButton(onClick = onRegenerate) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Generate new code",
)
}
}
}
// Step 2 — register on host
Text(
text = "2. On the host running Hermes-Relay, run:",
style = MaterialTheme.typography.titleSmall,
)
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
text = "hermes-pair --register-code ${pairingCode.ifBlank { "<code>" }}",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = {
if (pairingCode.isNotBlank()) {
val cmd = "hermes-pair --register-code $pairingCode"
scope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText("hermes-pair command", cmd)
)
)
}
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy command",
modifier = Modifier.size(16.dp),
)
}
}
}
// Step 3 — connect
Text(
text = "3. Then come back and tap Connect",
style = MaterialTheme.typography.titleSmall,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedButton(
onClick = onBack,
modifier = Modifier.weight(1f),
) {
Text("Back")
}
Button(
onClick = onConnect,
enabled = canConnect,
modifier = Modifier.weight(1f),
) {
Text("Connect")
}
}
}
}
@Composable
private fun ConfirmStep(
payload: HermesPairingPayload,
@@ -7,11 +7,20 @@ import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -27,6 +36,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -34,10 +44,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.Canvas
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -48,10 +65,10 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.util.concurrent.Executors
import kotlin.math.max
/**
* Parsed result from a Hermes pairing QR code.
@@ -192,9 +209,120 @@ fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
}
}
/**
* A bounding rect in *viewport* pixel coordinates (top-left origin), produced
* by mapping a barcode's image-space bounding box through the camera rotation
* + FILL_CENTER scale of the PreviewView. Used to drive the dynamic
* "snap-to-QR" corner brackets in [ScannerCornersOverlay].
*/
private data class ViewportRect(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
)
/**
* One L-shaped corner bracket — origin point + the two arm endpoints + its
* core/glow colors. Pulled out to a top-level class so the draw loop can be
* a regular `for` over a typed list (Kotlin local data classes inside
* lambdas have edge-case restrictions; safer to declare here).
*/
private data class CornerBracket(
val origin: Offset,
val horiz: Offset,
val vert: Offset,
val core: Color,
val glow: Color,
)
/**
* Map a barcode bounding box in **image buffer coordinates** through the
* camera rotation and FILL_CENTER scaling of a square viewport, returning
* the rect in viewport pixel coordinates.
*
* Math notes:
* - The camera buffer arrives in sensor orientation (typically landscape
* e.g. 1280×720), with [rotationDegrees] indicating how many degrees the
* image needs to be rotated CW to display upright on the device.
* - We rotate the bounding box first, then scale-and-offset it into the
* viewport. FILL_CENTER picks the *larger* of (vp/imgW, vp/imgH) so the
* image fully covers the viewport (cropping the longer side).
* - For 90°/270° rotations the post-rotation dimensions are swapped.
*/
private fun mapBoxToViewport(
box: android.graphics.Rect,
imgW: Int,
imgH: Int,
rotationDegrees: Int,
viewportSize: IntSize,
): ViewportRect {
// Rotate the box into display orientation.
val rotated = when (rotationDegrees) {
90 -> floatArrayOf(
(imgH - box.bottom).toFloat(),
box.left.toFloat(),
(imgH - box.top).toFloat(),
box.right.toFloat(),
)
180 -> floatArrayOf(
(imgW - box.right).toFloat(),
(imgH - box.bottom).toFloat(),
(imgW - box.left).toFloat(),
(imgH - box.top).toFloat(),
)
270 -> floatArrayOf(
box.top.toFloat(),
(imgW - box.right).toFloat(),
box.bottom.toFloat(),
(imgW - box.left).toFloat(),
)
else -> floatArrayOf(
box.left.toFloat(),
box.top.toFloat(),
box.right.toFloat(),
box.bottom.toFloat(),
)
}
val rotW = if (rotationDegrees == 90 || rotationDegrees == 270) imgH else imgW
val rotH = if (rotationDegrees == 90 || rotationDegrees == 270) imgW else imgH
// FILL_CENTER: the image is scaled to fully cover the viewport, then
// centered. The visible portion is the central `viewport`-sized window
// of the scaled image. We map by applying the scale + the centering offset.
val vpW = viewportSize.width.toFloat()
val vpH = viewportSize.height.toFloat()
val scale = max(vpW / rotW, vpH / rotH)
val scaledW = rotW * scale
val scaledH = rotH * scale
val offsetX = (vpW - scaledW) / 2f
val offsetY = (vpH - scaledH) / 2f
return ViewportRect(
left = (rotated[0] * scale + offsetX).coerceIn(0f, vpW),
top = (rotated[1] * scale + offsetY).coerceIn(0f, vpH),
right = (rotated[2] * scale + offsetX).coerceIn(0f, vpW),
bottom = (rotated[3] * scale + offsetY).coerceIn(0f, vpH),
)
}
/**
* Full-screen QR code scanner overlay.
* Detects Hermes pairing QR codes and calls [onPairingDetected] with the parsed payload.
*
* Layout:
* - Header bar with a Close button + "Scan Hermes QR" title
* - Square camera viewport at 50% of the screen width, with rounded corners
* - Sci-fi L-bracket overlay drawn on top of the viewport. When no QR is
* in frame the brackets sit at a centered "ready" position with a slow
* pulse animation. When a barcode is detected the brackets snap (with
* a spring) to the bounding box of the QR — defining a live "lock-on"
* indicator. Brackets release back to the centered ready state ~600ms
* after the QR leaves the frame.
* - Instruction copy below
*
* Detects Hermes pairing QR codes and calls [onPairingDetected] with the
* parsed payload after a brief delay, so the user actually sees the lock-on
* snap animation before the screen transitions away.
*/
@Composable
fun QrPairingScanner(
@@ -207,6 +335,30 @@ fun QrPairingScanner(
val hasDetected = remember { AtomicBoolean(false) }
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
// Viewport is sized at 50% of the screen width via Modifier.fillMaxWidth(0.5f)
// below — comfortable scan target without dominating the screen, and
// matches the "futuristic scan port" aesthetic the brackets are drawn around.
// Live viewport pixel size — captured via onSizeChanged so the analyzer
// thread can compute viewport-space coordinates for the corner brackets.
var viewportSizePx by remember { mutableStateOf(IntSize.Zero) }
// Latest detected QR bounding box in viewport pixel coordinates. Updated
// continuously by the analyzer for any successfully decoded QR (not just
// valid Hermes ones). null = no current detection → brackets fall back
// to centered ready position.
var detectedBox by remember { mutableStateOf<ViewportRect?>(null) }
// Frame counter from the analyzer — bumped every analyzed frame so the
// "release back to ready position" timer can detect when detections stop
// arriving. Volatile because it's written from the camera executor thread
// and read from the main thread coroutine.
var lastDetectionAtMs by remember { mutableStateOf(0L) }
// Lock-on state — set true when we've parsed a valid Hermes payload.
// Drives the brief settle delay before navigating away so the user sees
// the snap animation actually land on the QR.
var lockedPayload by remember { mutableStateOf<HermesPairingPayload?>(null) }
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
DisposableEffect(Unit) {
onDispose {
@@ -215,6 +367,26 @@ fun QrPairingScanner(
}
}
// Release the brackets back to the centered ready position when no
// detection has arrived for ~600ms. Otherwise a stale detection from
// a frame ago would keep the brackets "stuck" off-center after the QR
// has left the frame.
LaunchedEffect(lastDetectionAtMs) {
if (detectedBox == null) return@LaunchedEffect
kotlinx.coroutines.delay(600)
if (System.currentTimeMillis() - lastDetectionAtMs >= 600) {
detectedBox = null
}
}
// After we lock on a valid Hermes payload, hold the snap animation for
// ~450ms so the user perceives the lock-on, then forward to onPairingDetected.
LaunchedEffect(lockedPayload) {
val payload = lockedPayload ?: return@LaunchedEffect
kotlinx.coroutines.delay(450)
onPairingDetected(payload)
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -248,13 +420,18 @@ fun QrPairingScanner(
)
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(24.dp))
// Camera preview
// Camera preview viewport (75% of screen width, square). Wider
// than the original 50% pass — a generous scan target makes
// framing the QR effortless and gives the bracket animations
// more room to read as a "lock-on" instead of a tiny pop.
Box(
modifier = Modifier
.size(280.dp)
.clip(RoundedCornerShape(16.dp)),
.fillMaxWidth(0.75f)
.aspectRatio(1f)
.clip(RoundedCornerShape(20.dp))
.onSizeChanged { viewportSizePx = it },
contentAlignment = Alignment.Center
) {
AndroidView(
@@ -285,32 +462,56 @@ fun QrPairingScanner(
.also { analysis ->
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null && !hasDetected.get()) {
val inputImage = InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees
)
barcodeScanner.process(inputImage)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
if (barcode.valueType == Barcode.TYPE_TEXT ||
barcode.valueType == Barcode.TYPE_UNKNOWN
) {
val rawValue = barcode.rawValue ?: continue
val payload = parseHermesPairingQr(rawValue)
if (payload != null && hasDetected.compareAndSet(false, true)) {
onPairingDetected(payload)
return@addOnSuccessListener
}
if (mediaImage == null || hasDetected.get()) {
imageProxy.close()
return@setAnalyzer
}
val rotation = imageProxy.imageInfo.rotationDegrees
val imgW = mediaImage.width
val imgH = mediaImage.height
val inputImage = InputImage.fromMediaImage(
mediaImage,
rotation
)
barcodeScanner.process(inputImage)
.addOnSuccessListener { barcodes ->
// Drive the brackets off ANY decoded QR so
// the lock-on snap is visible even before
// we've parsed it as a valid Hermes payload.
val first = barcodes.firstOrNull { b ->
b.boundingBox != null &&
(b.valueType == Barcode.TYPE_TEXT ||
b.valueType == Barcode.TYPE_UNKNOWN)
}
val box = first?.boundingBox
val vpSize = viewportSizePx
if (box != null && vpSize.width > 0 && vpSize.height > 0) {
detectedBox = mapBoxToViewport(
box = box,
imgW = imgW,
imgH = imgH,
rotationDegrees = rotation,
viewportSize = vpSize,
)
lastDetectionAtMs = System.currentTimeMillis()
}
// Then try to parse for the actual lock.
for (barcode in barcodes) {
if (barcode.valueType == Barcode.TYPE_TEXT ||
barcode.valueType == Barcode.TYPE_UNKNOWN
) {
val rawValue = barcode.rawValue ?: continue
val payload = parseHermesPairingQr(rawValue)
if (payload != null && hasDetected.compareAndSet(false, true)) {
lockedPayload = payload
return@addOnSuccessListener
}
}
}
.addOnCompleteListener {
imageProxy.close()
}
} else {
imageProxy.close()
}
}
.addOnCompleteListener {
imageProxy.close()
}
}
}
@@ -331,6 +532,15 @@ fun QrPairingScanner(
},
modifier = Modifier.fillMaxSize()
)
// Sci-fi L-bracket overlay. When detectedBox is null the
// brackets sit at a centered ready inset; when present they
// spring to the bounding box of the live detection.
ScannerCornersOverlay(
detected = detectedBox,
locked = lockedPayload != null,
modifier = Modifier.fillMaxSize(),
)
}
Spacer(modifier = Modifier.height(24.dp))
@@ -363,3 +573,229 @@ fun QrPairingScanner(
}
}
}
/**
* Sci-fi L-bracket overlay drawn on top of the camera viewport. Renders four
* corner brackets that:
*
* - Sit at a centered "ready" inset (~12% of viewport from each edge) when
* no QR is detected, with a slow breathing pulse on alpha.
* - Spring to the bounding box of a live detection when [detected] is non-null
* — animated independently per side so the snap reads as a genuine "lock-on"
* rather than a translation.
* - Switch from the primary cyan tint to a vivid green when [locked] is true,
* so the brief settle delay before navigation reads as confirmation.
*
* The brackets themselves are drawn with `Stroke(cap = StrokeCap.Round)` so
* the L-corners blend cleanly. Two passes — a soft outer glow at low alpha
* + a crisp inner stroke — give the futuristic glow without needing actual
* blur shaders.
*/
@Composable
private fun ScannerCornersOverlay(
detected: ViewportRect?,
locked: Boolean,
modifier: Modifier = Modifier,
) {
// Themed idle: two-tone gradient between primary (top-left/bottom-right)
// and tertiary (top-right/bottom-left). Both are brand purples in this
// theme, so the corners read as cohesive but not flat.
val primary = MaterialTheme.colorScheme.primary
val tertiary = MaterialTheme.colorScheme.tertiary
val onPrimary = MaterialTheme.colorScheme.onPrimary
// Vivid Material A400 success green — much more saturated than the
// generic 500-shade we had before, reads as "lock-on confirmed" instead
// of "neutral status indicator".
val successCore = Color(0xFF00E676)
val successGlow = Color(0xFF69F0AE)
// Slow breathing pulse on alpha when idle. Locked state stays solid +
// gets its own one-shot ramp so the green burst is unmistakable.
val infiniteTransition = rememberInfiniteTransition(label = "scan-corners")
val idlePulse by infiniteTransition.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "idle-pulse",
)
// One-shot ramp that fires when `locked` flips true. Drives the
// outward scale pop on the corners + the green tint flash overlay.
val lockRamp by animateFloatAsState(
targetValue = if (locked) 1f else 0f,
animationSpec = if (locked) {
spring(dampingRatio = 0.55f, stiffness = 220f)
} else {
tween(180)
},
label = "lock-ramp",
)
var size by remember { mutableStateOf(IntSize.Zero) }
val density = LocalDensity.current
// Compute the target rect (left/top/right/bottom in px). When idle we
// inset from the viewport edges by ~10%; when detected we use the
// detected box. Each side animates independently with a snappy spring.
val readyInsetFrac = 0.10f
val targetLeft: Float
val targetTop: Float
val targetRight: Float
val targetBottom: Float
if (detected != null) {
targetLeft = detected.left
targetTop = detected.top
targetRight = detected.right
targetBottom = detected.bottom
} else if (size.width > 0 && size.height > 0) {
targetLeft = size.width * readyInsetFrac
targetTop = size.height * readyInsetFrac
targetRight = size.width * (1f - readyInsetFrac)
targetBottom = size.height * (1f - readyInsetFrac)
} else {
targetLeft = 0f
targetTop = 0f
targetRight = 0f
targetBottom = 0f
}
val springSpec = spring<Float>(
dampingRatio = 0.7f,
stiffness = 280f,
)
val animLeft by animateFloatAsState(targetLeft, springSpec, label = "snap-l")
val animTop by animateFloatAsState(targetTop, springSpec, label = "snap-t")
val animRight by animateFloatAsState(targetRight, springSpec, label = "snap-r")
val animBottom by animateFloatAsState(targetBottom, springSpec, label = "snap-b")
Canvas(
modifier = modifier.onSizeChanged { size = it }
) {
if (animRight <= animLeft || animBottom <= animTop) return@Canvas
// On lock, push the brackets outward by ~10dp so they pop OUT past
// the QR boundary like a "got it" flourish, then settle.
val popPx = with(density) { 10.dp.toPx() } * lockRamp
val left = animLeft - popPx
val top = animTop - popPx
val right = animRight + popPx
val bottom = animBottom + popPx
val w = right - left
val h = bottom - top
// Corner arm length scales with the smaller box side so the brackets
// stay proportional whether snapped to a small QR or sitting at the
// ready inset. Bumped from 22% → 26% for a more pronounced sci-fi look.
val arm = (kotlin.math.min(w, h) * 0.26f).coerceAtLeast(with(density) { 18.dp.toPx() })
val coreStroke = with(density) { 4.dp.toPx() }
val glowStroke = with(density) { 14.dp.toPx() }
val pipRadius = with(density) { 3.dp.toPx() }
// Idle alpha breathes; detected/locked are solid + amped by the lockRamp.
val baseAlpha = if (detected != null || locked) 1f else idlePulse
val glowAlpha = if (detected != null || locked) {
0.55f + 0.25f * lockRamp
} else {
idlePulse * 0.35f
}
// Diagonal pairing: TL+BR get the primary; TR+BL get the tertiary.
// Gives a cohesive two-tone "diagonal scan" feel. When locked, all
// four corners flip to the success green.
val tlBrCore = if (locked) successCore.copy(alpha = baseAlpha) else primary.copy(alpha = baseAlpha)
val trBlCore = if (locked) successCore.copy(alpha = baseAlpha) else tertiary.copy(alpha = baseAlpha)
val tlBrGlow = if (locked) successGlow.copy(alpha = glowAlpha) else primary.copy(alpha = glowAlpha)
val trBlGlow = if (locked) successGlow.copy(alpha = glowAlpha) else tertiary.copy(alpha = glowAlpha)
val pipColor = if (locked) successGlow.copy(alpha = baseAlpha) else onPrimary.copy(alpha = baseAlpha * 0.85f)
val corners = listOf(
CornerBracket(
origin = Offset(left, top),
horiz = Offset(left + arm, top),
vert = Offset(left, top + arm),
core = tlBrCore,
glow = tlBrGlow,
),
CornerBracket(
origin = Offset(right, top),
horiz = Offset(right - arm, top),
vert = Offset(right, top + arm),
core = trBlCore,
glow = trBlGlow,
),
CornerBracket(
origin = Offset(left, bottom),
horiz = Offset(left + arm, bottom),
vert = Offset(left, bottom - arm),
core = trBlCore,
glow = trBlGlow,
),
CornerBracket(
origin = Offset(right, bottom),
horiz = Offset(right - arm, bottom),
vert = Offset(right, bottom - arm),
core = tlBrCore,
glow = tlBrGlow,
),
)
// Pass 1 — wide soft glow underneath (low alpha, fat stroke)
for (c in corners) {
drawLine(
color = c.glow,
start = c.origin,
end = c.horiz,
strokeWidth = glowStroke,
cap = StrokeCap.Round,
)
drawLine(
color = c.glow,
start = c.origin,
end = c.vert,
strokeWidth = glowStroke,
cap = StrokeCap.Round,
)
}
// Pass 2 — crisp core stroke
for (c in corners) {
drawLine(
color = c.core,
start = c.origin,
end = c.horiz,
strokeWidth = coreStroke,
cap = StrokeCap.Round,
)
drawLine(
color = c.core,
start = c.origin,
end = c.vert,
strokeWidth = coreStroke,
cap = StrokeCap.Round,
)
}
// Pass 3 — pip dots at each L-corner origin. Tiny detail that reads
// as "targeting reticle" rather than "rounded rectangle".
for (c in corners) {
drawCircle(
color = pipColor,
radius = pipRadius,
center = c.origin,
)
}
// Lock flash — brief green tint over the entire viewport that fades
// out as lockRamp settles. Driven by the same spring as the corner
// pop so they read as one event.
if (lockRamp > 0f) {
drawRect(
color = successCore.copy(alpha = 0.18f * lockRamp),
topLeft = Offset.Zero,
size = this.size,
)
}
}
}
@@ -1,6 +1,10 @@
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
@@ -8,19 +12,27 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.ui.theme.gradientBorder
@Composable
fun OnboardingPage(
@@ -28,43 +40,124 @@ fun OnboardingPage(
title: String,
description: String,
modifier: Modifier = Modifier,
heroContent: @Composable BoxScope.() -> Unit = {
FeatureHero(
icon = icon,
title = title,
)
},
content: @Composable ColumnScope.() -> Unit = {}
) {
val isDarkTheme = isSystemInDarkTheme()
val heroShape = RoundedCornerShape(30.dp)
val bodyShape = RoundedCornerShape(26.dp)
val heroBrush = Brush.radialGradient(
colors = if (isDarkTheme) {
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.96f),
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.92f),
MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.98f),
)
} else {
listOf(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.70f),
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.60f),
)
}
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
.widthIn(max = 560.dp)
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary
)
Card(
modifier = Modifier
.fillMaxWidth()
.height(232.dp)
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
shape = heroShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(232.dp)
.background(heroBrush),
contentAlignment = Alignment.Center
) {
heroContent()
}
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(18.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Card(
modifier = Modifier
.fillMaxWidth()
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
shape = bodyShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
content()
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
content()
@Composable
private fun FeatureHero(
icon: ImageVector,
title: String,
) {
val isDarkTheme = isSystemInDarkTheme()
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(136.dp)
.clip(CircleShape)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = if (isDarkTheme) 0.14f else 0.10f)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(74.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
@@ -73,7 +166,7 @@ fun OnboardingPage(
private fun OnboardingPagePreview() {
HermesRelayTheme {
OnboardingPage(
icon = Icons.Filled.Chat,
icon = Icons.AutoMirrored.Filled.Chat,
title = "Talk to Your Agent",
description = "Stream conversations with any Hermes profile. Ask questions, run tasks, and collaborate in real time."
)
@@ -85,7 +178,7 @@ private fun OnboardingPagePreview() {
private fun OnboardingPageWithContentPreview() {
HermesRelayTheme {
OnboardingPage(
icon = Icons.Filled.Chat,
icon = Icons.AutoMirrored.Filled.Chat,
title = "Let's Connect",
description = "Enter your relay server URL to get started."
) {
@@ -5,6 +5,8 @@ import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,16 +17,21 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.PhonelinkSetup
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -38,13 +45,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -71,11 +83,28 @@ private enum class OnboardingPage { Welcome, Chat, Terminal, Bridge, Connect }
*/
@Composable
fun OnboardingScreen(
// === onboarding-pair-bug-fix (2026-04-13) ===
// CRITICAL: the ConnectionViewModel MUST be passed in from the top
// level of RelayApp, not fetched here via `viewModel()`. A bare
// `viewModel()` call inside a composable that lives under a
// `composable(Screen.Onboarding.route) { ... }` block binds to the
// NavBackStackEntry's ViewModelStore, not the Activity's. When
// onboarding completes and we `popUpTo(Onboarding) { inclusive = true }`
// during navigation to Chat, that backstack entry is destroyed and
// the scoped VM's `onCleared()` runs → `connectionManager.shutdown()`
// → WSS `close(1000)` → the freshly-minted session token is thrown
// away. Meanwhile Chat uses the Activity-scoped instance (a DIFFERENT
// ConnectionViewModel), which never saw the pair and has no token.
// Symptom: "onboarding reports success but only the API URL survived."
//
// Passing the VM in explicitly forces us to share the Activity-scoped
// instance that Chat / Settings / Bridge all use, so the pair state
// lands on the right VM and survives the Onboarding→Chat transition.
connectionViewModel: ConnectionViewModel,
onComplete: () -> Unit,
) {
val context = LocalContext.current
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
val connectionViewModel: ConnectionViewModel = viewModel()
// Build page list dynamically based on feature flags
val pages = remember(relayEnabled) {
@@ -232,9 +261,95 @@ private fun WelcomePage() {
OnboardingPage(
icon = Icons.Outlined.RocketLaunch,
title = "Hermes-Relay",
description = "Your Hermes agent, in your pocket."
description = "Your Hermes agent, in your pocket.",
heroContent = {
Box(modifier = Modifier.fillMaxSize()) {
MorphingSphere(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 6.dp),
state = SphereState.Idle,
intensity = 0.12f,
)
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.80f))
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = "Welcome",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 18.dp)
.size(60.dp)
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "Hermes-Relay logo",
modifier = Modifier.size(42.dp)
)
}
}
}
) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Read the app guide, browse the repo, or jump to Hermes Agent docs while you finish server setup.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("User Guide")
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_github),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("GitHub")
}
}
Text(
text = "hermes-agent.nousresearch.com",
style = MaterialTheme.typography.bodySmall.copy(
@@ -300,7 +415,13 @@ private fun ConnectPage(
@Composable
private fun OnboardingScreenPreview() {
HermesRelayTheme {
OnboardingScreen(onComplete = {})
// Preview uses whatever ViewModelStoreOwner Compose-Preview mocks;
// at preview time there's no NavHost so the scope collision that
// the production entry point has to avoid doesn't apply here.
OnboardingScreen(
connectionViewModel = viewModel(),
onComplete = {},
)
}
}
@@ -308,6 +429,9 @@ private fun OnboardingScreenPreview() {
@Composable
private fun OnboardingScreenDarkPreview() {
HermesRelayTheme(themePreference = "dark") {
OnboardingScreen(onComplete = {})
OnboardingScreen(
connectionViewModel = viewModel(),
onComplete = {},
)
}
}
@@ -76,7 +76,6 @@ import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.components.ApiServerInfoSheet
import com.hermesandroid.relay.ui.components.ConnectionStatusRow
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.ui.components.InsecureConnectionAckDialog
import com.hermesandroid.relay.ui.components.RelayInfoSheet
import com.hermesandroid.relay.ui.components.SessionInfoSheet
@@ -105,6 +104,7 @@ fun ConnectionSettingsScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onNavigateToPairedDevices: () -> Unit,
onNavigateToPair: () -> Unit,
) {
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
@@ -133,10 +133,11 @@ fun ConnectionSettingsScreen(
var relayUrlInput by remember(relayUrl) { mutableStateOf(relayUrl) }
var isTesting by remember { mutableStateOf(false) }
// Pairing wizard — modal full-screen flow that scans the QR, lets the
// user pick a TTL, and verifies the pair. Replaces the old split between
// QrPairingScanner + SessionTtlPickerDialog + PairingWalkthroughDialog.
var showConnectionWizard by remember { mutableStateOf(false) }
// Pairing wizard now lives at the dedicated Screen.Pair route — this
// page just navigates to it. The old `Dialog` wrapper wasn't actually
// filling the window (Settings cards were leaking through underneath
// the chooser tiles + camera viewport on first-run-from-Settings), so
// we route to a Scaffolded full-screen `PairScreen` instead.
var showManualCodeDialog by remember { mutableStateOf(false) }
var manualCodeInput by remember { mutableStateOf("") }
@@ -246,13 +247,12 @@ fun ConnectionSettingsScreen(
}
// === END MANUAL-PAIR-FOLLOWUP ===
// Connection section expand state — seeded from the current pair/reach
// status on first composition, then driven by the user. rememberSaveable
// preserves user intent across config changes so tapping "collapse" when
// the connection drops doesn't re-open the card.
val manualConfigExpandedDefault =
!(apiReachable && (authState is AuthState.Paired || !relayEnabled))
var manualConfigExpanded by rememberSaveable { mutableStateOf(manualConfigExpandedDefault) }
// Connection section expand state. Both manual cards stay collapsed by
// default — the primary path is now the chooser-driven Pair wizard
// (Screen.Pair) which fully replaces the need to hand-type URLs and
// codes for the common case. rememberSaveable preserves user intent
// across config changes so a manual expand survives a rotation.
var manualConfigExpanded by rememberSaveable { mutableStateOf(false) }
var bridgePairingExpanded by rememberSaveable { mutableStateOf(false) }
// The insecure ack dialog opens the first time the user toggles insecure
@@ -323,7 +323,7 @@ fun ConnectionSettingsScreen(
// (Scan → Confirm → Verify). Same wizard onboarding uses,
// so first-run and re-pair stay perfectly aligned.
Button(
onClick = { showConnectionWizard = true },
onClick = onNavigateToPair,
modifier = Modifier.fillMaxWidth()
) {
Icon(
@@ -451,7 +451,7 @@ fun ConnectionSettingsScreen(
OutlinedButton(
onClick = {
connectionViewModel.clearSession()
showConnectionWizard = true
onNavigateToPair()
}
) {
Text("Re-pair")
@@ -1085,50 +1085,10 @@ fun ConnectionSettingsScreen(
}
}
// Pairing wizard — full-screen modal scan → confirm → verify flow.
// Same composable onboarding uses, so first-run and re-pair stay aligned.
if (showConnectionWizard) {
androidx.compose.ui.window.Dialog(
onDismissRequest = { showConnectionWizard = false },
properties = androidx.compose.ui.window.DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Pair with your server") },
navigationIcon = {
IconButton(onClick = { showConnectionWizard = false }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
)
}
},
)
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
showConnectionWizard = false
Toast.makeText(
context,
"Paired successfully",
Toast.LENGTH_SHORT,
).show()
},
onCancel = { showConnectionWizard = false },
showSkip = false,
)
}
}
}
}
// (Pair flow lives at Screen.Pair / PairScreen.kt now — navigated to
// via onNavigateToPair() above. Kept out of this composable so the
// wizard gets a real Scaffolded window instead of a half-fullscreen
// Dialog leaking the cards behind it.)
// Connection info bottom sheets — tap-to-reveal details for each row
// in Settings → Connection. Mirrors the chat tap-agent-name overlay
@@ -1376,8 +1336,8 @@ fun ConnectionSettingsScreen(
)
}
// (QR scanner + TTL picker live inside ConnectionWizard now — see the
// showConnectionWizard block above.)
// (QR scanner + TTL picker live inside ConnectionWizard, which is
// hosted by Screen.Pair / PairScreen.kt.)
// Insecure ack dialog — first time the user flips the "Allow insecure"
// toggle on. Only gates the toggle itself; all actual pairing flows
@@ -0,0 +1,69 @@
package com.hermesandroid.relay.ui.screens
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
/**
* Full-screen pairing route. Wraps [ConnectionWizard] in a real Scaffold so
* the chooser tiles, manual-entry forms, and camera viewport all get the
* actual window — not a Compose Dialog that leaked the Settings cards
* underneath. Reached via Settings → Connection → Pair (or any "Re-pair"
* button), and pops back to wherever it came from on complete or cancel.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PairScreen(
connectionViewModel: ConnectionViewModel,
onComplete: () -> Unit,
onCancel: () -> Unit,
) {
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("Pair with your server") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
)
}
},
)
},
containerColor = MaterialTheme.colorScheme.background,
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
Toast.makeText(context, "Paired successfully", Toast.LENGTH_SHORT).show()
onComplete()
},
onCancel = onCancel,
showSkip = false,
)
}
}
}
@@ -0,0 +1,80 @@
package com.hermesandroid.relay.util
import android.os.Build
import android.view.View
import android.view.ViewGroup
import java.lang.reflect.Field
/**
* Compose 1.10.6 on Android 15 can spam logcat with
* `setRequestedFrameRate frameRate=NaN` from AndroidComposeView. Disabling the
* global Compose ARR flag should prevent new roots from opting into that path,
* but some roots can already exist by the time app code runs. This reflection
* fallback force-disables ARR on the concrete AndroidComposeView instances we
* attach so logcat stays usable until the upstream fix lands.
*/
object ComposeArrWorkaround {
private const val ANDROID_COMPOSE_VIEW = "androidx.compose.ui.platform.AndroidComposeView"
@Volatile
private var cachedOwnerClass: Class<*>? = null
@Volatile
private var isArrEnabledField: Field? = null
@Volatile
private var currentFrameRateField: Field? = null
@Volatile
private var currentFrameRateCategoryField: Field? = null
fun disableForViewTree(root: View) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return
visit(root)
}
private fun visit(view: View) {
disableForComposeRoot(view)
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
visit(view.getChildAt(i))
}
}
}
private fun disableForComposeRoot(view: View) {
if (view.javaClass.name != ANDROID_COMPOSE_VIEW) return
val ownerClass = view.javaClass
val arrField = field(ownerClass, "isArrEnabled") ?: return
runCatching { arrField.setBoolean(view, false) }
field(ownerClass, "currentFrameRate")?.let { runCatching { it.setFloat(view, 0f) } }
field(ownerClass, "currentFrameRateCategory")?.let {
runCatching { it.setFloat(view, 0f) }
}
}
private fun field(ownerClass: Class<*>, name: String): Field? {
if (cachedOwnerClass != ownerClass) {
cachedOwnerClass = ownerClass
isArrEnabledField = null
currentFrameRateField = null
currentFrameRateCategoryField = null
}
return when (name) {
"isArrEnabled" -> isArrEnabledField ?: ownerClass.findDeclaredField(name)?.also {
isArrEnabledField = it
}
"currentFrameRate" -> currentFrameRateField ?: ownerClass.findDeclaredField(name)?.also {
currentFrameRateField = it
}
"currentFrameRateCategory" ->
currentFrameRateCategoryField ?: ownerClass.findDeclaredField(name)?.also {
currentFrameRateCategoryField = it
}
else -> null
}
}
private fun Class<*>.findDeclaredField(name: String): Field? =
runCatching { getDeclaredField(name).apply { isAccessible = true } }.getOrNull()
}
@@ -143,6 +143,21 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application)
refreshPermissionStatus()
refreshBridgeStatusFromSystem()
// === PHASE3-bridge-ui-followup: react to MediaProjection grants ===
// The MediaProjection grant lands inside BridgeForegroundService
// (the only place where Android 14+ permits getMediaProjection),
// not in this ViewModel. We observe the holder's StateFlow so the
// permission row's green check lights up the moment the FGS stores
// the projection — no waiting for the next ON_RESUME, no race
// window where the user returns from the consent dialog and sees
// the row still red for a frame.
viewModelScope.launch {
MediaProjectionHolder.projectionFlow.collect {
refreshPermissionStatus()
}
}
// === END PHASE3-bridge-ui-followup ===
// === PHASE3-safety-rails: foreground service lifecycle ===
// Start/stop BridgeForegroundService based on the master toggle.
// distinctUntilChanged prevents re-firing the startForegroundService
@@ -162,6 +177,10 @@ class BridgeViewModel(application: Application) : AndroidViewModel(application)
// explicitly disabled it — no point showing "bridge
// active" when the toggle is off.
BridgeStatusOverlay.peek()?.setChipVisible(false)
// The projection is meaningless without the bridge —
// drop it on toggle-off so the row goes back to red
// and the next bridge enable prompts for fresh consent.
MediaProjectionHolder.revoke()
}
}
}
@@ -818,6 +818,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
payload: com.hermesandroid.relay.ui.components.HermesPairingPayload,
ttlSeconds: Long,
) {
android.util.Log.i(
"ConnectionVM",
"applyPairingPayload: serverUrl=${payload.serverUrl} keyPresent=${payload.key.isNotBlank()} " +
"relayBlock=${payload.relay != null} relayUrl=${payload.relay?.url} " +
"code=${payload.relay?.code} ttlSeconds=$ttlSeconds"
)
// SYNCHRONOUS RESET — must happen before this function returns so any
// wizard / verify watcher that observes authState immediately after
// sees Unpaired, not a stale Paired(token) from a prior install.
// applyServerIssuedCodeAndReset writes _authState.value synchronously,
// setPendingGrants/setPendingTtlSeconds are also sync. Putting these
// inside the coroutine below let the wizard race ahead and trip
// onComplete() against the stale Paired before the new pair started.
payload.relay?.let { relay ->
authManager.applyServerIssuedCodeAndReset(
code = relay.code,
relayUrl = relay.url,
)
authManager.setPendingGrants(relay.grants)
}
authManager.setPendingTtlSeconds(ttlSeconds)
viewModelScope.launch {
// API side — always present in any QR.
updateApiServerUrl(payload.serverUrl)
@@ -831,28 +853,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
if (relay.url.startsWith("ws://")) {
setInsecureMode(true)
}
// Wipe any TOFU pin for the new host + apply the code so the
// next WSS auth envelope rides the fresh server-issued code
// instead of the locally-generated fallback.
authManager.applyServerIssuedCodeAndReset(
code = relay.code,
relayUrl = relay.url,
)
authManager.setPendingGrants(relay.grants)
}
// Stash TTL for the next authenticate() call. Done unconditionally
// so even relay-less QRs persist the user's chosen TTL for the
// next pair attempt.
authManager.setPendingTtlSeconds(ttlSeconds)
// Kick the WSS handshake now if we have a relay. AuthManager is
// holding a fresh server-issued code so the pair-context gate on
// connectRelay will let it through.
payload.relay?.let { relay ->
android.util.Log.i(
"ConnectionVM",
"applyPairingPayload: disconnecting old relay + connecting to ${relay.url}"
)
disconnectRelay()
connectRelay(relay.url)
}
} ?: android.util.Log.w(
"ConnectionVM",
"applyPairingPayload: NO relay block in QR — relay/session will NOT pair"
)
// Fresh probe so the badges update without waiting for the next
// periodic tick.
@@ -1443,5 +1459,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
connectionManager.shutdown()
_apiClient.value?.shutdown()
tailscaleDetector.shutdown()
// Release the cached VirtualDisplay + ImageReader + HandlerThread
// built by ScreenCapture on the first /screenshot call. Without
// this, a process-rare VM teardown would leak the capture pipeline
// until the OS cleans up on exit.
runCatching { screenCapture.releaseCache() }
}
}
-209
View File
@@ -1,209 +0,0 @@
---
name: android
description: Control an Android phone remotely — navigate apps, tap, type, swipe, and automate Uber, WhatsApp, Spotify, Maps, Settings, Tinder
version: 1.0.0
metadata:
hermes:
tags: [android, phone, automation, accessibility]
category: android
---
# Android Device Control
You can control an Android phone remotely using the `android_*` tools. The phone runs the **Hermes-Relay** app which exposes an HTTP API. You communicate with it over the network — no USB, no ADB, no physical connection needed.
## How It Works
```
Hermes Agent (this server) ──HTTP──> Hermes Bridge app (Android phone)
├── Reads screen via AccessibilityService
├── Performs taps, types, swipes
└── Authenticated via pairing code
```
## Setup / Connecting a Phone
When the user wants to connect their phone, ask for their **pairing code** — a 6-character code shown in the Hermes Bridge app (e.g. `K7V3NP`).
Then call:
```
android_setup("<pairing_code>")
```
This does two things:
1. Starts a relay on this server (auto-detects the server's public IP)
2. Returns the exact instructions to tell the user — the server address and pairing code to enter in their phone app
**Relay the `user_instructions` field from the result directly to the user.** It contains the server IP and port they need to type into the phone app.
After the user taps Connect on their phone, the phone connects to this server via WebSocket. Call `android_ping()` to verify the connection is live.
**Do NOT ask about:**
- USB, ADB, or developer options
- The phone's IP address (not needed — the phone connects to the server, not the other way around)
- nginx, firewalls, or port forwarding
- Any networking concepts
**Just ask for the pairing code, call setup, and relay the instructions.**
## Available Tools
You have these 14 tools. Use them by name — they are function calls.
### Connectivity
- `android_ping()` — check if phone is connected and responding
- `android_setup(pairing_code)` — start relay and configure connection
### Reading the Screen
- `android_read_screen(include_bounds=False)` — get the full accessibility tree as JSON. Returns every visible UI element with text, className, nodeId, clickable, etc. **Always call this before interacting.**
- `android_screenshot()` — capture a screenshot as base64 PNG. Use when the accessibility tree doesn't show enough (canvas apps, image-heavy UIs).
- `android_current_app()` — get the package name and activity of the foreground app.
### Opening Apps
- `android_open_app(package)` — launch any app by package name. **This is the primary way to open apps. Do NOT try to find and tap app icons.** Example: `android_open_app("com.instagram.android")`
- `android_get_apps()` — list all installed apps with package names. Use this if you don't know the package name.
### Tapping
- `android_tap(x, y, node_id)` — tap by coordinates or node ID. Prefer node_id from read_screen.
- `android_tap_text(text, exact=False)` — tap the first element matching text. **Most convenient for buttons, menu items, links.**
### Typing
- `android_type(text, clear_first=False)` — type into the currently focused input field. Tap the field first.
### Gestures
- `android_swipe(direction, distance="medium")` — swipe up/down/left/right. Distances: short, medium, long.
- `android_scroll(direction, node_id=None)` — scroll a specific element or the whole screen.
### Keys
- `android_press_key(key)` — press a key. Options: `back`, `home`, `recents`, `power`, `volume_up`, `volume_down`, `enter`, `delete`, `tab`, `escape`, `search`, `notifications`
### Waiting
- `android_wait(text, class_name, timeout_ms=5000)` — poll until an element appears. Use after navigation or loading.
## Rules
### CRITICAL: Do not loop
- **Maximum 5-7 tool calls per user request.** After that, STOP and report what you did and what you see.
- **Do NOT keep taking screenshots in a loop.** Take ONE screenshot, analyze it, act, then report.
- **If an action doesn't work after 2 attempts, STOP and tell the user** what happened.
- **After completing the user's request, STOP and report the result.** Do not keep interacting with the screen.
### Workflow pattern
For any task, follow this pattern and then STOP:
1. `android_open_app(package)` — open the app
2. `android_read_screen()` — see what's on screen
3. 1-3 actions (tap, type, swipe) — do what the user asked
4. `android_read_screen()` or `android_screenshot()` — verify the result
5. **Report to the user and STOP.** Do not take further actions unless the user asks.
### Other rules
1. **ALWAYS open apps with `android_open_app(package)`** — never try to find and tap the icon on the home screen or app drawer.
2. **Prefer `android_read_screen()` over `android_screenshot()`** — read_screen is faster and structured. Only use screenshot when the accessibility tree is insufficient (canvas/image-heavy apps).
3. **Prefer `android_tap_text("Button Text")` over coordinates** — it's more reliable.
4. **If you don't know a package name**, call `android_get_apps()` and search the results.
5. **Confirm destructive actions** (purchases, sends, deletions) with the user before executing.
6. **Handle permission dialogs** — look for "Allow"/"Deny" buttons. Tap "Allow" or "While using the app".
7. **Go back**: `android_press_key("back")`. **Go home**: `android_press_key("home")`.
---
## Common Package Names
| App | Package |
|-----|---------|
| Uber | com.ubercab |
| Bolt | com.bolt.client |
| WhatsApp | com.whatsapp |
| Spotify | com.spotify.music |
| Google Maps | com.google.android.apps.maps |
| Chrome | com.android.chrome |
| Gmail | com.google.android.gm |
| Instagram | com.instagram.android |
| X/Twitter | com.twitter.android |
| Tinder | com.tinder |
| Settings | com.android.settings |
---
## App-Specific Procedures
### Uber — Order a ride
1. `android_open_app("com.ubercab")`
2. `android_wait(text="Where to?", timeout_ms=8000)`
3. `android_tap_text("Where to?")`
4. `android_type("<destination>", clear_first=True)`
5. `android_wait(text="<destination keyword>")` then tap suggestion
6. `android_read_screen()` — read price and car options
7. **STOP** — Report options and price to user, wait for confirmation
8. After confirmation: `android_tap_text("UberX")` then `android_tap_text("Confirm UberX")`
9. `android_wait(text="Finding your driver", timeout_ms=10000)`
**Pitfalls:** Uber may block accessibility taps on some versions — fall back to screenshot + coordinates. Always mention surge pricing to user.
### WhatsApp — Send a message
1. `android_open_app("com.whatsapp")`
2. `android_wait(text="Chats")`
3. Existing chat: `android_tap_text("<contact name>")`
4. New chat: `android_tap_text("New chat")` → type contact name → tap match
5. `android_tap_text("Type a message")`
6. `android_type("<message text>")`
7. **STOP** — Confirm with user before sending
8. `android_tap_text("Send")` or `android_press_key("enter")`
**Pitfalls:** Message input is `android.widget.EditText`. Read screen after typing to verify before sending.
### Spotify — Play music
1. `android_open_app("com.spotify.music")`
2. `android_wait(text="Search", timeout_ms=8000)`
3. `android_tap_text("Search")`
4. `android_wait(class_name="android.widget.EditText")`
5. `android_type("<query>", clear_first=True)`
6. `android_wait(text="Songs", timeout_ms=5000)`
7. `android_read_screen()` then tap desired result
**Playback:** `android_tap_text("Play")`, `android_tap_text("Next")`, `android_tap_text("Pause")`
**Pitfalls:** Spotify uses custom views — screenshot may be more useful than read_screen.
### Google Maps — Get directions
1. `android_open_app("com.google.android.apps.maps")`
2. `android_wait(text="Search here", timeout_ms=8000)`
3. `android_tap_text("Search here")`
4. `android_type("<destination>", clear_first=True)`
5. Tap suggestion → `android_tap_text("Directions")`
6. `android_read_screen()` — report time, distance, route to user
7. Start navigation only if user confirms: `android_tap_text("Start")`
**Pitfalls:** Maps uses heavy canvas rendering — prefer `android_screenshot()`. Exit navigation with `android_press_key("back")`.
### Settings — Change system settings
1. `android_open_app("com.android.settings")`
2. `android_wait(text="Settings", timeout_ms=5000)`
3. Navigate by tapping section names:
- "Network & internet" → WiFi, mobile data
- "Connected devices" → Bluetooth, NFC
- "Display" → Brightness, dark mode
- "Sound & vibration" → Volume
- "Apps" → App management
4. `android_read_screen()` to find specific toggles
**Pitfalls:** Settings UI varies across manufacturers (Samsung, Pixel, Xiaomi). Always read_screen to discover actual labels. Use `android_scroll("down")` if setting not visible.
### Tinder — View profiles and interact
1. `android_open_app("com.tinder")`
2. `android_wait(timeout_ms=8000)`
3. `android_read_screen()` + `android_screenshot()` — Tinder is image-heavy
4. Report profile details to user
**IMPORTANT:** Always confirm with user before swiping or messaging.
- Like: `android_swipe("right")`
- Pass: `android_swipe("left")`
- Super Like: `android_swipe("up")`
**Pitfalls:** Tinder uses custom UI — accessibility tree is limited, prefer screenshots. "It's a Match!" popup: tap anywhere to dismiss.
-1
View File
@@ -38,7 +38,6 @@ exclude = ["plugin.tests*", "plugin.skills*", "app*", "docs*", "user-docs*", "sc
[tool.setuptools.package-data]
plugin = ["skill.md", "plugin.yaml"]
"plugin.skills" = ["android/*.md"]
# Note: `hermes_relay_bootstrap.pth` at the repo root is NOT installed by
# `pip install -e` automatically — setuptools' data-files doesn't ship to
@@ -0,0 +1,82 @@
---
name: hermes-relay-doctor
description: Smoke-test the Hermes-Relay bridge stack — checks relay health, phone connection, bridge channel, accessibility service, and screenshot capture in one pass
version: 1.0.0
author: Axiom Labs
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [android, bridge, debug, doctor, relay, health, troubleshoot]
category: devops
homepage: https://github.com/Codename-11/hermes-relay
related_skills: [hermes-relay-pair, hermes-relay-self-setup]
---
# Hermes-Relay Doctor
Runs a sequential smoke-test of the full bridge stack and reports pass/fail with fix hints for each check. Use this before reaching for `android_*` tools when something feels off.
## When to Use
- User runs `/hermes-relay-doctor`.
- User says the bridge isn't working, the phone isn't connecting, or a screenshot is failing.
- You want to verify the stack is healthy before issuing `android_*` tool calls.
- The `android_ping()` tool returns an error or `phone_connected: false`.
## Procedure
Run the shim if it is installed:
```bash
hermes-relay-doctor
```
If the shim is missing (pre-0.3.x installs or after a manual uninstall), run the checks inline:
```bash
PORT="${RELAY_PORT:-8767}"
curl -sf "http://127.0.0.1:$PORT/health"
curl -sf "http://127.0.0.1:$PORT/bridge/status"
curl -sf "http://127.0.0.1:$PORT/ping"
curl -sf "http://127.0.0.1:$PORT/current_app"
curl -sf "http://127.0.0.1:$PORT/screen"
curl -sf "http://127.0.0.1:$PORT/screenshot"
```
All six checks call loopback (`127.0.0.1`) — no bearer token needed.
## Interpreting Results
| Check | Pass | Fail / Fix |
|-------|------|------------|
| `/health` | `{"status":"ok","version":"..."}` (200) | Relay not running — `systemctl --user start hermes-relay` or check `journalctl --user -u hermes-relay -n 30` |
| `/bridge/status` | `{"phone_connected":true,...}` (200) | Phone not connected — open Hermes-Relay app and scan a pairing QR (`/hermes-relay-pair`) |
| `/ping` | `{"phone_connected":true}` (200) | Same as above — phone disconnected mid-session; re-scan QR |
| `/current_app` | `{"package":"...","activity":"..."}` (200) | Accessibility service not connected — go to Android Settings → Accessibility → Hermes-Relay → enable |
| `/screen` | JSON with `nodes` array (200) | Same as above — also check the permission checklist in the Bridge screen |
| `/screenshot` | `{"token":"..."}` (200) | MediaProjection not granted — see "Screenshot gap" below |
### Screenshot gap (known issue)
`/screenshot` returns `500 {"error":"MediaProjection not granted — enable Bridge screenshots in the app"}` when the accessibility service is running but the system MediaProjection consent dialog hasn't been accepted.
Fix:
1. Open the Hermes-Relay app → **Bridge** screen.
2. Toggle the **Allow Agent Control** switch on.
3. Android shows a "Start recording?" system dialog — tap **Start**.
4. Re-run `/hermes-relay-doctor` to confirm the screenshot check passes.
This is a per-boot requirement on Android — the MediaProjection grant is revoked on reboot.
## Re-installing the shim
```bash
hermes-relay-update
```
Or re-run the full installer:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
+8 -1
View File
@@ -73,6 +73,7 @@ QR_SECRET="$HERMES_HOME/hermes-relay-qr-secret"
SHIM_PATH="$HOME/.local/bin/hermes-pair"
STATUS_SHIM_PATH="$HOME/.local/bin/hermes-status"
UPDATE_SHIM_PATH="$HOME/.local/bin/hermes-relay-update"
DOCTOR_SHIM_PATH="$HOME/.local/bin/hermes-relay-doctor"
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
SERVICE_DST="$SYSTEMD_USER_DIR/hermes-relay.service"
PTH_NAME="hermes_relay_bootstrap.pth"
@@ -131,7 +132,7 @@ else
fi
# ── 5/6 Remove hermes-pair + hermes-status + hermes-relay-update shims ───
info "[5/6] Removing hermes-pair + hermes-status + hermes-relay-update shims..."
info "[5/6] Removing hermes-pair + hermes-status + hermes-relay-update + hermes-relay-doctor shims..."
if [ -f "$SHIM_PATH" ] || [ -L "$SHIM_PATH" ]; then
run "rm -f \"$SHIM_PATH\""
ok "Removed $SHIM_PATH"
@@ -150,6 +151,12 @@ if [ -f "$UPDATE_SHIM_PATH" ] || [ -L "$UPDATE_SHIM_PATH" ]; then
else
warn "$UPDATE_SHIM_PATH does not exist"
fi
if [ -f "$DOCTOR_SHIM_PATH" ] || [ -L "$DOCTOR_SHIM_PATH" ]; then
run "rm -f \"$DOCTOR_SHIM_PATH\""
ok "Removed $DOCTOR_SHIM_PATH"
else
warn "$DOCTOR_SHIM_PATH does not exist"
fi
# ── 4/6 Remove skills external_dirs entry from config.yaml ────────────────
info "[4/6] Removing skills external_dirs entry..."
+1 -1
View File
@@ -117,7 +117,7 @@ Auth uses optional Bearer token (`API_SERVER_KEY`). Most local setups run withou
- Tokens stored in EncryptedSharedPreferences (AES-256-GCM, Android Keystore-backed).
- Codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambiguous 0/O/1/I" restriction only mattered when a human had to retype a code from a display; with QR + HTTP the restriction silently rejected valid codes.
- Old API-only QRs (no `relay` block) still parse cleanly — the `relay` field is nullable and the Android parser runs with `ignoreUnknownKeys = true`.
- Phase 3 (bridge) will use the symmetric phone-generates, host-approves flow and reuse `/pairing/register` from the opposite direction; phone-side `AuthManager.generatePairingCode()` is retained for that reason.
- A future symmetric phone-generates, host-approves flow for the bridge channel will reuse `/pairing/register` from the opposite direction; phone-side `AuthManager.generatePairingCode()` is retained for that reason.
---
+3 -3
View File
@@ -117,11 +117,11 @@ For the full decision guide and install instructions for each, see [Release trac
## Coming Soon
| Feature | Phase |
|---------|-------|
| Feature | Status |
|---------|--------|
| Push Notifications | Future — Agent-initiated alerts |
| Memory Viewer | Future — View/edit agent memories |
| Cross-device handoff | Phase 3+ — Hand a task from phone to desktop terminal session |
| Cross-device handoff | Future — Hand a task from phone to desktop terminal session |
<style scoped>
.track-badge {
+1 -1
View File
@@ -195,7 +195,7 @@ If you'd rather not use Google Play, you can install the signed APK directly fro
Head to [github.com/Codename-11/hermes-relay/releases/latest](https://github.com/Codename-11/hermes-relay/releases/latest) and grab the file ending in **`-sideload-release.apk`** from the assets list — for example, `hermes-relay-0.3.0-sideload-release.apk`. Every release is version-tagged, so the exact prefix changes each version but the `-sideload-release.apk` suffix stays constant.
::: tip Why "sideload" and not "googlePlay"?
Each release ships both a `-sideload-release.apk` (full Phase 3 feature set — bridge channel, voice-to-bridge intents, vision-driven navigation) and a `-googlePlay-release.apk` (conservative Play Store build). Most sideloaders want the `-sideload-` flavor. The two builds install with different application IDs, so you can have both side-by-side.
Each release ships both a `-sideload-release.apk` (full feature set — bridge channel, voice-to-bridge intents, vision-driven navigation) and a `-googlePlay-release.apk` (conservative Play Store build). Most sideloaders want the `-sideload-` flavor. The two builds install with different application IDs, so you can have both side-by-side.
:::
::: warning Download the .apk, not the .aab
+1 -1
View File
@@ -124,7 +124,7 @@ hermes relay start [OPTIONS] (or: python -m plugin.relay)
| `/health` | GET | `{status, version, clients, sessions}` JSON |
| `/pairing` | POST | Generate a new relay-side pairing code |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can be embedded in a QR payload. Optional body fields `ttl_seconds` / `grants` / `transport_hint` attach pairing metadata that applies to the session when the phone consumes the code — operator policy wins over phone-sent values. Also **clears all rate-limit blocks on success** so legitimate re-pair after a relay restart works immediately. Used by `/hermes-relay-pair` / `hermes-pair` on the same host. Rejects non-loopback peers with HTTP 403. |
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape as `/pairing/register`. Reserved for the phone-generates-code / host-approves direction that lands with the bridge channel. |
| `/pairing/approve` | POST | **Loopback only, reserved for future use.** Same wire shape as `/pairing/register`. Placeholder for a future phone-generates-code / host-approves flow that would complement the existing QR pairing direction. |
| `/sessions` | GET | Bearer-auth'd (same token the WSS channel uses). Returns all active paired devices with metadata — device name, token prefix (first 8 chars, full token never exposed), created/last-seen timestamps, session expiry, per-channel grants, transport hint, and `is_current` for the device matching the bearer. `math.inf` expiries serialize as `null` (never expire). |
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by token-prefix (≥ 4 chars). 200 on exact match, 404 on zero, 409 on ambiguous matches. Self-revoke is allowed and flagged via `revoked_self: true`. |
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd. Update a paired device's session TTL and/or per-channel grants in place. Body `{ttl_seconds?, grants?}`. TTL restarts the clock from now; grants re-clamp automatically. Powers the Paired Devices "Extend" button. |