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:
co-authored by
Claude Opus 4.6
parent
63a60ea077
commit
5cc8036f3c
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# @subframe-version 0.15.1-beta
|
||||
# @subframe-managed
|
||||
#
|
||||
# SubFrame pre-commit hook
|
||||
# Auto-updates STRUCTURE.json when JS files in src/ are committed.
|
||||
#
|
||||
|
||||
# Check if any JS files in src/ are staged
|
||||
STAGED_JS=$(git diff --cached --name-only --diff-filter=ACMRD | grep -E '^src/.*\.(js|ts|tsx|jsx)$' || true)
|
||||
|
||||
# Also check for deleted source files
|
||||
DELETED_JS=$(git diff --cached --name-only --diff-filter=D | grep -E '^src/.*\.(js|ts|tsx|jsx)$' || true)
|
||||
|
||||
if [ -z "$STAGED_JS" ] && [ -z "$DELETED_JS" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only update if .subframe/STRUCTURE.json exists (SubFrame project)
|
||||
if [ ! -f ".subframe/STRUCTURE.json" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only update if the updater script exists
|
||||
UPDATER=".githooks/update-structure.js"
|
||||
if [ ! -f "$UPDATER" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[SubFrame] Source files changed, updating .subframe/STRUCTURE.json..."
|
||||
|
||||
# Run the updater with staged/deleted file lists as env vars
|
||||
STAGED_FILES="$STAGED_JS" DELETED_FILES="$DELETED_JS" node "$UPDATER"
|
||||
|
||||
# Stage the updated .subframe/STRUCTURE.json
|
||||
git add .subframe/STRUCTURE.json
|
||||
|
||||
echo "[SubFrame] .subframe/STRUCTURE.json updated and staged."
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# @subframe-version 0.15.1-beta
|
||||
# @subframe-managed
|
||||
# SubFrame pre-push hook
|
||||
# Triggers pipeline workflows configured with "on: { push: true }"
|
||||
# To bypass: git push --no-verify
|
||||
|
||||
SUBFRAME_DIR=".subframe"
|
||||
PIPELINES_DIR="$SUBFRAME_DIR/pipelines"
|
||||
TRIGGER_FILE="$PIPELINES_DIR/.pre-push-trigger"
|
||||
|
||||
# Only trigger if SubFrame is initialized
|
||||
if [ ! -d "$SUBFRAME_DIR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Write trigger file for SubFrame to detect
|
||||
mkdir -p "$PIPELINES_DIR"
|
||||
echo "{\"trigger\": \"pre-push\", \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$TRIGGER_FILE"
|
||||
|
||||
# Don't block the push — pipeline runs async via SubFrame UI
|
||||
exit 0
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
// @subframe-version 0.15.1-beta
|
||||
// @subframe-managed
|
||||
/**
|
||||
* SubFrame STRUCTURE.json Updater
|
||||
* Called by .githooks/pre-commit when source files in src/ are staged.
|
||||
* Reads STAGED_FILES and DELETED_FILES from environment variables.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const STRUCTURE_FILE = path.join(ROOT, '.subframe', 'STRUCTURE.json');
|
||||
const SRC_DIR = path.join(ROOT, 'src');
|
||||
|
||||
// Strip any JS/TS extension for module key
|
||||
function stripExt(p) {
|
||||
return p.replace(/\.(js|ts|tsx|jsx)$/, '');
|
||||
}
|
||||
|
||||
// Load existing STRUCTURE.json
|
||||
let structure;
|
||||
try {
|
||||
structure = JSON.parse(fs.readFileSync(STRUCTURE_FILE, 'utf-8'));
|
||||
} catch (e) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!structure.modules) {
|
||||
structure.modules = {};
|
||||
}
|
||||
|
||||
const files = (process.env.STAGED_FILES || '').split('\n').filter(Boolean);
|
||||
const deleted = (process.env.DELETED_FILES || '').split('\n').filter(Boolean);
|
||||
|
||||
// Remove deleted modules
|
||||
for (const file of deleted) {
|
||||
const key = stripExt(path.relative(SRC_DIR, path.join(ROOT, file)))
|
||||
.replace(/\\/g, '/');
|
||||
if (structure.modules[key]) {
|
||||
delete structure.modules[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse each staged file
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(ROOT, file);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(fullPath, 'utf-8');
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = stripExt(path.relative(SRC_DIR, fullPath))
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
// Extract description from top JSDoc comment
|
||||
let description = '';
|
||||
const docMatch = content.match(/^\/\*\*\s*\n\s*\*\s*([^\n]+)/);
|
||||
if (docMatch) description = docMatch[1].trim();
|
||||
|
||||
// Extract exports — CJS (module.exports) and ESM (export { ... }, export function)
|
||||
const xports = [];
|
||||
const cjsMatch = content.match(/module\.exports\s*=\s*\{([^}]+)\}/);
|
||||
if (cjsMatch) {
|
||||
cjsMatch[1].split(',').forEach(function(s) {
|
||||
const name = s.trim().split(':')[0].trim();
|
||||
if (name && !name.startsWith('//')) xports.push(name);
|
||||
});
|
||||
}
|
||||
// ESM named exports: export { foo, bar } or export function foo
|
||||
const esmExportRe = /^export\s+(?:function|const|let|class|async\s+function)\s+(\w+)/gm;
|
||||
let em;
|
||||
while ((em = esmExportRe.exec(content)) !== null) {
|
||||
if (!xports.includes(em[1])) xports.push(em[1]);
|
||||
}
|
||||
|
||||
// Extract dependencies — CJS require() and ESM import
|
||||
const deps = [];
|
||||
const reqRe = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
let m;
|
||||
while ((m = reqRe.exec(content)) !== null) {
|
||||
const dep = m[1];
|
||||
if (dep.startsWith('./') || dep.startsWith('../')) {
|
||||
deps.push(stripExt(dep.replace(/^\.+\//, '')));
|
||||
} else {
|
||||
deps.push(dep);
|
||||
}
|
||||
}
|
||||
const importRe = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
const dep = m[1];
|
||||
if (dep.startsWith('./') || dep.startsWith('../')) {
|
||||
deps.push(stripExt(dep.replace(/^\.+\//, '')));
|
||||
} else {
|
||||
deps.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract function names with line numbers
|
||||
const functions = {};
|
||||
const fnRe = /^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/gm;
|
||||
while ((m = fnRe.exec(content)) !== null) {
|
||||
const lineNum = content.substring(0, m.index).split('\n').length;
|
||||
functions[m[1]] = { line: lineNum };
|
||||
}
|
||||
|
||||
const existing = structure.modules[key] || {};
|
||||
structure.modules[key] = {
|
||||
file: file,
|
||||
description: description || existing.description || '',
|
||||
exports: xports,
|
||||
depends: deps.filter(function(v, i, a) { return a.indexOf(v) === i; }),
|
||||
functions: Object.keys(functions).length > 0 ? functions : (existing.functions || {})
|
||||
};
|
||||
}
|
||||
|
||||
// Update timestamp and save
|
||||
structure.lastUpdated = new Date().toISOString().split('T')[0];
|
||||
if (structure._frame_metadata) {
|
||||
structure._frame_metadata.lastUpdated = structure.lastUpdated;
|
||||
}
|
||||
fs.writeFileSync(STRUCTURE_FILE, JSON.stringify(structure, null, 2) + '\n');
|
||||
@@ -1,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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-60
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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..."
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user