Compare commits
208
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
545d238fd2 | ||
|
|
9b36a34e2a | ||
|
|
7c35a51aed | ||
|
|
474147cb62 | ||
|
|
0bd246586a | ||
|
|
fc6aaffc11 | ||
|
|
197b23f344 | ||
|
|
0862b4c346 | ||
|
|
9c7bc46c7c | ||
|
|
9554eab757 | ||
|
|
3cacc7d6d6 | ||
|
|
311888a9fb | ||
|
|
96dfe472ef | ||
|
|
7d6a0215ba | ||
|
|
51ec55e08c | ||
|
|
4edcd18398 | ||
|
|
3f50a94d03 | ||
|
|
94d2231a80 | ||
|
|
5ac798d5b3 | ||
|
|
ebe1a8ebc4 | ||
|
|
fc3a1c169b | ||
|
|
6eb2c8bd9e | ||
|
|
cbe323cf6d | ||
|
|
5401e8f274 | ||
|
|
0b62afa6c3 | ||
|
|
02801dda70 | ||
|
|
39011d5600 | ||
|
|
e50c644d09 | ||
|
|
3ff4e7cf8e | ||
|
|
683d9712d8 | ||
|
|
7ae4a3987e | ||
|
|
9dcb280eff | ||
|
|
a90067292a | ||
|
|
4447886e80 | ||
|
|
5d00885291 | ||
|
|
bbea5d1b73 | ||
|
|
cce4b9b1d6 | ||
|
|
d3a4bd5423 | ||
|
|
c3ff201ecc | ||
|
|
20b75d9454 | ||
|
|
d04264a224 | ||
|
|
ee30ec4bf7 | ||
|
|
111c5409bf | ||
|
|
03ce9aa8d5 | ||
|
|
41c2d10700 | ||
|
|
c508392261 | ||
|
|
e9c0620d24 | ||
|
|
217746a243 | ||
|
|
a40e6a5b01 | ||
|
|
118bf07638 | ||
|
|
95f6721b06 | ||
|
|
ac023c0e24 | ||
|
|
cd7792918e | ||
|
|
e617809930 | ||
|
|
79532c6c9b | ||
|
|
0af1cda38e | ||
|
|
454bd44e7a | ||
|
|
b92a40174e | ||
|
|
c6ab26ef13 | ||
|
|
414ade5fa0 | ||
|
|
9fdb11ae94 | ||
|
|
fd35c9b2b6 | ||
|
|
9dc625ddd0 | ||
|
|
e5b25ab650 | ||
|
|
ea16750af4 | ||
|
|
7ae95915bf | ||
|
|
c85f42c51c | ||
|
|
15e0106648 | ||
|
|
58d7e8581b | ||
|
|
8c570b214d | ||
|
|
52f19ca028 | ||
|
|
acc7daa6f2 | ||
|
|
9ca42e3e6c | ||
|
|
781969d782 | ||
|
|
279b83a77c | ||
|
|
2f949c7d15 | ||
|
|
c77fd057bb | ||
|
|
ed1c47f47d | ||
|
|
c1800a3ddd | ||
|
|
f5c2a2b888 | ||
|
|
3ec5a09885 | ||
|
|
2a8038a1bd | ||
|
|
d0684dd252 | ||
|
|
dae75ebe13 | ||
|
|
82fb46b914 | ||
|
|
3ac74404bf | ||
|
|
9187d8e77c | ||
|
|
9b7bf0f7fd | ||
|
|
a59b54c600 | ||
|
|
9c56598438 | ||
|
|
4efc52dd5b | ||
|
|
b274e6f2a6 | ||
|
|
6f5c49be17 | ||
|
|
b328370d32 | ||
|
|
0e1d70e8ff | ||
|
|
4ea41386ee | ||
|
|
6ac7e5457f | ||
|
|
46463f4b00 | ||
|
|
2f72c5444c | ||
|
|
ecc97416fc | ||
|
|
88667eea89 | ||
|
|
febd938651 | ||
|
|
9419615068 | ||
|
|
8e4fffab6d | ||
|
|
1112a622a7 | ||
|
|
285342bf7e | ||
|
|
401acdda8e | ||
|
|
a0299d62ca | ||
|
|
4f37b87a3d | ||
|
|
7d3ad1c19f | ||
|
|
54e069888d | ||
|
|
7d3ebfb842 | ||
|
|
7d9552bf1e | ||
|
|
73c0b6c99d | ||
|
|
9b68577c47 | ||
|
|
ec3ff1da02 | ||
|
|
0d78077393 | ||
|
|
eaf345b9c7 | ||
|
|
2d4afd035c | ||
|
|
b5f3eba340 | ||
|
|
e6c48d5fa9 | ||
|
|
eed739c3a3 | ||
|
|
91ddebde79 | ||
|
|
a1d99f390f | ||
|
|
af284952e7 | ||
|
|
4b872f3f54 | ||
|
|
b725f2b295 | ||
|
|
0359fb46ff | ||
|
|
0e8ab74685 | ||
|
|
f49b8d8161 | ||
|
|
8361a059e8 | ||
|
|
f6c222ced5 | ||
|
|
05cda21119 | ||
|
|
310893a49a | ||
|
|
09c48efecf | ||
|
|
748c3b1c07 | ||
|
|
259d64fe30 | ||
|
|
e2044a15ea | ||
|
|
33cc622643 | ||
|
|
2c388a4c73 | ||
|
|
a4323a6b04 | ||
|
|
af6680090f | ||
|
|
54362c7d31 | ||
|
|
40837013c7 | ||
|
|
70edc5e73f | ||
|
|
55e8486f94 | ||
|
|
ab4519d4cf | ||
|
|
d5cfa2bf5c | ||
|
|
c5376b2c25 | ||
|
|
ee33666e1f | ||
|
|
c357f201c6 | ||
|
|
17a439cfaa | ||
|
|
6b888e91d3 | ||
|
|
0793f9213c | ||
|
|
146652e475 | ||
|
|
9bdaf3a02f | ||
|
|
e08d0e13a4 | ||
|
|
3c10c67075 | ||
|
|
e8e51d9ee4 | ||
|
|
dd5a80d03c | ||
|
|
06a2f35144 | ||
|
|
3d78e79c5d | ||
|
|
50f745d9da | ||
|
|
33e8a11615 | ||
|
|
21bbad5f3b | ||
|
|
6524157549 | ||
|
|
e8192b09dd | ||
|
|
ef3916a143 | ||
|
|
f2b92b2755 | ||
|
|
8651656899 | ||
|
|
b458d83fcc | ||
|
|
f4ae8d21ca | ||
|
|
b2f8070a1b | ||
|
|
27f1393ea7 | ||
|
|
d8f8082639 | ||
|
|
5df42c1fda | ||
|
|
778c15de7f | ||
|
|
a606eb7c40 | ||
|
|
a4df726bae | ||
|
|
f87de0f96a | ||
|
|
2471c3dd55 | ||
|
|
c53b0c6aa3 | ||
|
|
8ade8debf0 | ||
|
|
e84eeb8e4f | ||
|
|
d28f0d5de7 | ||
|
|
c680f6f896 | ||
|
|
7df350365c | ||
|
|
0795565a4d | ||
|
|
fe4ef2441c | ||
|
|
042f5c0927 | ||
|
|
2998773e70 | ||
|
|
91ab611b54 | ||
|
|
c76d0bf33e | ||
|
|
ff965305d2 | ||
|
|
a7d76d56e5 | ||
|
|
6e1e3d8b38 | ||
|
|
04944ffe8d | ||
|
|
34cf109804 | ||
|
|
e459f24a1a | ||
|
|
4346e33fd4 | ||
|
|
ca0c5b2a54 | ||
|
|
aa26f7c9b6 | ||
|
|
325b8e5670 | ||
|
|
0e6d64f987 | ||
|
|
e9da59cf40 | ||
|
|
c9a03c9ed7 | ||
|
|
e3bc816624 | ||
|
|
68f8b58a0b |
@@ -63,7 +63,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue number to label again"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: issue-triage-${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-label:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' && github.event.issue.user.type != 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label from title prefix and issue area
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
with:
|
||||
script: |
|
||||
const issue_number = Number(process.env.ISSUE_NUMBER);
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number,
|
||||
});
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const haystack = `${title}\n${body}`;
|
||||
const labels = [];
|
||||
|
||||
if (title.startsWith('[bug]')) labels.push('bug');
|
||||
else if (title.startsWith('[feature]') || title.startsWith('[feat]')) labels.push('enhancement');
|
||||
else if (title.startsWith('[docs]')) labels.push('documentation');
|
||||
|
||||
if (/\b(cli|desktop|terminal|daemon|pty|hermes-relay (install|binary|tray))\b/.test(haystack)) labels.push('area:cli');
|
||||
else if (/\b(dashboard|plugin ui|react)\b/.test(haystack)) labels.push('area:dashboard');
|
||||
else if (/\b(relay|plugin|aiohttp|python|pairing|voice (transcribe|synthesize)|bridge (endpoint|route))\b/.test(haystack)) labels.push('area:plugin');
|
||||
else if (/\b(readme|user-?docs|documentation)\b/.test(haystack)) labels.push('area:docs');
|
||||
else if (/\b(android|app|compose|apk|phone|samsung|gradle|chat|voice|notification|sphere|keystore)\b/.test(haystack)) labels.push('area:android');
|
||||
|
||||
if (!labels.length) {
|
||||
core.info('No deterministic label matched; leaving the issue for maintainer triage.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number, labels,
|
||||
});
|
||||
core.info(`Applied labels: ${labels.join(', ')}`);
|
||||
} catch (error) {
|
||||
core.warning(`Could not apply ${labels.join(', ')}: ${error.message}`);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v6.2.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
@@ -191,6 +191,19 @@ jobs:
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
# The Play AAB carries its mapping for Play Console deobfuscation, but
|
||||
# sideload issue reports need the exact mapping from this immutable build.
|
||||
# Keep both variants as a workflow artifact (not a public release asset).
|
||||
- name: Retain R8 mappings for retrace
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
|
||||
path: |
|
||||
app/build/outputs/mapping/googlePlayRelease/mapping.txt
|
||||
app/build/outputs/mapping/sideloadRelease/mapping.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
@@ -8,8 +8,86 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Android 1.7.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Android exposes provider-aware reasoning controls.** The effort drawer consumes exact upstream or optional Relay capability metadata for each provider/model identity, while unmodified or older Hermes installations retain a fail-soft standard fallback including `max` and `ultra`.
|
||||
- **Android support information is local, redacted, and reviewable.** Fatal crashes and handled failures share a bounded on-device record, Diagnostics can copy or share the exact reviewed text, and nothing is uploaded automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android chat chrome follows its active interaction state.** Opening the session drawer dismisses the composer keyboard, refreshed sessions keep their newest row visible, and floating pets wait for measured chat terrain, sit flush on supported rails, and treat the complete scroll-to-bottom control as forbidden space.
|
||||
- **Android pets and optional model discovery initialize quietly.** Floating companions wait for a measured overlay before taking their home position, and background API model-inventory failures retain actionable local diagnostics without interrupting chat with a generic notice.
|
||||
- **Android chat and Voice stay precisely bottom-pinned through replies, restores, and layout changes.** The active tail keeps its stable live renderer until another row takes ownership, restored sessions follow late composer and message measurement without overriding a reader, and bottom-owned transcripts settle to the exact list boundary after replies and keyboard animations instead of leaving a small hidden remainder.
|
||||
- **Android Focus voice controls remain responsive.** The modal click-through guard now sits behind the voice UI instead of consuming pointer events from the mic, close, expansion, and panel controls.
|
||||
- **Android diagnostics explain what failed and what to try next.** Relay, route, WebSocket, and API checks distinguish the saved route from the redacted request they actually attempted, name the operation, and provide targeted guidance for connection, DNS, timeout, TLS, authentication, rate-limit, and server failures.
|
||||
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
|
||||
- **Android crash reports retain actionable release context.** Reports identify the Android surface, avoid exposing hosts and credentials, migrate earlier local crash records, and release automation retains exact Play and sideload R8 mappings for retrace.
|
||||
|
||||
## [1.6.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay supplies exact provider/model reasoning capabilities when providers expose them.** The bounded, profile-aware overlay resolves dynamic catalogs for OpenAI Codex, Copilot, LM Studio, and Ollama Cloud, keeps provider credentials on the host, and leaves unknown or unavailable catalogs on the advisory fallback.
|
||||
|
||||
## [Android 1.6.1] - 2026-08-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice capture waits for the microphone to be released.** Manual recording no longer races barge-in teardown, and AudioRecord startup failures now explain how to free or permit the microphone before retrying.
|
||||
- **Android text selection stays stable as streamed replies finish.** Chat resets an active selection when live text becomes rich Markdown, preventing selection-handle drags from retaining removed text nodes.
|
||||
- **Android session history follows the upstream page-size contract.** The drawer keeps its 200-session window through bounded 100-row requests, avoiding HTTP 422 errors from current dashboard servers while preserving active-profile isolation.
|
||||
- **Android no longer mistakes optional-surface auth failures for expired Relay pairing.** Background session refreshes stay out of the global snackbar, Dashboard and API authorization errors name their owning credential, and Relay-only surfaces use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states. Foreground recovery retries ordinary Relay backoff immediately while preserving server rate limits, and recovery prioritizes Dashboard or host session management while retained credentials are labeled as stored details instead of active pairing.
|
||||
- **Voice controls no longer collide with new-chat coaching.** The clean-view hint yields while Voice owns the composer so it cannot cover the expanding Voice drawer.
|
||||
|
||||
## [1.5.1] - 2026-08-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Re-pairing repairs one device instead of accumulating duplicate sessions.** An explicit host-approved pair replaces older sessions and refresh credentials for the same device, while the Dashboard and `/relay revoke <token-prefix>` remain available for operator cleanup.
|
||||
|
||||
## [Android 1.6.0] - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Hermes can be selected as Android’s default Digital Assistant.** The opt-in system role supports background and locked-screen invocation, while the separate experimental “Hey Hermes” listener keeps pre-activation audio on the phone and exposes an ongoing Stop control.
|
||||
- **Installed Hermes plugins can contribute native Android pages.** Android renders a bounded declarative schema instead of plugin code, keeps write access off until the user grants it, and supports approval-gated agent-created previews through Relay 1.5.0.
|
||||
- **Pets can stay with you across the Android app without replacing the agent.** Petdex and imported companions live in an app-level overlay, can be held and dragged, and optionally roam across live-measured chat and settings surfaces without reserving message space. (#267)
|
||||
- **Petdex browsing and one-tap installation are built into Appearance.** Search results use lightweight previews, full atlases download only after Install, creator attribution remains visible, and installed pets stay available offline. (#267)
|
||||
- **Android can be used in Russian.** Both product flavors include an AI-assisted Russian catalog, language picker support, localized plurals, and refreshed translations for the 1.6 feature set.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Assistant and floating Voice surfaces use compact, expandable controls.** Opening full Voice continues the same turn and microphone owner instead of restarting the session.
|
||||
- **Voice interruption covers generation and playback.** Barge-in follows upstream RMS calibration and timing, exact stop phrases can end an active voice chat, and interrupted spoken context remains private to the next Standard turn.
|
||||
- **Profile identity, the Sphere, and pets are separate appearance choices.** Agent avatars identify messages, background visualization controls ambient art, and Floating pet controls the companion independently. (#267)
|
||||
- **The Agent Passport exposes more profile state and safer controls.** Profile configuration, skills, routing, reasoning, and scoped API access remain visibly distinct from the active session identity.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice output recovers when a streaming renderer produces no audio.** Android falls back to basic synthesis after a bounded first-audio timeout, and long Standard Voice uploads no longer retain duplicate encoded audio buffers.
|
||||
- **Relay route failover avoids competing reconnect loops.** Route changes settle through one generation-aware reconnect owner instead of rapidly switching between LAN and remote candidates.
|
||||
- **Live chat rows keep stable UI identity while upstream state reconciles.** Streamed messages and process rows no longer collide or restart merely because a server identity arrives later.
|
||||
- **Floating pets recover from invalid or scrolling terrain.** Roaming uses measured bubble edges, avoids the jump-to-latest control and text overlap, resumes after drag or scrolling, and preserves locomotion, held, drop, and fallback animation states.
|
||||
- **Hermes appears and activates in OEM Android assistant pickers.** Required Assist, Voice, recognition-service, and single-microphone lifecycle metadata now agree.
|
||||
- **Experimental wake detection handles completed sherpa results and empty speech cleanly.** Tests use the real local microphone/model path, and no-speech activation returns to ready state instead of surfacing a fatal server error.
|
||||
|
||||
## [Android 1.5.3] - 2026-07-31
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice transcripts retain stable rows after chat-history reconciliation.** Focus mode uses the same stable Compose identity as the main conversation, preventing duplicate-key crashes when live rows adopt persisted server IDs.
|
||||
|
||||
## [1.5.0] - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Realtime Agent sessions can speak only settled answers.** Clients may enable an optional per-session `final_answer_only` policy that suppresses routine acknowledgements, progress narration, and intermediate commentary while preserving spoken approvals, confirmation questions, blocking failures, and the final Hermes answer.
|
||||
- **Agents can draft native Android plugin pages through Relay.** New tools store bounded declarative JSON pages under the authenticated Relay plugin namespace, while Android retains control of enablement, publication, write grants, and persistent removal. Generated pages cannot include executable code, arbitrary network calls, Android intents, or backend action requests.
|
||||
## [Android 1.5.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
|
||||
+32
-7
@@ -17,17 +17,41 @@ That's it — no extra setup or credentials required for a debug build.
|
||||
Helper scripts for common development tasks:
|
||||
|
||||
```bash
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat build # Build the sideload debug APK
|
||||
scripts/dev.bat compile # Compile sideload Kotlin only
|
||||
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Run one test class
|
||||
scripts/dev.bat install-fast # Build arm64 only + install + launch
|
||||
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 run # Build sideload + install + launch + logcat
|
||||
scripts/dev.bat test # Run sideload debug 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`.
|
||||
|
||||
### Fast Android iteration
|
||||
|
||||
Gradle's daemon, local build cache, configuration cache, and parallel task
|
||||
execution are enabled for repeat local builds. Keep the same Gradle JVM
|
||||
configuration between invocations and do not add `--no-daemon` to normal dev
|
||||
commands; a different heap or Java home starts a separate daemon and discards
|
||||
the warm-process benefit.
|
||||
|
||||
Use the narrowest command that proves the change:
|
||||
|
||||
1. `scripts/dev.bat compile` for a Kotlin compile check.
|
||||
2. `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"` for a focused regression.
|
||||
3. `scripts/dev.bat install-fast` when the result must run on the connected
|
||||
arm64 phone. This passes `-Phermes.devAbi=arm64-v8a`, avoiding the x86,
|
||||
x86_64, and armeabi-v7a native libraries in the local APK.
|
||||
4. `scripts/dev.bat prepush` before pushing Android work.
|
||||
|
||||
`install-fast` is intentionally phone-specific. Use `install` for a universal
|
||||
sideload debug APK or when the target ABI is not arm64. Release builds remain
|
||||
universal and are unaffected unless `-Phermes.devAbi` is explicitly supplied.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
@@ -51,12 +75,12 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Android App** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
|
||||
|
||||
## Running the Relay Locally
|
||||
|
||||
@@ -174,7 +198,8 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||
to catch common hosted failures without waiting for another full Actions
|
||||
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Focused Android unit test:** `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"`
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs the sideload debug JUnit + MockK + Compose suite)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
|
||||
@@ -1,5 +1,313 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-08-05 — Restored chat bottom ownership and effort fallback clarity
|
||||
|
||||
Opening an existing Android session now retains exact bottom ownership through
|
||||
late, non-streaming layout changes. Composer capability controls, status rows,
|
||||
and restored message content can finish measuring after history first reaches
|
||||
the footer; a session-scoped geometry observer corrects those changes without
|
||||
using a fixed delay. New-message following remains governed by the smooth
|
||||
auto-scroll setting, while a real drag, IME ownership, and the Voice dock keep
|
||||
their existing anchors.
|
||||
|
||||
The advisory effort drawer now states that Hermes does not advertise exact
|
||||
levels for the selected model before explaining why standard options are shown.
|
||||
The wording is consistent across all shipped Android locales.
|
||||
|
||||
## 2026-08-05 — Provider-aware reasoning effort discovery
|
||||
|
||||
The optional Relay plugin now exposes a bearer-protected, profile-aware model
|
||||
capability overlay without requiring changes to upstream Hermes. Android merges
|
||||
that overlay with the standard `model.options` inventory using exact provider
|
||||
and model identities, while older or unpaired Relay installations continue with
|
||||
the canonical advisory fallback.
|
||||
|
||||
Dynamic LM Studio, Ollama Cloud, and Copilot discovery is bounded by a shared
|
||||
network limiter, cached by profile, endpoint, model, and credential fingerprint,
|
||||
and fenced across refresh generations. Neither credentials nor internal cache
|
||||
scope are returned to clients. Composer controls, Agent Passport, session
|
||||
creation, and asynchronous server reconciliation share the same capability
|
||||
resolver so a displayed effort cannot silently differ from the value sent.
|
||||
|
||||
## 2026-08-05 — Chat drawer and companion terrain ownership
|
||||
|
||||
The Chat screen now clears composer focus when the session drawer commits to
|
||||
opening, dismissing the IME without continuously clearing focus from drawer
|
||||
search or rename fields. Drawer refreshes override keyed list anchoring only
|
||||
when the leading session identity changes, keeping the newest row visible after
|
||||
activity-based reordering.
|
||||
|
||||
Floating companions wait for Chat's measured composer rail before publishing
|
||||
their first roaming position. Their collision footprint contains both the
|
||||
pointer target and rendered sprite, and the complete scroll-to-bottom control
|
||||
envelope is an obstacle rather than a landing perch. Supported rails add no
|
||||
visual lift, and the floating-only renderer aligns each frame's opaque bottom
|
||||
edge to its canvas baseline so transparent atlas padding cannot make pets hover;
|
||||
centered previews and message avatars remain unchanged.
|
||||
|
||||
## 2026-08-05 — Measured pet placement and passive model sync
|
||||
|
||||
The floating pet now remains unpublished until the app-level overlay has a
|
||||
positive measured viewport. Its initial home coordinate is therefore derived
|
||||
from the real safe bounds instead of the zero-size pre-measure bounds that
|
||||
collapsed to the top-left corner.
|
||||
|
||||
API provider inventory remains an optional background catalog on Gateway-led
|
||||
connections. A timeout, refusal, or unavailable optional route no longer emits
|
||||
a global chat notice during initialization, reconnection, or connection-sheet
|
||||
refresh. The failure is retained as a contextual warning in local Diagnostics,
|
||||
including the operation, endpoint role, redacted stack trace, preserved network
|
||||
cause, and targeted troubleshooting guidance. Cached and Gateway-owned model
|
||||
options remain unchanged.
|
||||
|
||||
## 2026-08-05 — Stable chat-tail completion
|
||||
|
||||
Chat and Voice now treat the active streamed reply as the owner of its live
|
||||
renderer until a different row becomes the conversation tail. Stream
|
||||
completion retains the existing Compose subtree and list anchor; the full
|
||||
Markdown renderer is deferred until the row is no longer active or the session
|
||||
is revisited.
|
||||
|
||||
The last-in-group timestamp occupies its final geometry from the first
|
||||
streaming frame and is only revealed at completion. Measured positive growth
|
||||
during an active stream continues to follow the bottom without replacing the
|
||||
logical anchor. Once completion layout stabilizes, a bottom-owned transcript
|
||||
settles to the exact LazyColumn boundary; proximity slop is reserved for
|
||||
retaining follow intent during motion and cannot define the final position. The
|
||||
visible footer supplies the exact remaining distance so rounding or adjacent
|
||||
layout changes cannot leave a residual forward range.
|
||||
|
||||
IME expansion participates in that same viewport owner. A transcript already
|
||||
at the bottom advances by the measured viewport-height loss throughout the
|
||||
keyboard animation, then settles exactly after inset updates stop on both open
|
||||
and close. A transcript being read above the bottom preserves its existing
|
||||
anchor, and a real drag cancels keyboard follow immediately. Host-side coverage
|
||||
verifies renderer ownership, unchanged bubble height, exact footer settling,
|
||||
keyboard arming, viewport loss, completion/IME settlement ownership, and
|
||||
history-reading behavior.
|
||||
|
||||
## 2026-08-05 — Focus voice input boundary repair
|
||||
|
||||
The Focus voice presentation remains modal without installing a consuming
|
||||
pointer handler on the full overlay ancestor. Its click-through guard is now a
|
||||
behind-content sibling: empty-space gestures cannot reach the chat or drawer,
|
||||
while the mic, close, expand/collapse, and panel controls receive their full
|
||||
pointer sequence.
|
||||
|
||||
Host-side Compose coverage injects real touch events instead of invoking
|
||||
semantic click actions. It verifies both child callback delivery and the modal
|
||||
background boundary so the two requirements cannot regress independently.
|
||||
|
||||
## 2026-08-05 — Actionable Android connection diagnostics
|
||||
|
||||
Android diagnostic entries now separate the configured route from the exact
|
||||
request operation and path used to test it. Relay health checks identify the
|
||||
HTTP `/health` probe that precedes a WebSocket connection, route selection
|
||||
records its Dashboard, API, or Relay probe, and WebSocket and API checks name
|
||||
their handshake or authentication stage.
|
||||
|
||||
Known network and HTTP failure classes attach a bounded next step for refused
|
||||
listeners, DNS, routing, timeouts, TLS, credentials, rate limits, missing
|
||||
routes, and server failures. The activity list, status timeline, detail dialog,
|
||||
copy text, and GitHub issue prefill all carry the same context. Public issue
|
||||
text preserves protocol and request paths while redacting hosts, credentials,
|
||||
queries, and user information.
|
||||
|
||||
## 2026-08-04 — Android transcript identity ownership
|
||||
|
||||
ChatHandler now owns one render identity for every published transcript row.
|
||||
Checkpoint recovery and all streamed message mutations resolve both the mutable
|
||||
server/domain ID and the stable UI identity, so history adoption cannot leave a
|
||||
stale client reference that appends a second row. The publication boundary also
|
||||
coalesces repeated render identities before Chat or Voice can observe them,
|
||||
while keeping the first transcript position and latest state.
|
||||
|
||||
Focused coverage composes history reconciliation with checkpoint restore,
|
||||
exercises stale post-adoption callbacks, and runs deterministic transition
|
||||
sequences across restore, replay, deltas, thinking, and usage updates. Voice's
|
||||
temporary transcript row now occupies an auxiliary key namespace disjoint from
|
||||
real message rows.
|
||||
|
||||
## 2026-08-04 — Android reliability and support foundation
|
||||
|
||||
Android fatal capture and centrally classified handled failures now converge on
|
||||
a versioned, allowlisted reliability record. Reports are redacted before local
|
||||
persistence, capped at 20 records with 14-day retention, written atomically,
|
||||
and correlated only with random app/report identifiers. Expected cancellation
|
||||
and permission denial remain non-reportable. The pre-existing one-file crash
|
||||
format migrates locally on first launch.
|
||||
|
||||
Crash recovery leads with the recovery outcome and no-upload guarantee, then
|
||||
requires an explicit review before copy, share, or GitHub actions. Diagnostics
|
||||
adds an offline support-information review using the same exact redacted text.
|
||||
Android issue prefills now request the Android area while repository-wide issue
|
||||
ownership remains maintainer-reviewed, and the release workflow retains both
|
||||
variant R8 mappings for deterministic retrace.
|
||||
|
||||
The architecture audit defers an ANR watchdog, richer allowlisted breadcrumbs,
|
||||
hashed product correlation, and OOM emergency writing until their lifecycle,
|
||||
privacy, and false-positive behavior can be validated on devices.
|
||||
|
||||
## 2026-08-02 — Android Russian localization
|
||||
|
||||
Android now ships complete Russian catalogs for the main and sideload builds.
|
||||
The in-app language picker, Android locale configuration, chat and voice labels,
|
||||
tool and status presentation, diagnostics, onboarding, and plural resources are
|
||||
registered against the canonical English catalog. Existing non-English catalogs
|
||||
were refreshed to retain exact resource and format-argument parity.
|
||||
|
||||
The integration preserves PR #276 as the source contribution while excluding
|
||||
unrelated recovery, routing, and test-stability changes from the localization
|
||||
scope. The localization registry records Android coverage only; Russian public
|
||||
documentation and marketing pages continue to use the canonical English
|
||||
fallback until those surfaces are translated separately.
|
||||
|
||||
## 2026-07-31 — Upstream-compatible voice interruption semantics
|
||||
|
||||
Android full-turn barge-in now follows upstream Hermes' RMS behavior: roughly
|
||||
450 ms of quiet-room calibration, a 90th-percentile floor, 3× default
|
||||
multiplier, separate generation/playback minimums, a bounded ceiling, 500 ms
|
||||
playback grace, and an 80%-majority decision window. Calibration remains frozen
|
||||
against speaker output and cannot itself trigger. Renderer-driven phase tracking
|
||||
returns to generation thresholds in quiet output gaps and rearms playback grace
|
||||
only after a gap of at least one second. Opt-in Logcat diagnostics expose the
|
||||
inputs used for device tuning. Barge-in is enabled by default while retaining its master,
|
||||
Silero sensitivity, RMS multiplier, playback grace, and resume controls.
|
||||
|
||||
Voice stop phrases now use an editable exact-match list that defaults to
|
||||
`stop`; clearing the list disables the behavior. A match ends the active voice
|
||||
chat in generation or playback, but the same word outside voice chat and longer
|
||||
requests continue through normal Hermes input. Continuous-mode pause/resume and
|
||||
explicit background-task cancellation retain their narrower state gates.
|
||||
|
||||
Interrupting spoken playback arms the upstream one-shot interruption note for
|
||||
the next Standard model-bound message. The latch expires after 120 seconds and
|
||||
travels only in API-local voice interface context, never in visible or
|
||||
persisted user text. Generation and pre-audio synthesis interruption do not mark
|
||||
an unspoken reply, Realtime keeps its provider-session context, and silencing remains independent
|
||||
from cancellation of promoted background work.
|
||||
|
||||
## 2026-07-30 — Expandable Android assistant surface
|
||||
|
||||
Android Digital Assistant sessions now open as a compact bottom bar over the
|
||||
current app, expand in place for transcript and response detail, and collapse
|
||||
without changing the active turn. Open full voice disables only the
|
||||
system-owned session UI and reveals the existing app Voice surface, preserving
|
||||
the same session, response stream, and microphone owner.
|
||||
|
||||
Connection, chat, and voice state machines now have one main-process,
|
||||
application-lifetime owner. Assistant activation can initialize and run a cold
|
||||
voice turn without constructing or foregrounding `MainActivity`; opening full
|
||||
Voice binds the Activity to those same ViewModels and audio resources.
|
||||
|
||||
The optional `SYSTEM_ALERT_WINDOW` Voice surface now follows the same
|
||||
wide-bar-to-expanded-sheet progression while retaining its minimized bubble.
|
||||
It remains a separately user-invoked control for turns that began in the app;
|
||||
the Assistant-role session does not require display-over-other-apps permission.
|
||||
|
||||
The assistant window is transparent outside the bar or sheet, leaves the
|
||||
underlying app unresized, and restricts touch interception to the measured
|
||||
surface. Back collapses an expanded surface first; Back from compact, Stop, and
|
||||
ordinary dismissal remain terminal. A hidden full-Voice handoff instead follows
|
||||
the app-owned turn through its final Closed state.
|
||||
Package-scoped lifecycle reconciliation also clears assistant state if Android
|
||||
reclaims the separately processed UI while full Voice remains active.
|
||||
|
||||
Assistant activation is ID-aware and single-flight. Duplicate delivery cannot
|
||||
re-arm capture, Retry replaces a pending readiness attempt, and Stop invalidates
|
||||
the attempt before chat, Voice, or microphone mutations. Scoped voice settings
|
||||
must hydrate from DataStore before route readiness, and process extraction
|
||||
preserves connection-catalog isolation plus the existing gateway route-flip
|
||||
settle window.
|
||||
|
||||
Physical-device validation on a Samsung SM-S938U confirmed cold invocation over
|
||||
a non-Hermes foreground app, compact and expanded presentation without
|
||||
foregrounding `MainActivity`, and one main-process microphone owner. Locked
|
||||
invocation reached a shown system assistant session without runtime or recorder
|
||||
errors; Samsung's secure lock screen prevented screenshot-based visual review.
|
||||
|
||||
## 2026-07-30 — Foreground wake-word diagnostics and recovery
|
||||
|
||||
The Android-local sherpa listener now treats each non-empty keyword result as a
|
||||
completed KWS event, resets the stream immediately, and maps the stored
|
||||
confirmation setting to sherpa's native trailing-blank confirmation instead of
|
||||
requiring an already-completed result to recur across application frames. Voice
|
||||
settings can arm a ten-second test against the same foreground service,
|
||||
microphone owner, installed model, and current tuning; it displays live input
|
||||
level and reports detection without opening voice or transmitting audio.
|
||||
|
||||
Expected empty-transcript responses after activation now record a no-speech
|
||||
diagnostic and return voice to its ready state with a retry hint rather than
|
||||
surfacing the provider's HTTP error. Other transcription failures retain the
|
||||
existing error path. Foreground-service behavior is documented explicitly:
|
||||
background detections remain pending behind the notification until Hermes is
|
||||
visible; Android default-assistant integration is a separate mode. Opening the
|
||||
visible Voice settings screen also reconciles an enabled listener after package
|
||||
replacement or process death without adding boot/background auto-start.
|
||||
|
||||
Focused wake preferences/core and no-speech classification tests pass.
|
||||
Sideload lint, sideload debug packaging, and Google Play debug Kotlin
|
||||
compilation pass. This batch adds no model, native library, ABI, permission, or
|
||||
network dependency; the existing approximately 6 MB downloaded model and
|
||||
packaged sherpa ABI footprint are unchanged.
|
||||
|
||||
## 2026-07-30 — Voice transcript identity alignment
|
||||
|
||||
Android voice Focus mode now keys transcript rows with the same stable UI
|
||||
identity as the main Chat list. A live row may adopt its persisted server
|
||||
message ID during history reconciliation while retaining its original Compose
|
||||
identity; using the mutable domain ID in the voice overlay could otherwise
|
||||
collide during that transition and close the app.
|
||||
|
||||
Focused JVM coverage recreates two visible rows with a shared reconciled server
|
||||
ID and verifies distinct stable transcript keys. Sideload production and
|
||||
Android-test Kotlin compilation pass. The existing full-overlay instrumentation
|
||||
fixture remains blocked by its continuously animating surface never reaching
|
||||
Compose idleness.
|
||||
|
||||
## 2026-07-30 — Opt-in Android Digital Assistant mode
|
||||
|
||||
Android now declares an explicit `VoiceInteractionService` and separately
|
||||
processed `VoiceInteractionSessionService`. Only Android's user-confirmed
|
||||
Assistant role activates the integration. Optional background “Hey Hermes”
|
||||
detection reuses the local sherpa model and tuning, releases its recorder before
|
||||
the system session opens the existing voice flow, and resumes after session
|
||||
exit. Package-scoped lifecycle messages reconcile prompt/listen state,
|
||||
transcript/response presentation, cancellation, errors, and process recreation.
|
||||
|
||||
The Digital Assistant listener and the existing experimental microphone
|
||||
foreground service are separate, mutually exclusive opt-ins. Both retain local
|
||||
pre-activation privacy and the shared one-microphone contract. Standard voice
|
||||
continues through the upstream Dashboard audio surface. Voice settings include
|
||||
role status, setup, removal, runtime status, and the limitation that third-party
|
||||
assistants do not receive Google's low-power hotword hardware.
|
||||
|
||||
## 2026-07-29 — Full-turn voice interruption and local wake-word preview
|
||||
|
||||
Android barge-in now owns one microphone/VAD listener from response generation
|
||||
through playback drain for both Standard and Realtime voice. Quiet-room RMS
|
||||
calibration freezes before output begins, playback receives a grace interval,
|
||||
and model-confirmed majority filtering separates actual interruption from raw
|
||||
ducking hints. Turn epochs, stream cancellation, late-delta suppression, and
|
||||
an awaited microphone handoff keep an interrupted response from speaking again
|
||||
or racing the replacement recording. Exact stop/pause intent is phase-aware,
|
||||
while explicit background-task cancellation remains separate from silencing.
|
||||
|
||||
An opt-in Android-local “Hey Hermes” preview uses sherpa-onnx in a user-started
|
||||
microphone foreground service. Its approximately 6 MB English model is
|
||||
downloaded and hash-verified on first enable rather than bundled. The service
|
||||
keeps pre-activation audio local, exposes an ongoing Stop notification, pauses
|
||||
for active voice, and shares a process-wide single-microphone ownership
|
||||
contract with voice recording, barge-in, and realtime diagnostics. The stored
|
||||
configuration includes strictness, confirmation frames, new-session behavior,
|
||||
and a deliberately inactive future profile-routing shape.
|
||||
|
||||
Focused JVM coverage exercises calibration, grace, listener teardown,
|
||||
Thinking-to-Speaking ownership, generation/playback interruption, command
|
||||
gating, wake preferences, activation, and microphone exclusion. Android
|
||||
compilation for both distribution flavors, sideload lint, and sideload APK
|
||||
packaging pass with all four supported ABIs. On-device acoustic, foreground
|
||||
service, and lifecycle checks remain the corresponding validation gates.
|
||||
## 2026-07-28 — Android 1.5.2 production release
|
||||
|
||||
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
|
||||
@@ -6744,3 +7052,16 @@ After: Phone (HTTP/SSE) → API Server (:8642) [chat — direct]
|
||||
- Deploy docs site (GitHub Pages or similar)
|
||||
- Phase 2: Terminal channel (xterm.js in WebView, tmux integration)
|
||||
- Phase 3: Bridge channel migration
|
||||
|
||||
# 2026-07-30 — Wake-word strictness tuning
|
||||
|
||||
- Lowered the unset Android wake-word strictness default from `0.6` to `0.3` after physical-device testing showed reliable activation only at the lower slider positions.
|
||||
- Added the live numeric strictness value to Voice settings so tuning is observable.
|
||||
- Preserved saved user values and the existing three-frame confirmation default; this adjustment does not migrate working installations or broaden the detector acceptance window beyond the selected threshold.
|
||||
|
||||
# 2026-07-30 — OEM assistant-picker compatibility
|
||||
|
||||
- Added the standard `android.intent.action.ASSIST` activity filter because some OEM assistant pickers enumerate Assist activities even when a valid `VoiceInteractionService` is present.
|
||||
- Routed activity-based Assist invocations through the existing system-assistant activation protocol so both Android entry points share microphone ownership and session lifecycle behavior.
|
||||
- Added the required `recognitionService` metadata and a bounded recognition component after device validation showed Samsung could grant the package role while leaving the active voice-interaction service empty. The component does not open the microphone; Hermes assistant sessions continue to use the established transcription pipeline.
|
||||
- Declared `CATEGORY_VOICE` on the Assist activity and retained `ACTION_ASSIST` on the explicit activation intent. `VoiceInteractionSession.startVoiceActivity()` adds the voice category, and Android rejects the launch as `START_NOT_VOICE_COMPATIBLE` unless the target filter matches both.
|
||||
|
||||
+10
-10
@@ -1,22 +1,22 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
# Hermes-Relay-Server v__VERSION__
|
||||
|
||||
**Release Date:** July 22, 2026
|
||||
**Release Date:** August 6, 2026
|
||||
|
||||
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
|
||||
This patch adds an optional Relay capability overlay so clients can present the reasoning effort levels supported by an exact provider and model without changing upstream model selection or chat behavior.
|
||||
|
||||
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
|
||||
- **Profile-aware reasoning capability resolution.** `POST /relay/model-capabilities` accepts exact provider/model pairs and returns ordered effort choices with explicit exactness and source metadata.
|
||||
- **Bounded live discovery for local and authenticated providers.** Relay can query OpenAI Codex, GitHub Copilot, LM Studio, and Ollama Cloud capability surfaces with short timeouts, concurrency limits, isolated caches, and an explicit refresh option.
|
||||
- **Capability advertisement.** `/relay/info` now advertises `model_reasoning_capabilities_v1` so clients can detect the optional overlay before using it.
|
||||
|
||||
### Fixed
|
||||
### Security
|
||||
|
||||
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
|
||||
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
|
||||
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
|
||||
- **Provider credentials remain host-local.** Remote requests require a paired session with the chat grant, and responses never serialize provider credentials.
|
||||
|
||||
## Install / update
|
||||
|
||||
@@ -24,7 +24,7 @@ It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
|
||||
# Classic install / update on a systemd host:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/server-v__VERSION__/install.sh | bash
|
||||
# or, if already installed:
|
||||
hermes-relay-update
|
||||
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
|
||||
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
|
||||
|
||||
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
|
||||
- **📱 Android app** — streaming chat, hands-free voice, native plugin pages, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. Add a floating Petdex companion or optionally make Hermes your Android assistant; sideload builds can also let the agent read and act on your screen.
|
||||
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, voice, Petdex, and ordinary installed-plugin pages need **no Relay plugin**. Add the optional Relay only when you want terminal, phone control, agent-created page drafts, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
|
||||
@@ -99,7 +99,7 @@ the whole Vanilla Hermes setup.
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, the realtime voice engine, or approval-gated agent-created plugin-page drafts:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
@@ -115,8 +115,11 @@ shell shims, and the full clone/update workflow:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
|
||||
and agent tools. `hermes relay compat status/install/remove` manages only the
|
||||
Installed Hermes plugins can expose bounded, host-rendered pages to Android
|
||||
through the authenticated Dashboard without running plugin code on the phone.
|
||||
Relay 1.5.0 additionally supports approval-gated agent-created page drafts. The
|
||||
plugin-manager install owns the plugin code, dashboard tab, CLI commands, and
|
||||
agent tools. `hermes relay compat status/install/remove` manages only the
|
||||
optional legacy API compatibility hook when an older Hermes build needs it. Scan
|
||||
the QR from the phone's Connections screen — or use
|
||||
`hermes pair --register-code ABCD12` with the manual code from Android
|
||||
@@ -159,7 +162,7 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
</table>
|
||||
|
||||
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
|
||||
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
|
||||
**日本語**, **Português (Brasil)**, **Русский**, and **简体中文**. Choose a language from
|
||||
**Settings → Appearance → Language**; translation status and fluent review are
|
||||
tracked independently so community corrections remain easy to contribute.
|
||||
|
||||
@@ -264,11 +267,14 @@ Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `
|
||||
|
||||
```bash
|
||||
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat build # Build sideload debug APK
|
||||
scripts/dev.bat compile # Compile sideload Kotlin only
|
||||
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Focused unit test
|
||||
scripts/dev.bat install-fast # arm64 phone build + install + launch
|
||||
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 run # Build sideload + install + launch + logcat
|
||||
scripts/dev.bat test # Run sideload debug unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
```
|
||||
@@ -277,13 +283,13 @@ scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Android app** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server / plugin** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 36 |
|
||||
|
||||
<details>
|
||||
<summary><b>Repository structure</b></summary>
|
||||
|
||||
+5
-3
@@ -18,9 +18,9 @@
|
||||
|
||||
## 功能简介
|
||||
|
||||
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、多连接和配置文件。
|
||||
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、原生插件页面、Petdex 悬浮宠物、多连接和配置文件;也可将 Hermes 设为 Android 助手。
|
||||
- **无需插件的标准路径**:聊天、管理和标准语音可直接连接未修改的上游 Hermes Agent。
|
||||
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音和电脑工具。
|
||||
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音、电脑工具,以及需确认的代理创建插件页面草稿。
|
||||
- **安全连接**:二维码配对、Android Keystore、证书固定、按通道授权和可配置会话有效期。
|
||||
- **远程使用**:可配置 Tailscale 或 HTTPS 地址,在家庭局域网和远程路由之间自动切换。
|
||||
- **两种 Android 发行渠道**:Google Play 版本适合日常使用;sideload 版本包含完整手机控制能力。
|
||||
@@ -67,7 +67,7 @@ hermes gateway
|
||||
|
||||
### 4. 可选:安装 Relay
|
||||
|
||||
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音或电脑工具时安装:
|
||||
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音、电脑工具或代理创建插件页面草稿时安装:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
@@ -76,6 +76,8 @@ hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
已安装的 Hermes 插件可通过已认证的 Dashboard 向 Android 提供由应用安全渲染的原生页面,无需在手机上运行插件代码。Relay 1.5.0 另支持需用户确认的代理创建页面草稿。
|
||||
|
||||
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
## 中文界面
|
||||
|
||||
+13
-1
@@ -494,6 +494,14 @@ the new app version and a higher `appVersionCode`.
|
||||
in `app/build.gradle.kts`. Never rename the sideload APK — the
|
||||
in-app update checker matches assets by `.apk` + `sideload` in the
|
||||
name, and user-docs verify steps cite the filename.
|
||||
The release workflow also retains
|
||||
`app/build/outputs/mapping/{googlePlayRelease,sideloadRelease}/mapping.txt`
|
||||
for 90 days in the `android-r8-mappings-<version>-<sha>` workflow
|
||||
artifact. It is intentionally not a GitHub Release asset. To symbolicate an
|
||||
in-app or sideload report, download the artifact for the exact version/SHA and
|
||||
run Android's retrace tool with the matching flavor mapping:
|
||||
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
|
||||
use the mapping bundled into the uploaded AAB through Play Console.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
@@ -509,7 +517,11 @@ the new app version and a higher `appVersionCode`.
|
||||
the version reference and the "Release Notes" section that gets
|
||||
pasted into the Play Console "What's new" field. Keep the Play
|
||||
"What's new" within **500 characters** and framed around the
|
||||
release's themes, not a feature dump.
|
||||
release's themes, not a feature dump. Compare its **Foreground service
|
||||
permissions** section with the merged `googlePlayRelease` manifest and
|
||||
complete Play Console declarations for every declared service type before
|
||||
approval; the Publisher API can upload a draft and still reject promotion
|
||||
when an App content declaration is missing.
|
||||
|
||||
#### Scrub for public distribution
|
||||
|
||||
|
||||
+33
-11
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.5.2
|
||||
# Hermes-Relay-Android v1.7.0
|
||||
|
||||
**Release Date:** July 28, 2026
|
||||
**Release Date:** August 6, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.5.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.7.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,17 +12,39 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch restores reliable dashboard sign-in for self-hosted OIDC and Nous Portal connections, including private-LAN and Tailscale routes, and prevents replayed chat events from destabilizing the conversation list.
|
||||
This patch makes model reasoning controls provider-aware, keeps active and
|
||||
restored conversations stable, and adds private, actionable support information.
|
||||
|
||||
## Added
|
||||
|
||||
- Reasoning effort choices now follow the selected provider and model when an
|
||||
exact list is available. Standard choices remain available as an advisory
|
||||
fallback on unmodified Hermes or without the optional Relay capability overlay.
|
||||
- The session drawer can search sessions and shows which conversations are
|
||||
working or waiting for input.
|
||||
- Diagnostics now provides a locally redacted support report that can be
|
||||
reviewed before copy, share, or opening GitHub. Nothing uploads automatically.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback.
|
||||
- Nous Portal authentication opens in the system browser so provider security challenges can complete.
|
||||
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
|
||||
- Full-screen in-app sign-in remains available for compatible dashboard providers.
|
||||
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
|
||||
- Restored and completed conversations remain at the exact bottom through late
|
||||
message, composer, keyboard, and status-row layout changes without overriding
|
||||
someone who intentionally scrolls up.
|
||||
- Chat recovery keeps one stable rendered row through checkpoint restore,
|
||||
streaming callbacks, server reconciliation, and replay, preventing recurring
|
||||
duplicate-row crashes in Chat and Voice.
|
||||
- Focus Voice controls receive taps again while the modal background continues
|
||||
to block interaction with the chat behind it.
|
||||
- Connection diagnostics identify the attempted route and operation, redact host
|
||||
details, and give targeted guidance for DNS, timeout, TLS, authentication,
|
||||
rate-limit, missing-route, and server failures.
|
||||
- The session drawer dismisses the composer keyboard when opening, preserves the
|
||||
newest row during refresh, and keeps floating pets on measured safe terrain.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.5.2** (versionCode **35**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- App version: **1.7.0** (versionCode **39**).
|
||||
- Standard Chat, model selection, and Vanilla Hermes voice continue to work
|
||||
against unmodified upstream Hermes.
|
||||
- Relay capability discovery is optional and fail-soft; unavailable or older
|
||||
Relay installations retain the standard reasoning choices.
|
||||
|
||||
@@ -6,6 +6,40 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Android Plugin Studio protocol follow-ups
|
||||
|
||||
The first live declarative Plugin lane is host-local: Relay tools create bounded
|
||||
draft JSON, Android previews it through the authenticated Dashboard namespace,
|
||||
and exact-digest Keep/Remove actions require an Android user tap. Complete the
|
||||
multi-session protocol before treating `lifecycle=session` as an isolation claim:
|
||||
|
||||
- Derive draft ownership from trusted Hermes task context and store only an HMAC
|
||||
of that identifier; never accept a model-supplied session owner.
|
||||
- Filter draft discovery by the Android app's active Hermes session while keeping
|
||||
profile and connection publications separate with explicit precedence.
|
||||
- Replace foreground five-second catalog polling with authenticated catalog
|
||||
invalidation events plus ETag polling fallback.
|
||||
- Expire abandoned drafts and pending approvals, and add revision-bound profile
|
||||
versus connection promotion targets.
|
||||
|
||||
---
|
||||
|
||||
## Split fast Android unit tests from resource and screenshot tests
|
||||
|
||||
The quick-loop commands now narrow execution to the sideload debug variant and
|
||||
support one-class filtering, but all `:app` unit tests still share one Android
|
||||
test variant. That variant includes merged Android resources, gives every test
|
||||
worker a 2 GiB heap, runs on JDK 21, and enables Roborazzi recording because a
|
||||
small subset of Robolectric/screenshot tests requires those settings.
|
||||
|
||||
Create a separate resource/screenshot test lane so pure state, parser, routing,
|
||||
and formatting tests can run as ordinary JVM tests without Android resource
|
||||
packaging. Keep golden-image recording explicit rather than applying it to all
|
||||
unit tests, preserve a CI task that runs both lanes, and benchmark cold plus
|
||||
warm focused-test latency before adopting the split.
|
||||
|
||||
---
|
||||
|
||||
## Verify Android native dashboard sign-in on device
|
||||
|
||||
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
|
||||
@@ -1042,12 +1076,14 @@ to tool state, safety prompts, or the current task.
|
||||
playback-synchronized amplitude through `shouldMarkRealtimeOutputActive`,
|
||||
matching the basic-TTS path. Confirm visually on-device with the 1.4.1 batch.
|
||||
|
||||
- **Voice command layer — initial 1.4.1 subset code-complete; live verify and
|
||||
navigation residuals remain.** Exact final transcripts can stop speech,
|
||||
- **Voice command layer — upstream stop phrases and phase-aware pause are
|
||||
code-complete; live verify and navigation residuals remain.** Exact final transcripts can end the active voice chat,
|
||||
explicitly cancel the active background task, pause/resume Continuous mode,
|
||||
repeat a settled background answer, and start a new Standard chat. Bare `stop`
|
||||
and `cancel`, partial transcripts, and command-like ordinary prompts stay on the
|
||||
normal Hermes route. Realtime `new chat` remains gated on a clean websocket
|
||||
repeat a settled background answer, and start a new Standard chat. Bare
|
||||
`stop` is configurable and exact-only while voice chat is active; bare
|
||||
`pause` remains phase-gated to Continuous mode. `cancel`, partial transcripts,
|
||||
and command-like ordinary prompts stay on the normal Hermes route. Realtime
|
||||
`new chat` remains gated on a clean websocket
|
||||
session-rebind boundary; `open overlay` and `return to Hermes` remain future
|
||||
navigation commands. Verify barge-in Stop, pause during a background run, local
|
||||
command Chat cleanup, and Continuous rearm on device.
|
||||
@@ -1082,11 +1118,34 @@ and whether the agent is waiting on the user.
|
||||
experimental barge-in choice. Relay update is server-first; local Voice/barge-in
|
||||
values share one DataStore transaction, with relay rollback on local failure.
|
||||
|
||||
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
|
||||
- **Barge-in hardening — code complete; on-device matrix remains.** Full-turn
|
||||
listener ownership, AEC/noise suppression, upstream-compatible RMS
|
||||
calibration and thresholds, configurable playback grace, duck/cut behavior,
|
||||
late-delta fencing, next-turn interruption context, and single-microphone
|
||||
handoff are implemented. Phone testing still needs to cover speakerphone/headphones, quiet/noisy rooms, Standard/Realtime
|
||||
generation and playback, stop/pause, and resume-after-interruption.
|
||||
|
||||
is solved. The target path is proper AEC, playback-ducking, and a rule that
|
||||
- **Experimental wake word — on-device validation.** Verify first-enable model
|
||||
installation and integrity failure recovery, all supported ABIs, Android
|
||||
notification/microphone permission variants, background-start restrictions,
|
||||
task recreation from the detection notification, acoustic false-positive and
|
||||
false-negative rates, battery impact, stop action, and wake→voice→wake
|
||||
microphone handoff. Voice settings now provide a bounded real-microphone/model
|
||||
test with an input meter; use it to distinguish audio capture from KWS tuning
|
||||
before testing the full activation flow. The first release remains fixed to
|
||||
“Hey Hermes”; do not
|
||||
expose profile-specific phrases until routing and acoustic behavior are
|
||||
implemented and validated.
|
||||
|
||||
output audio can never become a user turn.
|
||||
- **Android Digital Assistant — on-device validation.** On a physical device,
|
||||
select and remove Hermes through the system Assistant role; verify gesture,
|
||||
power-button, screen-off, credential-lock, and unlocked “Hey Hermes”
|
||||
invocation; confirm the system session appears without overlay/full-screen
|
||||
permissions; exercise compact, expanded, collapsed, and full-Voice handoff
|
||||
states, background tap-through, rotation and insets, cancel/back, microphone
|
||||
denial, network failure, process kill/recreation, and wake→voice→wake
|
||||
resumption. Measure idle battery drain because third-party assistants do not
|
||||
receive Google's dedicated low-power hotword hardware.
|
||||
|
||||
- **Audio quality guardrails** — normalize output volume across realtime and
|
||||
|
||||
@@ -1218,11 +1277,8 @@ Follow-ups:
|
||||
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
|
||||
- `**adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
|
||||
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
|
||||
- **Pet state-change re-decode can flash one blank frame.** When the agent state switches clips, the first frame of the new clip may briefly be blank during decode; prewarm/hold-last-frame to smooth it. Root cause is the same as the next item: `PetAvatar.Render` re-decodes from disk on every clip change.
|
||||
- **Pet frame-sequence memory: no cap or downsample (audit 2026-06-19).** `decodeClip` decodes every frame of the selected clip into `List<ImageBitmap>` at full resolution with no `inSampleSize` downscale to the display size and no frame-count/dimension ceiling — a long sequence of large PNGs can use a lot of RAM and a single very large image can OOM `BitmapFactory`. Add `inSampleSize` downsampling to the avatar's draw size and/or a documented hard cap. Spec now warns authors (prefer sprite sheets), but the renderer doesn't enforce it.
|
||||
- **Pet decoded-clip cache (audit 2026-06-19).** `PetAvatar.Render` keys `produceState` on `clip`, so idle→thinking→speaking→idle within one turn re-runs `BitmapFactory.decodeFile` from disk each transition (repeated I/O + GC churn, and the blank-frame flash above). Add a small per-avatar `Map<SphereState, PetFrames>` decode cache.
|
||||
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states & pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
|
||||
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
|
||||
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Watch for the known clip re-decode flash on each swap (separate TODO — decoded-clip cache).
|
||||
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Confirm each decoded clip swap holds the previous complete visual until the new state is ready.
|
||||
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@ plugins {
|
||||
alias(libs.plugins.play.publisher)
|
||||
}
|
||||
|
||||
val supportedHermesDevAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val hermesDevAbi = providers.gradleProperty("hermes.devAbi").orNull
|
||||
hermesDevAbi?.let { requestedAbi ->
|
||||
require(requestedAbi in supportedHermesDevAbis) {
|
||||
"Unsupported hermes.devAbi '$requestedAbi'. Expected one of: " +
|
||||
supportedHermesDevAbis.sorted().joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
// Rename output artifacts to include the app version. AGP respects
|
||||
// `archivesName` for both APK (assemble*) and AAB (bundle*) outputs, so
|
||||
// this single line produces `hermes-relay-<version>-<flavor>-<buildType>`
|
||||
@@ -43,6 +52,17 @@ android {
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Optional local-only fast path for device iteration. Native voice/VAD
|
||||
// dependencies make the universal sideload APK very large, while a
|
||||
// connected phone needs only its own ABI. Release and normal debug
|
||||
// builds remain universal unless the developer explicitly supplies
|
||||
// -Phermes.devAbi=<abi>.
|
||||
hermesDevAbi?.let { requestedAbi ->
|
||||
ndk {
|
||||
abiFilters += requestedAbi
|
||||
}
|
||||
}
|
||||
|
||||
// Feature flags — DEV_MODE enables all experimental features in debug builds
|
||||
buildConfigField("boolean", "DEV_MODE", "false")
|
||||
}
|
||||
@@ -125,6 +145,7 @@ android {
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
@@ -164,6 +185,21 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// sherpa-onnx v1.13.4 and the Silero VAD both use ONNX Runtime.
|
||||
// Keep them on sherpa's 1.27.0 baseline and package one shared core.
|
||||
pickFirsts += "**/libonnxruntime.so"
|
||||
|
||||
// The Android app calls only sherpa's JNI facade. These native C/C++
|
||||
// API facades are development surfaces and are not loaded by the app.
|
||||
excludes += setOf(
|
||||
"**/libsherpa-onnx-c-api.so",
|
||||
"**/libsherpa-onnx-cxx-api.so",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// JVM unit tests run against the stubbed Android SDK jar, where every
|
||||
// platform API method throws RuntimeException("... not mocked") by
|
||||
// default. With returnDefaultValues = true, those stubs instead
|
||||
@@ -262,6 +298,12 @@ dependencies {
|
||||
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
|
||||
implementation(libs.android.vad.silero)
|
||||
|
||||
// Experimental, opt-in local keyword spotting. Models are downloaded only
|
||||
// after the user enables the feature; no model binary is bundled in APKs.
|
||||
// Keep the shared runtime aligned with sherpa-onnx v1.13.4.
|
||||
implementation(libs.onnxruntime.android)
|
||||
implementation(libs.sherpa.onnx)
|
||||
|
||||
// Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
|
||||
// Scoped via the `googlePlayImplementation` configuration so it never
|
||||
// ships in the sideload APK, which updates via the GitHub-releases
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.hermesandroid.relay.plugins.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.hermesandroid.relay.plugins.document.PluginDocumentState
|
||||
import com.hermesandroid.relay.plugins.document.PluginElement
|
||||
import com.hermesandroid.relay.plugins.document.PluginPage
|
||||
import com.hermesandroid.relay.plugins.document.PluginText
|
||||
import com.hermesandroid.relay.plugins.document.PluginValue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PluginDocumentRendererTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun pageRendersBindingsAndEmitsControlledStateChanges() {
|
||||
var interaction: PluginInteraction? = null
|
||||
val page = PluginPage(
|
||||
id = "home",
|
||||
title = PluginText.Binding("title", "Fallback"),
|
||||
content = PluginElement.Group(
|
||||
id = "root",
|
||||
children = listOf(
|
||||
PluginElement.Text(
|
||||
id = "message",
|
||||
text = PluginText.Binding("message"),
|
||||
),
|
||||
PluginElement.Toggle(
|
||||
id = "enabled-toggle",
|
||||
label = PluginText.Literal("Enabled"),
|
||||
binding = "enabled",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val state = PluginDocumentState(
|
||||
mapOf(
|
||||
"title" to PluginValue.StringValue("Status plugin"),
|
||||
"message" to PluginValue.StringValue("Everything is healthy"),
|
||||
"enabled" to PluginValue.BooleanValue(false),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PluginPageRenderer(page, state, { interaction = it })
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Status plugin").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Everything is healthy").assertIsDisplayed()
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
|
||||
assertEquals(
|
||||
PluginInteraction.ValueChanged(
|
||||
elementId = "enabled-toggle",
|
||||
key = "enabled",
|
||||
value = PluginValue.BooleanValue(true),
|
||||
),
|
||||
interaction,
|
||||
)
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertDoesNotExist
|
||||
import androidx.compose.ui.test.assertExists
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarSource
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AmbientVisualizationVisibilityTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun cleanMode_backgroundOff_hidesSphereAndKeepsComposer() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = false) {
|
||||
CleanChatMode(
|
||||
messages = emptyList(),
|
||||
isStreaming = false,
|
||||
sphereState = SphereState.Idle,
|
||||
streamingIntensity = 0f,
|
||||
toolCallBurst = 0f,
|
||||
animationEnabled = true,
|
||||
enabled = true,
|
||||
onSend = {},
|
||||
onExit = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithContentDescription(targetString(R.string.agent_text_send_cd))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cleanMode_backgroundOn_rendersSphere() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = true) {
|
||||
CleanChatMode(
|
||||
messages = emptyList(),
|
||||
isStreaming = false,
|
||||
sphereState = SphereState.Idle,
|
||||
streamingIntensity = 0f,
|
||||
toolCallBurst = 0f,
|
||||
animationEnabled = false,
|
||||
enabled = true,
|
||||
onSend = {},
|
||||
onExit = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceMode_backgroundOff_hidesSphereAndKeepsVoiceUi() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = false) {
|
||||
TestVoiceOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(targetString(R.string.voice_overlay_tap_mic)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceMode_backgroundOn_rendersSphere() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = true) {
|
||||
TestVoiceOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmbientTestProviders(enabled: Boolean, content: @Composable () -> Unit) {
|
||||
MaterialTheme {
|
||||
CompositionLocalProvider(
|
||||
LocalAgentAvatar provides TaggedAmbientRenderer,
|
||||
LocalBackgroundVisualizationEnabled provides enabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestVoiceOverlay() {
|
||||
VoiceModeOverlay(
|
||||
uiState = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
interactionMode = InteractionMode.TapToTalk,
|
||||
),
|
||||
onMicTap = {},
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
private fun targetString(id: Int): String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||
|
||||
private object TaggedAmbientRenderer : AgentAvatar {
|
||||
override val id = "ambient-test"
|
||||
override val label = "Ambient test"
|
||||
override val description = "Test renderer"
|
||||
override val source = AvatarSource.BUILT_IN
|
||||
override val reactivity = SphereReactivity()
|
||||
|
||||
@Composable
|
||||
override fun Render(state: AvatarRenderState, modifier: Modifier) {
|
||||
Box(modifier = modifier.testTag(AMBIENT_RENDERER_TAG))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AMBIENT_RENDERER_TAG = "ambientVisualizationRenderer"
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
Dashboard sign-in now completes reliably for self-hosted OIDC and Nous Portal, including private-LAN and Tailscale routes. Nous opens securely in the system browser, while compatible providers retain full-screen in-app sign-in. Replayed chat updates no longer duplicate conversation rows.
|
||||
Smarter reasoning controls now match the selected provider and model, with standard choices when exact levels are unavailable. The session drawer shows working and needs-input chats. Restored and completed conversations stay bottom-pinned without overriding scrollback. Redacted local support information and clearer connection diagnostics make problems easier to resolve. Focus Voice controls and recovered chat rows are more reliable.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。
|
||||
新增可在界面中漫游的 Petdex 浮动宠物、由已安装 Hermes 插件提供的安全原生页面,以及支持本地“Hey Hermes”的可选 Android 数字助理。本次更新还新增俄语,并改进语音恢复、路线切换、实时聊天稳定性和宠物移动。
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
for the device-control bridge service; the merger dedups.) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
@@ -47,6 +48,13 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Some Android OEM assistant pickers enumerate ACTION_ASSIST
|
||||
activities in addition to VoiceInteractionService providers. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.ASSIST" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.VOICE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- AppCompat persists in-app language choices on Android 12 and lower.
|
||||
@@ -93,8 +101,8 @@
|
||||
<!-- Protects user-started active turns automatically; the optional
|
||||
"Persistent connection" setting extends the same foreground
|
||||
protection to idle/background connectivity (and relay-paired
|
||||
device features). In main so BOTH flavors ship it. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
device features). In main so BOTH flavors ship it. Every Play
|
||||
foreground-service type needs its matching App content declaration. -->
|
||||
<service
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
android:exported="false"
|
||||
@@ -104,6 +112,70 @@
|
||||
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
|
||||
</service>
|
||||
|
||||
<!-- Experimental, explicitly user-started on-device wake-word listener.
|
||||
Audio remains local and the service is never boot/restart started.
|
||||
The Play build's microphone type needs an App content declaration. -->
|
||||
<service
|
||||
android:name=".wake.WakeWordForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<!-- User-started protection for voice capture from the system overlay.
|
||||
The service does not own AudioRecord; it keeps foreground-only
|
||||
microphone app-ops available while Hermes is behind another app.
|
||||
Include this use case in the Play microphone declaration. -->
|
||||
<service
|
||||
android:name=".voice.VoiceOverlayForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<!-- Explicitly opt-in Android Digital Assistant integration. Android
|
||||
binds this only after the user selects Hermes for ROLE_ASSISTANT. -->
|
||||
<service
|
||||
android:name=".assistant.HermesVoiceInteractionService"
|
||||
android:exported="true"
|
||||
android:label="@string/assistant_service_label"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_service" />
|
||||
</service>
|
||||
|
||||
<!-- Heavy assistant UI is isolated from the always-running interaction
|
||||
service, matching the platform lifecycle guidance. -->
|
||||
<service
|
||||
android:name=".assistant.HermesVoiceInteractionSessionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||
android:process=":assistant_session" />
|
||||
|
||||
<!-- Required companion component for VoiceInteractionService metadata.
|
||||
Hermes session transcription remains owned by the existing voice
|
||||
pipeline; this service does not open a second microphone stream. -->
|
||||
<service
|
||||
android:name=".assistant.HermesRecognitionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
<intent-filter>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".assistant.AssistantSessionStateReceiver"
|
||||
android:exported="false"
|
||||
android:process=":assistant_session" />
|
||||
|
||||
<receiver
|
||||
android:name=".assistant.AssistantSessionLifecycleReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,110 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.7.0",
|
||||
"title": "Smarter controls, steadier sessions",
|
||||
"date": "2026-08-06",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Model controls fit the model",
|
||||
"bullets": [
|
||||
"Reasoning effort choices follow the selected provider and model when an exact supported list is available.",
|
||||
"Unmodified Hermes and setups without the optional Relay capability overlay keep a fail-soft standard choice list."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active chats stay easy to follow",
|
||||
"bullets": [
|
||||
"The searchable session drawer shows which conversations are working or waiting for input.",
|
||||
"Restored and completed chats remain bottom-pinned through late layout changes without overriding intentional scrollback.",
|
||||
"Chat and Voice keep stable rows through recovery, and Focus Voice controls receive taps normally."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Support stays private and useful",
|
||||
"bullets": [
|
||||
"Review locally redacted support information before choosing to copy, share, or open GitHub; nothing uploads automatically.",
|
||||
"Connection diagnostics identify the failed operation and offer targeted guidance without exposing hosts or credentials."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.6.1",
|
||||
"title": "Clearer recovery, steadier chat",
|
||||
"date": "2026-08-03",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Relay stays optional",
|
||||
"bullets": [
|
||||
"Relay-only surfaces now use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states without nagging from background session refreshes.",
|
||||
"Foreground recovery retries ordinary reconnect backoff immediately and explains whether Relay credentials are merely stored or actually need re-pairing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Sessions and chat stay stable",
|
||||
"bullets": [
|
||||
"The session drawer restores its 200-row window through upstream-compatible 100-row pages.",
|
||||
"Selecting streamed text stays stable when a completed response changes to rendered Markdown."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice controls stay reachable",
|
||||
"bullets": [
|
||||
"Manual recording waits for the previous microphone owner to release it and gives a useful recovery message if capture cannot start.",
|
||||
"New-chat coaching yields while Voice owns the composer so it cannot cover the expanding Voice drawer."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.6.0",
|
||||
"title": "Pets, plugins, and voice",
|
||||
"date": "2026-08-02",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A companion with personality",
|
||||
"bullets": [
|
||||
"Browse and install Petdex companions, or import your own pet without replacing the agent avatar or background Sphere.",
|
||||
"Drag a pet anywhere or let it roam across measured chat bubbles, settings cards, controls, and other safe UI ledges."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Native plugin pages",
|
||||
"bullets": [
|
||||
"Installed Hermes plugins can contribute host-rendered native pages without loading executable plugin code on the phone.",
|
||||
"Scoped writes stay off until granted, while Relay 1.5.0 adds approval-gated agent-created page previews."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Hermes as your assistant",
|
||||
"bullets": [
|
||||
"Optionally select Hermes as Android’s Digital Assistant and use a local “Hey Hermes” listener for background or locked-screen sessions.",
|
||||
"Compact assistant and floating Voice controls expand for detail and continue the same turn when full Voice opens."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More reliable everywhere",
|
||||
"bullets": [
|
||||
"Voice output recovery, long recordings, route failover, streamed chat identity, and pet terrain recovery are more resilient.",
|
||||
"Android now includes a complete AI-assisted Russian catalog refreshed for the 1.6 feature set."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"title": "Voice stays open",
|
||||
"date": "2026-07-31",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stable voice transcripts",
|
||||
"bullets": [
|
||||
"Voice Focus keeps stable transcript rows while live messages reconcile with persisted chat history, preventing duplicate-key crashes that could close the app."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.2",
|
||||
"title": "Sign in without detours",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
v1.5.2 - Sign in without detours
|
||||
v1.7.0 - Smarter controls, steadier sessions
|
||||
|
||||
* Complete self-hosted OIDC sign-in through the dashboard callback.
|
||||
* Open Nous Portal securely in the system browser.
|
||||
* Sign in over private-LAN and Tailscale dashboard routes.
|
||||
* Keep replayed chat updates from duplicating conversation rows.
|
||||
* Match reasoning levels to the selected provider and model, with a standard fallback.
|
||||
* See working and needs-input activity in the searchable session drawer.
|
||||
* Keep restored and completed chats bottom-pinned without overriding scrollback.
|
||||
* Review redacted local support information and actionable connection guidance.
|
||||
* Restore Focus Voice controls and stabilize recovered chat rows.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
@@ -9,11 +12,24 @@ import coil3.request.crossfade
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.runtime.HermesProcessRuntime
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
/**
|
||||
* Shared chat/voice runtime for the main application process. It is lazy so
|
||||
* the always-available assistant session UI process stays lightweight and
|
||||
* cannot accidentally become a second microphone/session owner.
|
||||
*/
|
||||
val runtime: HermesProcessRuntime by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
check(isMainApplicationProcess()) {
|
||||
"HermesProcessRuntime may only be created in the main application process"
|
||||
}
|
||||
HermesProcessRuntime(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coil's singleton image loader for the whole app. Registering the OkHttp
|
||||
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
|
||||
@@ -51,6 +67,19 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
AppForegroundTracker.initialize()
|
||||
}
|
||||
|
||||
private fun isMainApplicationProcess(): Boolean {
|
||||
val processName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
getProcessName()
|
||||
} else {
|
||||
val pid = android.os.Process.myPid()
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
activityManager.runningAppProcesses
|
||||
?.firstOrNull { process -> process.pid == pid }
|
||||
?.processName
|
||||
}
|
||||
return processName == packageName
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: HermesRelayApp
|
||||
private set
|
||||
|
||||
@@ -5,16 +5,18 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
@@ -24,10 +26,13 @@ import com.hermesandroid.relay.notifications.InteractionRequestNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val connectionViewModel: ConnectionViewModel by viewModels()
|
||||
private val connectionViewModel: ConnectionViewModel
|
||||
get() = (applicationContext as HermesRelayApp).runtime.connectionViewModel
|
||||
|
||||
// === PHASE3-bridge-ui-followup: MediaProjection consent flow ===
|
||||
// ActivityResultLauncher for the system screen-capture consent dialog.
|
||||
@@ -67,6 +72,8 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol
|
||||
.prepareAssistActivation(intent)
|
||||
|
||||
// Hold splash until DataStore is loaded and onboarding status is known
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
@@ -88,6 +95,12 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
configureAssistantWindow(intent)
|
||||
lifecycleScope.launch {
|
||||
com.hermesandroid.relay.assistant.AssistantAppSessionState.active.collect { active ->
|
||||
if (!active) clearAssistantWindow()
|
||||
}
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
|
||||
// === PHASE3-bridge-ui-followup: install MediaProjection requester ===
|
||||
@@ -114,6 +127,14 @@ class MainActivity : AppCompatActivity() {
|
||||
// in RelayApp's NavRouteRequest collector — we just pump the request
|
||||
// into the SharedFlow here.
|
||||
consumeNavRouteIntent(intent)
|
||||
val consumedAssistantActivation =
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(
|
||||
this,
|
||||
intent,
|
||||
)
|
||||
if (!consumedAssistantActivation) {
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.restoreActivation(this)
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
setContent {
|
||||
RelayApp()
|
||||
@@ -122,6 +143,9 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol
|
||||
.prepareAssistActivation(intent)
|
||||
configureAssistantWindow(intent)
|
||||
// === PHASE3-safety-rails-followup: deep-link nav route on re-launch ===
|
||||
// Same as onCreate but for the singleTask / FLAG_ACTIVITY_CLEAR_TOP
|
||||
// path: when the app is already running and the foreground service's
|
||||
@@ -129,6 +153,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// instead of onCreate. RelayApp's collector handles both cases.
|
||||
setIntent(intent)
|
||||
consumeNavRouteIntent(intent)
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(this, intent)
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
}
|
||||
|
||||
@@ -138,6 +163,40 @@ class MainActivity : AppCompatActivity() {
|
||||
NavRouteRequest.tryRequest(route)
|
||||
}
|
||||
|
||||
private fun configureAssistantWindow(intent: Intent?) {
|
||||
if (
|
||||
intent?.getBooleanExtra(
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.EXTRA_ASSISTANT_SESSION,
|
||||
false,
|
||||
) == true ||
|
||||
com.hermesandroid.relay.assistant.AssistantSessionPersistence.isActive(this)
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAssistantWindow() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(false)
|
||||
setTurnScreenOn(false)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.clearFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.hermesandroid.relay.assistant
|
||||
|
||||
import android.app.role.RoleManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import androidx.core.content.edit
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import com.hermesandroid.relay.HermesRelayApp
|
||||
import com.hermesandroid.relay.wake.WakeWordActivation
|
||||
import com.hermesandroid.relay.wake.WakeWordActivationCoordinator
|
||||
import com.hermesandroid.relay.wake.WakeWordActivationSource
|
||||
import com.hermesandroid.relay.wake.WakeWordProfileRouting
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class AssistantRoleStatus {
|
||||
Unavailable,
|
||||
NotSelected,
|
||||
Selected,
|
||||
}
|
||||
|
||||
enum class AssistantSessionPhase {
|
||||
Launching,
|
||||
Listening,
|
||||
Transcribing,
|
||||
Thinking,
|
||||
Speaking,
|
||||
Idle,
|
||||
Error,
|
||||
Closed,
|
||||
}
|
||||
|
||||
data class AssistantSessionSnapshot(
|
||||
val phase: AssistantSessionPhase = AssistantSessionPhase.Launching,
|
||||
val transcript: String? = null,
|
||||
val response: String = "",
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
object AssistantRole {
|
||||
fun status(context: Context): AssistantRoleStatus {
|
||||
val component = ComponentName(context, HermesVoiceInteractionService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val roles = context.getSystemService(RoleManager::class.java)
|
||||
?: return AssistantRoleStatus.Unavailable
|
||||
if (!roles.isRoleAvailable(RoleManager.ROLE_ASSISTANT)) {
|
||||
return AssistantRoleStatus.Unavailable
|
||||
}
|
||||
return if (roles.isRoleHeld(RoleManager.ROLE_ASSISTANT) &&
|
||||
VoiceInteractionService.isActiveService(context, component)
|
||||
) {
|
||||
AssistantRoleStatus.Selected
|
||||
} else {
|
||||
AssistantRoleStatus.NotSelected
|
||||
}
|
||||
}
|
||||
return if (VoiceInteractionService.isActiveService(context, component)) {
|
||||
AssistantRoleStatus.Selected
|
||||
} else {
|
||||
AssistantRoleStatus.NotSelected
|
||||
}
|
||||
}
|
||||
|
||||
fun selectionIntent(context: Context): Intent? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val roles = context.getSystemService(RoleManager::class.java)
|
||||
if (roles?.isRoleAvailable(RoleManager.ROLE_ASSISTANT) == true) {
|
||||
return roles.createRequestRoleIntent(RoleManager.ROLE_ASSISTANT)
|
||||
}
|
||||
}
|
||||
return Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
|
||||
.takeIf { it.resolveActivity(context.packageManager) != null }
|
||||
}
|
||||
|
||||
fun managementIntent(context: Context): Intent? =
|
||||
Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
|
||||
.takeIf { it.resolveActivity(context.packageManager) != null }
|
||||
?: selectionIntent(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-process protocol between the system-owned assistant session process
|
||||
* and the normal app process that owns the established voice pipeline.
|
||||
*/
|
||||
object AssistantSessionProtocol {
|
||||
const val EXTRA_ASSISTANT_SESSION = "com.hermesandroid.relay.assistant.SESSION"
|
||||
const val EXTRA_ACTIVATION_ID = "com.hermesandroid.relay.assistant.ACTIVATION_ID"
|
||||
const val EXTRA_START_NEW_SESSION =
|
||||
"com.hermesandroid.relay.assistant.START_NEW_SESSION"
|
||||
const val EXTRA_HANDOFF_ONLY = "com.hermesandroid.relay.assistant.HANDOFF_ONLY"
|
||||
private const val ACTION_STATUS = "com.hermesandroid.relay.assistant.STATUS"
|
||||
private const val ACTION_FINISH = "com.hermesandroid.relay.assistant.FINISH"
|
||||
private const val ACTION_START = "com.hermesandroid.relay.assistant.START"
|
||||
private const val ACTION_ACTIVATE = "com.hermesandroid.relay.assistant.ACTIVATE"
|
||||
private const val EXTRA_PHASE = "phase"
|
||||
private const val EXTRA_TRANSCRIPT = "transcript"
|
||||
private const val EXTRA_RESPONSE = "response"
|
||||
private const val EXTRA_ERROR = "error"
|
||||
private const val EXTRA_CANCEL_VOICE = "cancel_voice"
|
||||
|
||||
fun prepareAssistActivation(intent: Intent?) {
|
||||
val assistIntent = intent ?: return
|
||||
if (!isAssistAction(assistIntent.action)) return
|
||||
if (assistIntent.getBooleanExtra(EXTRA_HANDOFF_ONLY, false)) return
|
||||
assistIntent.putExtra(EXTRA_ASSISTANT_SESSION, true)
|
||||
}
|
||||
|
||||
internal fun isAssistAction(action: String?): Boolean = action == Intent.ACTION_ASSIST
|
||||
|
||||
fun activationIntent(
|
||||
context: Context,
|
||||
activationId: String = UUID.randomUUID().toString(),
|
||||
startNewSession: Boolean = true,
|
||||
) =
|
||||
Intent(context, com.hermesandroid.relay.MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_ASSIST
|
||||
putExtra(EXTRA_ASSISTANT_SESSION, true)
|
||||
putExtra(EXTRA_ACTIVATION_ID, activationId)
|
||||
putExtra(EXTRA_START_NEW_SESSION, startNewSession)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
|
||||
fun fullVoiceIntent(context: Context) =
|
||||
Intent(context, com.hermesandroid.relay.MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_ASSIST
|
||||
addCategory(Intent.CATEGORY_VOICE)
|
||||
putExtra(EXTRA_HANDOFF_ONLY, true)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
|
||||
fun activate(
|
||||
context: Context,
|
||||
activationId: String = UUID.randomUUID().toString(),
|
||||
startNewSession: Boolean = true,
|
||||
) {
|
||||
context.sendBroadcast(
|
||||
Intent(context, AssistantSessionLifecycleReceiver::class.java).apply {
|
||||
action = ACTION_ACTIVATE
|
||||
putExtra(EXTRA_ACTIVATION_ID, activationId)
|
||||
putExtra(EXTRA_START_NEW_SESSION, startNewSession)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun consumeActivation(context: Context, intent: Intent?): Boolean {
|
||||
if (intent?.getBooleanExtra(EXTRA_HANDOFF_ONLY, false) == true) {
|
||||
intent.removeExtra(EXTRA_HANDOFF_ONLY)
|
||||
com.hermesandroid.relay.util.NavRouteRequest.tryRequest("chat")
|
||||
return true
|
||||
}
|
||||
if (intent?.getBooleanExtra(EXTRA_ASSISTANT_SESSION, false) != true) return false
|
||||
val id = intent.getStringExtra(EXTRA_ACTIVATION_ID) ?: UUID.randomUUID().toString()
|
||||
val startNewSession = intent.getBooleanExtra(EXTRA_START_NEW_SESSION, true)
|
||||
AssistantSessionPersistence.setActivation(context, id, startNewSession)
|
||||
WakeWordActivationCoordinator.request(
|
||||
WakeWordActivation(
|
||||
id = id,
|
||||
startNewSession = startNewSession,
|
||||
profileRouting = WakeWordProfileRouting(),
|
||||
source = WakeWordActivationSource.SystemAssistant,
|
||||
)
|
||||
)
|
||||
AssistantAppSessionState.setActive(true)
|
||||
intent.removeExtra(EXTRA_ASSISTANT_SESSION)
|
||||
intent.removeExtra(EXTRA_ACTIVATION_ID)
|
||||
intent.removeExtra(EXTRA_START_NEW_SESSION)
|
||||
return true
|
||||
}
|
||||
|
||||
fun restoreActivation(context: Context): Boolean {
|
||||
if (AssistantAppSessionState.active.value) return false
|
||||
val activation = AssistantSessionPersistence.restoreActivation(context) ?: return false
|
||||
AssistantAppSessionState.setActive(true)
|
||||
HermesVoiceInteractionService.setVoiceSessionActive(true)
|
||||
val application = context.applicationContext as HermesRelayApp
|
||||
application.runtime.requestVoiceActivation(
|
||||
activationId = activation.id,
|
||||
startNewSession = activation.startNewSession,
|
||||
onFailure = { failure ->
|
||||
publish(
|
||||
application,
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = failure.message ?: "Hermes voice could not start",
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
fun publish(context: Context, snapshot: AssistantSessionSnapshot) {
|
||||
context.sendBroadcast(
|
||||
Intent(context, AssistantSessionStateReceiver::class.java).apply {
|
||||
action = ACTION_STATUS
|
||||
putExtra(EXTRA_PHASE, snapshot.phase.name)
|
||||
putExtra(EXTRA_TRANSCRIPT, snapshot.transcript)
|
||||
putExtra(EXTRA_RESPONSE, snapshot.response)
|
||||
putExtra(EXTRA_ERROR, snapshot.error)
|
||||
}
|
||||
)
|
||||
if (shouldFinishLifecycleOnSnapshot(snapshot)) {
|
||||
// The session UI runs in a separate process. Reconcile the app-owned
|
||||
// lifecycle directly as well so a reclaimed hidden UI process cannot
|
||||
// leave wake listening paused after full Voice closes.
|
||||
finish(context, cancelVoice = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun publish(context: Context, state: VoiceUiState) {
|
||||
publish(context, snapshotFromVoiceState(state))
|
||||
}
|
||||
|
||||
internal fun snapshotFromVoiceState(state: VoiceUiState): AssistantSessionSnapshot {
|
||||
val phase = when {
|
||||
!state.voiceMode -> AssistantSessionPhase.Closed
|
||||
state.state == VoiceState.Listening -> AssistantSessionPhase.Listening
|
||||
state.state == VoiceState.Transcribing -> AssistantSessionPhase.Transcribing
|
||||
state.state == VoiceState.Thinking -> AssistantSessionPhase.Thinking
|
||||
state.state == VoiceState.Speaking -> AssistantSessionPhase.Speaking
|
||||
state.state == VoiceState.Error -> AssistantSessionPhase.Error
|
||||
else -> AssistantSessionPhase.Idle
|
||||
}
|
||||
return AssistantSessionSnapshot(
|
||||
phase = phase,
|
||||
transcript = state.transcribedText?.take(MAX_SESSION_TEXT_CHARS),
|
||||
response = state.responseText.take(MAX_SESSION_TEXT_CHARS),
|
||||
error = state.error?.take(MAX_SESSION_ERROR_CHARS),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldFinishLifecycleOnSnapshot(snapshot: AssistantSessionSnapshot): Boolean =
|
||||
snapshot.phase == AssistantSessionPhase.Closed
|
||||
|
||||
fun finish(context: Context, cancelVoice: Boolean) {
|
||||
context.sendBroadcast(
|
||||
Intent(context, AssistantSessionLifecycleReceiver::class.java).apply {
|
||||
action = ACTION_FINISH
|
||||
putExtra(EXTRA_CANCEL_VOICE, cancelVoice)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun started(context: Context) {
|
||||
context.sendBroadcast(
|
||||
Intent(context, AssistantSessionLifecycleReceiver::class.java).setAction(ACTION_START)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun isFinishAction(action: String?): Boolean = action == ACTION_FINISH
|
||||
internal fun isStartAction(action: String?): Boolean = action == ACTION_START
|
||||
internal fun isActivateAction(action: String?): Boolean = action == ACTION_ACTIVATE
|
||||
internal fun shouldCancelVoice(intent: Intent): Boolean =
|
||||
intent.getBooleanExtra(EXTRA_CANCEL_VOICE, false)
|
||||
|
||||
internal fun readSnapshot(intent: Intent): AssistantSessionSnapshot {
|
||||
val phase = runCatching {
|
||||
AssistantSessionPhase.valueOf(
|
||||
intent.getStringExtra(EXTRA_PHASE) ?: AssistantSessionPhase.Launching.name
|
||||
)
|
||||
}.getOrDefault(AssistantSessionPhase.Error)
|
||||
return AssistantSessionSnapshot(
|
||||
phase = phase,
|
||||
transcript = intent.getStringExtra(EXTRA_TRANSCRIPT),
|
||||
response = intent.getStringExtra(EXTRA_RESPONSE).orEmpty(),
|
||||
error = intent.getStringExtra(EXTRA_ERROR),
|
||||
)
|
||||
}
|
||||
|
||||
private const val MAX_SESSION_TEXT_CHARS = 4_000
|
||||
private const val MAX_SESSION_ERROR_CHARS = 1_000
|
||||
}
|
||||
|
||||
object AssistantSessionState {
|
||||
private val _snapshot = MutableStateFlow(AssistantSessionSnapshot())
|
||||
val snapshot: StateFlow<AssistantSessionSnapshot> = _snapshot.asStateFlow()
|
||||
|
||||
internal fun update(snapshot: AssistantSessionSnapshot) {
|
||||
_snapshot.value = snapshot
|
||||
}
|
||||
|
||||
internal fun reset() {
|
||||
_snapshot.value = AssistantSessionSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantSessionStateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AssistantSessionState.update(AssistantSessionProtocol.readSnapshot(intent))
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (AssistantSessionProtocol.isActivateAction(intent.action)) {
|
||||
val id = intent.getStringExtra(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
|
||||
?: UUID.randomUUID().toString()
|
||||
val startNewSession = intent.getBooleanExtra(
|
||||
AssistantSessionProtocol.EXTRA_START_NEW_SESSION,
|
||||
true,
|
||||
)
|
||||
AssistantSessionPersistence.setActive(context, true)
|
||||
AssistantSessionPersistence.setActivation(context, id, startNewSession)
|
||||
AssistantAppSessionState.setActive(true)
|
||||
HermesVoiceInteractionService.setVoiceSessionActive(true)
|
||||
val application = context.applicationContext as HermesRelayApp
|
||||
// Dispatch into the process-owned scope and return from the receiver
|
||||
// immediately. Cold readiness can take longer than a broadcast's
|
||||
// execution budget.
|
||||
application.runtime.requestVoiceActivation(
|
||||
activationId = id,
|
||||
startNewSession = startNewSession,
|
||||
onFailure = { failure ->
|
||||
AssistantSessionProtocol.publish(
|
||||
application,
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = failure.message ?: "Hermes voice could not start",
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (AssistantSessionProtocol.isStartAction(intent.action)) {
|
||||
AssistantSessionPersistence.setActive(context, true)
|
||||
HermesVoiceInteractionService.setVoiceSessionActive(true)
|
||||
return
|
||||
}
|
||||
if (!AssistantSessionProtocol.isFinishAction(intent.action)) return
|
||||
AssistantSessionPersistence.setActive(context, false)
|
||||
if (AssistantSessionProtocol.shouldCancelVoice(intent)) {
|
||||
val application = context.applicationContext as HermesRelayApp
|
||||
application.runtime.cancelVoice()
|
||||
}
|
||||
AssistantAppSessionState.setActive(false)
|
||||
HermesVoiceInteractionService.setVoiceSessionActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
object AssistantSessionPersistence {
|
||||
private const val STORE = "assistant_session_lifecycle"
|
||||
private const val KEY_ACTIVE_SINCE = "active_since"
|
||||
private const val KEY_ACTIVATION_ID = "activation_id"
|
||||
private const val KEY_START_NEW_SESSION = "start_new_session"
|
||||
private const val STALE_AFTER_MS = 30 * 60 * 1_000L
|
||||
|
||||
fun setActive(context: Context, active: Boolean) {
|
||||
context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit(commit = true) {
|
||||
putLong(KEY_ACTIVE_SINCE, if (active) System.currentTimeMillis() else 0L)
|
||||
if (!active) {
|
||||
remove(KEY_ACTIVATION_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setActivation(context: Context, id: String, startNewSession: Boolean) {
|
||||
context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit(commit = true) {
|
||||
putString(KEY_ACTIVATION_ID, id)
|
||||
putBoolean(KEY_START_NEW_SESSION, startNewSession)
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreActivation(context: Context): WakeWordActivation? {
|
||||
if (!isActive(context)) return null
|
||||
val store = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
|
||||
val id = store.getString(KEY_ACTIVATION_ID, null) ?: return null
|
||||
return WakeWordActivation(
|
||||
id = id,
|
||||
startNewSession = store.getBoolean(KEY_START_NEW_SESSION, true),
|
||||
profileRouting = WakeWordProfileRouting(),
|
||||
source = WakeWordActivationSource.SystemAssistant,
|
||||
)
|
||||
}
|
||||
|
||||
fun isActive(context: Context, nowMs: Long = System.currentTimeMillis()): Boolean {
|
||||
val since = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_ACTIVE_SINCE, 0L)
|
||||
return isFresh(since, nowMs)
|
||||
}
|
||||
|
||||
internal fun isFresh(sinceMs: Long, nowMs: Long): Boolean =
|
||||
sinceMs > 0L && nowMs - sinceMs in 0..STALE_AFTER_MS
|
||||
}
|
||||
|
||||
object AssistantAppSessionState {
|
||||
private val _active = MutableStateFlow(false)
|
||||
val active: StateFlow<Boolean> = _active.asStateFlow()
|
||||
@Volatile private var voiceStarted = false
|
||||
|
||||
internal fun setActive(active: Boolean) {
|
||||
if (active && !_active.value) voiceStarted = false
|
||||
if (!active) voiceStarted = false
|
||||
_active.value = active
|
||||
}
|
||||
|
||||
fun markVoiceStarted() {
|
||||
voiceStarted = true
|
||||
}
|
||||
|
||||
fun hasVoiceStarted(): Boolean = voiceStarted
|
||||
}
|
||||
|
||||
object AssistantVoiceCommandCoordinator {
|
||||
private val _cancelRequest = MutableStateFlow<String?>(null)
|
||||
val cancelRequest: StateFlow<String?> = _cancelRequest.asStateFlow()
|
||||
|
||||
fun requestCancel() {
|
||||
_cancelRequest.value = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
fun consume(id: String): Boolean {
|
||||
if (_cancelRequest.value != id) return false
|
||||
_cancelRequest.value = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hermesandroid.relay.assistant
|
||||
|
||||
import android.content.Intent
|
||||
import android.speech.RecognitionService
|
||||
import android.speech.SpeechRecognizer
|
||||
|
||||
/**
|
||||
* Platform-required recognition component for the Hermes voice interactor.
|
||||
*
|
||||
* Assistant sessions deliberately use the existing Hermes transcription
|
||||
* pipeline so wake detection, session capture, and active voice never compete
|
||||
* for the microphone. Direct SpeechRecognizer clients are therefore rejected
|
||||
* instead of opening a second recorder.
|
||||
*/
|
||||
class HermesRecognitionService : RecognitionService() {
|
||||
override fun onStartListening(
|
||||
recognizerIntent: Intent,
|
||||
listener: Callback,
|
||||
) {
|
||||
listener.error(SpeechRecognizer.ERROR_CLIENT)
|
||||
}
|
||||
|
||||
override fun onStopListening(listener: Callback) = Unit
|
||||
|
||||
override fun onCancel(listener: Callback) = Unit
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package com.hermesandroid.relay.assistant
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.wake.MicrophoneLease
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwner
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
|
||||
import com.hermesandroid.relay.wake.SherpaWakeWordDetector
|
||||
import com.hermesandroid.relay.wake.WakeWordModelInstaller
|
||||
import com.hermesandroid.relay.wake.WakeWordPreferences
|
||||
import com.hermesandroid.relay.wake.WakeWordPreferencesRepository
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class AssistantWakeRuntimeState {
|
||||
Stopped,
|
||||
Starting,
|
||||
Listening,
|
||||
PausedForVoice,
|
||||
AwaitingSession,
|
||||
Error,
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in Android Digital Assistant service. Android keeps the selected service
|
||||
* available in the background; all pre-activation audio is evaluated locally.
|
||||
*/
|
||||
class HermesVoiceInteractionService : VoiceInteractionService() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private val resourceLock = Any()
|
||||
private var preferencesJob: Job? = null
|
||||
private var recognitionJob: Job? = null
|
||||
private var recorder: AudioRecord? = null
|
||||
private var detector: SherpaWakeWordDetector? = null
|
||||
private var microphoneLease: MicrophoneLease? = null
|
||||
@Volatile private var latestPreferences = WakeWordPreferences()
|
||||
@Volatile private var voiceSessionActive = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runningInstance = this
|
||||
}
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
if (runningInstance !== this) return
|
||||
voiceSessionActive = AssistantSessionPersistence.isActive(this)
|
||||
preferencesJob?.cancel()
|
||||
preferencesJob = scope.launch {
|
||||
WakeWordPreferencesRepository(applicationContext).flow.collectLatest { prefs ->
|
||||
latestPreferences = prefs
|
||||
if (prefs.assistantEnabled && !voiceSessionActive) {
|
||||
restartRecognition(prefs)
|
||||
} else {
|
||||
stopRecognition()
|
||||
setRuntimeState(
|
||||
if (voiceSessionActive) {
|
||||
AssistantWakeRuntimeState.PausedForVoice
|
||||
} else {
|
||||
AssistantWakeRuntimeState.Stopped
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||
val activationId = java.util.UUID.randomUUID().toString()
|
||||
showAssistantSession(
|
||||
fromKeyguard = true,
|
||||
activationId = activationId,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShutdown() {
|
||||
stopRecognition()
|
||||
preferencesJob?.cancel()
|
||||
setRuntimeState(AssistantWakeRuntimeState.Stopped)
|
||||
super.onShutdown()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopRecognition()
|
||||
preferencesJob?.cancel()
|
||||
if (runningInstance === this) runningInstance = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private suspend fun restartRecognition(preferences: WakeWordPreferences) {
|
||||
val previous = recognitionJob
|
||||
stopRecognition()
|
||||
previous?.join()
|
||||
if (!voiceSessionActive && preferences.assistantEnabled) {
|
||||
startRecognition(preferences)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startRecognition(preferences: WakeWordPreferences) {
|
||||
if (voiceSessionActive || recognitionJob?.isActive == true) return
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
setRuntimeState(AssistantWakeRuntimeState.Error)
|
||||
return
|
||||
}
|
||||
val files = WakeWordModelInstaller(this).installedFiles()
|
||||
if (files == null) {
|
||||
setRuntimeState(AssistantWakeRuntimeState.Error)
|
||||
return
|
||||
}
|
||||
val lease = MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.WakeWord)
|
||||
if (lease == null) {
|
||||
setRuntimeState(AssistantWakeRuntimeState.PausedForVoice)
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
microphoneLease = lease
|
||||
stopRequested.set(false)
|
||||
setRuntimeState(AssistantWakeRuntimeState.Starting)
|
||||
recognitionJob = scope.launch {
|
||||
var detected = false
|
||||
var unattachedDetector: SherpaWakeWordDetector? = null
|
||||
try {
|
||||
val createdDetector = SherpaWakeWordDetector(
|
||||
files,
|
||||
preferences.sensitivity,
|
||||
preferences.confirmationFrames,
|
||||
)
|
||||
unattachedDetector = createdDetector
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(SAMPLE_RATE / 5 * 2)
|
||||
val createdRecorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
if (createdRecorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
createdRecorder.release()
|
||||
error("Assistant wake microphone failed to initialize")
|
||||
}
|
||||
synchronized(resourceLock) {
|
||||
if (stopRequested.get()) {
|
||||
createdRecorder.release()
|
||||
return@launch
|
||||
}
|
||||
recorder = createdRecorder
|
||||
detector = createdDetector
|
||||
unattachedDetector = null
|
||||
}
|
||||
createdRecorder.startRecording()
|
||||
setRuntimeState(AssistantWakeRuntimeState.Listening)
|
||||
val samples = ShortArray(FRAME_SAMPLES)
|
||||
while (!stopRequested.get()) {
|
||||
val count = createdRecorder.read(samples, 0, samples.size)
|
||||
if (count < 0) error("Assistant wake microphone read failed: $count")
|
||||
if (count > 0 && createdDetector.accept(samples, count)) {
|
||||
detected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (!stopRequested.get()) {
|
||||
Log.w(TAG, "Assistant wake listening failed", t)
|
||||
setRuntimeState(AssistantWakeRuntimeState.Error)
|
||||
}
|
||||
} finally {
|
||||
runCatching { unattachedDetector?.close() }
|
||||
releaseResources()
|
||||
recognitionJob = null
|
||||
}
|
||||
if (detected && !stopRequested.get()) {
|
||||
setRuntimeState(AssistantWakeRuntimeState.AwaitingSession)
|
||||
val keyguard = getSystemService(android.app.KeyguardManager::class.java)
|
||||
mainHandler.post {
|
||||
showAssistantSession(fromKeyguard = keyguard?.isKeyguardLocked == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAssistantSession(fromKeyguard: Boolean, activationId: String? = null) {
|
||||
voiceSessionActive = true
|
||||
stopRecognition()
|
||||
setRuntimeState(AssistantWakeRuntimeState.AwaitingSession)
|
||||
showSession(
|
||||
Bundle().apply {
|
||||
putBoolean(EXTRA_FROM_KEYGUARD, fromKeyguard)
|
||||
activationId?.let { putString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID, it) }
|
||||
putBoolean(
|
||||
AssistantSessionProtocol.EXTRA_START_NEW_SESSION,
|
||||
latestPreferences.startNewSession,
|
||||
)
|
||||
},
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setVoiceSessionActiveInternal(active: Boolean) {
|
||||
voiceSessionActive = active
|
||||
if (active) {
|
||||
stopRecognition()
|
||||
setRuntimeState(AssistantWakeRuntimeState.PausedForVoice)
|
||||
} else if (latestPreferences.assistantEnabled) {
|
||||
scheduleRetry()
|
||||
} else {
|
||||
setRuntimeState(AssistantWakeRuntimeState.Stopped)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleRetry() {
|
||||
if (recognitionJob?.isActive == true || voiceSessionActive) return
|
||||
recognitionJob = scope.launch {
|
||||
delay(RETRY_DELAY_MS)
|
||||
recognitionJob = null
|
||||
if (!voiceSessionActive && latestPreferences.assistantEnabled) {
|
||||
startRecognition(latestPreferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecognition() {
|
||||
stopRequested.set(true)
|
||||
synchronized(resourceLock) {
|
||||
runCatching { recorder?.stop() }
|
||||
runCatching { recorder?.release() }
|
||||
recorder = null
|
||||
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
|
||||
microphoneLease = null
|
||||
}
|
||||
recognitionJob?.cancel()
|
||||
}
|
||||
|
||||
private fun releaseResources() {
|
||||
synchronized(resourceLock) {
|
||||
runCatching { recorder?.stop() }
|
||||
runCatching { recorder?.release() }
|
||||
recorder = null
|
||||
runCatching { detector?.close() }
|
||||
detector = null
|
||||
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
|
||||
microphoneLease = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setRuntimeState(state: AssistantWakeRuntimeState) {
|
||||
_runtimeState.value = state
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HermesAssistant"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val FRAME_SAMPLES = 1_600
|
||||
private const val RETRY_DELAY_MS = 500L
|
||||
const val EXTRA_FROM_KEYGUARD = "from_keyguard"
|
||||
|
||||
private val _runtimeState = kotlinx.coroutines.flow.MutableStateFlow(
|
||||
AssistantWakeRuntimeState.Stopped
|
||||
)
|
||||
val runtimeState = _runtimeState.asStateFlow()
|
||||
|
||||
@Volatile private var runningInstance: HermesVoiceInteractionService? = null
|
||||
|
||||
fun setVoiceSessionActive(active: Boolean) {
|
||||
runningInstance?.setVoiceSessionActiveInternal(active)
|
||||
}
|
||||
}
|
||||
}
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
package com.hermesandroid.relay.assistant
|
||||
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.service.voice.VoiceInteractionSessionService
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HermesVoiceInteractionSessionService : VoiceInteractionSessionService() {
|
||||
override fun onNewSession(args: Bundle?): VoiceInteractionSession =
|
||||
HermesVoiceInteractionSession(this)
|
||||
}
|
||||
|
||||
internal enum class AssistantSessionPresentation {
|
||||
Inactive,
|
||||
Overlay,
|
||||
FullVoice,
|
||||
}
|
||||
|
||||
internal fun shouldCancelVoiceWhenSessionUiEnds(
|
||||
presentation: AssistantSessionPresentation,
|
||||
): Boolean = presentation == AssistantSessionPresentation.Overlay
|
||||
|
||||
private class HermesVoiceInteractionSession(
|
||||
private val service: HermesVoiceInteractionSessionService,
|
||||
) : VoiceInteractionSession(service) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val viewOwner = AssistantSessionViewOwner().also { it.start() }
|
||||
private var presentation = AssistantSessionPresentation.Inactive
|
||||
private val assistantSurfaceBounds = android.graphics.Rect()
|
||||
private var surfaceExpanded by mutableStateOf(false)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
AssistantSessionState.snapshot.collect { snapshot ->
|
||||
if (presentation != AssistantSessionPresentation.Inactive &&
|
||||
snapshot.phase == AssistantSessionPhase.Closed
|
||||
) {
|
||||
finishSession(cancelVoice = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
window.window?.apply {
|
||||
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
|
||||
setDimAmount(0f)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateContentView(): View = ComposeView(service).apply {
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
setViewTreeLifecycleOwner(viewOwner)
|
||||
setViewTreeViewModelStoreOwner(viewOwner)
|
||||
setViewTreeSavedStateRegistryOwner(viewOwner)
|
||||
setContent {
|
||||
HermesRelayTheme {
|
||||
AssistantSessionSurface(
|
||||
expanded = surfaceExpanded,
|
||||
onExpandedChange = { surfaceExpanded = it },
|
||||
onCancel = { finishSession(cancelVoice = true) },
|
||||
onRetry = { launchVoice(startNewSession = true) },
|
||||
onOpenFullVoice = {
|
||||
if (presentation == AssistantSessionPresentation.Overlay) {
|
||||
openFullVoice()
|
||||
}
|
||||
},
|
||||
onSurfaceBoundsChanged = { bounds ->
|
||||
if (assistantSurfaceBounds != bounds) {
|
||||
assistantSurfaceBounds.set(bounds)
|
||||
window.window?.decorView?.requestLayout()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||
super.onShow(args, showFlags)
|
||||
if (args?.getBoolean(HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD, false) == true) {
|
||||
window.window?.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
setUiEnabled(true)
|
||||
val startsNewLifecycle = presentation == AssistantSessionPresentation.Inactive
|
||||
presentation = AssistantSessionPresentation.Overlay
|
||||
if (!startsNewLifecycle) return
|
||||
|
||||
surfaceExpanded = false
|
||||
AssistantSessionState.reset()
|
||||
launchVoice(
|
||||
activationId = args?.getString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
|
||||
?: UUID.randomUUID().toString(),
|
||||
startNewSession = args?.getBoolean(
|
||||
AssistantSessionProtocol.EXTRA_START_NEW_SESSION,
|
||||
true,
|
||||
) ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onComputeInsets(outInsets: Insets) {
|
||||
super.onComputeInsets(outInsets)
|
||||
outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_REGION
|
||||
outInsets.touchableRegion.set(assistantSurfaceBounds)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (presentation == AssistantSessionPresentation.Overlay && surfaceExpanded) {
|
||||
surfaceExpanded = false
|
||||
return
|
||||
}
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
if (shouldCancelVoiceWhenSessionUiEnds(presentation)) {
|
||||
finishSession(cancelVoice = true)
|
||||
}
|
||||
super.onHide()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (shouldCancelVoiceWhenSessionUiEnds(presentation)) {
|
||||
AssistantSessionProtocol.finish(service, cancelVoice = true)
|
||||
}
|
||||
presentation = AssistantSessionPresentation.Inactive
|
||||
viewOwner.stop()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun launchVoice(
|
||||
activationId: String = UUID.randomUUID().toString(),
|
||||
startNewSession: Boolean,
|
||||
) {
|
||||
runCatching {
|
||||
AssistantSessionProtocol.activate(
|
||||
service,
|
||||
activationId = activationId,
|
||||
startNewSession = startNewSession,
|
||||
)
|
||||
}.onFailure {
|
||||
AssistantSessionState.update(
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = it.message ?: "Hermes could not open the voice session.",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFullVoice() {
|
||||
runCatching {
|
||||
startVoiceActivity(AssistantSessionProtocol.fullVoiceIntent(service))
|
||||
presentation = AssistantSessionPresentation.FullVoice
|
||||
setUiEnabled(false)
|
||||
}.onFailure {
|
||||
AssistantSessionState.update(
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = it.message ?: "Hermes could not open full voice.",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishSession(cancelVoice: Boolean) {
|
||||
if (presentation == AssistantSessionPresentation.Inactive) return
|
||||
presentation = AssistantSessionPresentation.Inactive
|
||||
AssistantSessionProtocol.finish(service, cancelVoice)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private class AssistantSessionViewOwner :
|
||||
LifecycleOwner,
|
||||
ViewModelStoreOwner,
|
||||
SavedStateRegistryOwner {
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
private val store = ViewModelStore()
|
||||
private val savedStateController = SavedStateRegistryController.create(this)
|
||||
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
override val viewModelStore: ViewModelStore get() = store
|
||||
override val savedStateRegistry: SavedStateRegistry
|
||||
get() = savedStateController.savedStateRegistry
|
||||
|
||||
fun start() {
|
||||
savedStateController.performRestore(null)
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantSessionSurface(
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenFullVoice: () -> Unit,
|
||||
onSurfaceBoundsChanged: (android.graphics.Rect) -> Unit,
|
||||
) {
|
||||
val snapshot by AssistantSessionState.snapshot.collectAsState()
|
||||
val status = assistantStatus(snapshot.phase)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.navigationBarsPadding(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
onSurfaceBoundsChanged(
|
||||
android.graphics.Rect(
|
||||
bounds.left.roundToInt(),
|
||||
bounds.top.roundToInt(),
|
||||
bounds.right.roundToInt(),
|
||||
bounds.bottom.roundToInt(),
|
||||
)
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(if (expanded) 30.dp else 28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 10.dp,
|
||||
shadowElevation = 12.dp,
|
||||
) {
|
||||
if (expanded) {
|
||||
ExpandedAssistantSurface(
|
||||
snapshot = snapshot,
|
||||
status = status,
|
||||
onCollapse = { onExpandedChange(false) },
|
||||
onCancel = onCancel,
|
||||
onRetry = onRetry,
|
||||
onOpenFullVoice = onOpenFullVoice,
|
||||
)
|
||||
} else {
|
||||
CompactAssistantSurface(
|
||||
snapshot = snapshot,
|
||||
status = status,
|
||||
onExpand = { onExpandedChange(true) },
|
||||
onCancel = onCancel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactAssistantSurface(
|
||||
snapshot: AssistantSessionSnapshot,
|
||||
status: String,
|
||||
onExpand: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AssistantOrb(snapshot.phase)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = compactAssistantText(snapshot),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onExpand,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExpandLess,
|
||||
contentDescription = stringResource(R.string.assistant_session_expand),
|
||||
)
|
||||
}
|
||||
AssistantStopButton(onClick = onCancel, compact = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandedAssistantSurface(
|
||||
snapshot: AssistantSessionSnapshot,
|
||||
status: String,
|
||||
onCollapse: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenFullVoice: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(38.dp)
|
||||
.height(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
|
||||
.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
AssistantOrb(snapshot.phase, size = 38)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onCollapse) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(R.string.assistant_session_collapse),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AssistantWaveform(snapshot.phase)
|
||||
|
||||
snapshot.transcript?.takeIf { it.isNotBlank() }?.let { transcript ->
|
||||
AssistantTextRow(
|
||||
icon = Icons.Filled.Person,
|
||||
text = transcript,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
snapshot.response.takeIf { it.isNotBlank() }?.let { response ->
|
||||
AssistantTextRow(
|
||||
icon = Icons.Filled.AutoAwesome,
|
||||
text = response,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
snapshot.error?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
if (snapshot.phase == AssistantSessionPhase.Transcribing ||
|
||||
snapshot.phase == AssistantSessionPhase.Thinking
|
||||
) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AssistantStopButton(onClick = onCancel, compact = false)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (snapshot.phase == AssistantSessionPhase.Error) {
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(stringResource(R.string.assistant_session_retry))
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onOpenFullVoice) {
|
||||
Text(stringResource(R.string.assistant_session_open_full_voice))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantOrb(
|
||||
phase: AssistantSessionPhase,
|
||||
size: Int = 52,
|
||||
) {
|
||||
val active = phase == AssistantSessionPhase.Listening ||
|
||||
phase == AssistantSessionPhase.Transcribing ||
|
||||
phase == AssistantSessionPhase.Thinking ||
|
||||
phase == AssistantSessionPhase.Speaking
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (active) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = if (active) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size((size * 0.5f).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantWaveform(phase: AssistantSessionPhase) {
|
||||
val active = phase == AssistantSessionPhase.Listening ||
|
||||
phase == AssistantSessionPhase.Speaking
|
||||
val primary = if (active) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||
}
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(28.dp),
|
||||
) {
|
||||
val centerY = size.height / 2f
|
||||
val bars = 33
|
||||
val spacing = size.width / bars
|
||||
repeat(bars) { index ->
|
||||
val distance = kotlin.math.abs(index - bars / 2f) / (bars / 2f)
|
||||
val envelope = max(0.18f, 1f - distance)
|
||||
val pattern = 0.45f + ((index * 17) % 11) / 20f
|
||||
val halfHeight = size.height * 0.46f * envelope * pattern
|
||||
val x = spacing * (index + 0.5f)
|
||||
drawLine(
|
||||
color = primary,
|
||||
start = Offset(x, centerY - halfHeight),
|
||||
end = Offset(x, centerY + halfHeight),
|
||||
strokeWidth = max(2f, spacing * 0.28f),
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantTextRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
text: String,
|
||||
color: Color,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = color,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantStopButton(
|
||||
onClick: () -> Unit,
|
||||
compact: Boolean,
|
||||
) {
|
||||
if (compact) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = stringResource(R.string.assistant_session_cancel),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.assistant_session_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun assistantStatus(phase: AssistantSessionPhase): String = when (phase) {
|
||||
AssistantSessionPhase.Launching -> stringResource(R.string.assistant_session_launching)
|
||||
AssistantSessionPhase.Listening -> stringResource(R.string.assistant_session_listening)
|
||||
AssistantSessionPhase.Transcribing -> stringResource(R.string.assistant_session_transcribing)
|
||||
AssistantSessionPhase.Thinking -> stringResource(R.string.assistant_session_thinking)
|
||||
AssistantSessionPhase.Speaking -> stringResource(R.string.assistant_session_speaking)
|
||||
AssistantSessionPhase.Idle -> stringResource(R.string.assistant_session_ready)
|
||||
AssistantSessionPhase.Error -> stringResource(R.string.assistant_session_error)
|
||||
AssistantSessionPhase.Closed -> stringResource(R.string.assistant_session_closing)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun compactAssistantText(snapshot: AssistantSessionSnapshot): String =
|
||||
snapshot.transcript?.takeIf { it.isNotBlank() }
|
||||
?: snapshot.response.takeIf { it.isNotBlank() }
|
||||
?: snapshot.error?.takeIf { it.isNotBlank() }
|
||||
?: assistantStatus(snapshot.phase)
|
||||
@@ -8,11 +8,16 @@ import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.wake.MicrophoneLease
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwner
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -22,23 +27,26 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Duplex audio capture for voice barge-in (plan unit B3).
|
||||
*
|
||||
* While TTS is playing, this listener continuously pulls 32 ms / 512-sample
|
||||
* PCM frames off the microphone and feeds them to [VadEngine]. It emits two
|
||||
* SharedFlows that B4 will wire into the voice state machine:
|
||||
* During response generation and playback, this listener continuously pulls
|
||||
* 32 ms / 512-sample PCM frames off the microphone and feeds them to
|
||||
* [VadEngine]. One instance owns the full active turn. It emits two
|
||||
* SharedFlows wired into the voice state machine:
|
||||
*
|
||||
* - [maybeSpeech] fires on the **first** positive raw-VAD frame — before the
|
||||
* second-layer debouncer latches. B4 uses this to softly [VoicePlayer.duck]
|
||||
* the TTS so the user's voice has acoustic headroom while we decide whether
|
||||
* to cut off.
|
||||
*
|
||||
* - [bargeInDetected] fires when [VadEngine] confirms speech post-hysteresis.
|
||||
* B4 uses this to call `interruptSpeaking()` and flip state to Listening.
|
||||
* - [bargeInDetected] fires when [VadEngine] confirms speech post-hysteresis
|
||||
* and the calibrated RMS majority gate accepts it. The owner uses this to
|
||||
* interrupt generation/playback and flip state to Listening.
|
||||
*
|
||||
* ### Acoustic echo cancellation
|
||||
*
|
||||
@@ -140,8 +148,43 @@ class BargeInListener internal constructor(
|
||||
private val frameBuffer: ShortArray = ShortArray(VadEngine.FRAME_SIZE_SAMPLES)
|
||||
|
||||
@Volatile private var readerJob: Job? = null
|
||||
@Volatile private var microphoneLease: MicrophoneLease? = null
|
||||
@Volatile private var aec: AcousticEchoCanceler? = null
|
||||
@Volatile private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private val rmsGate = RmsBargeInGate()
|
||||
@Volatile private var playbackGraceMs: Long = RmsBargeInGate.DEFAULT_PLAYBACK_GRACE_MS
|
||||
@Volatile private var playbackActiveProvider: (() -> Boolean)? = null
|
||||
@Volatile private var diagnosticsEnabled: Boolean = false
|
||||
private var wasCalibrating: Boolean = false
|
||||
|
||||
/** Apply the user-facing barge-in sensitivity to the quiet-room RMS gate. */
|
||||
fun setThresholdMultiplier(multiplier: Float) {
|
||||
rmsGate.thresholdMultiplier = multiplier
|
||||
}
|
||||
|
||||
fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
diagnosticsEnabled = enabled
|
||||
}
|
||||
|
||||
/** Supplies the renderer's current playback phase for upstream-style gaps. */
|
||||
fun setPlaybackActiveProvider(provider: () -> Boolean) {
|
||||
playbackActiveProvider = provider
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze quiet-room calibration and begin the playback-only grace window.
|
||||
* Idempotent so every renderer may call it at its first audible chunk.
|
||||
*/
|
||||
fun markPlaybackStarted(
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
graceMs: Long = RmsBargeInGate.DEFAULT_PLAYBACK_GRACE_MS,
|
||||
) {
|
||||
playbackGraceMs = graceMs.coerceAtLeast(0L)
|
||||
rmsGate.markPlaybackStarted(nowMs)
|
||||
if (diagnosticsEnabled) {
|
||||
Log.d(TAG, "voice-vad playback started; grace=${playbackGraceMs}ms")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the audio pipeline and begin reading frames into [vadEngine].
|
||||
@@ -163,6 +206,12 @@ class BargeInListener internal constructor(
|
||||
return
|
||||
}
|
||||
|
||||
val lease = MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.BargeIn)
|
||||
if (lease == null) {
|
||||
Log.i(TAG, "Barge-in listener inactive — microphone is owned by another voice surface")
|
||||
return
|
||||
}
|
||||
microphoneLease = lease
|
||||
if (!audioSource.initialize()) {
|
||||
Log.w(
|
||||
TAG,
|
||||
@@ -170,11 +219,16 @@ class BargeInListener internal constructor(
|
||||
"(missing RECORD_AUDIO permission or mic busy) — listener inactive",
|
||||
)
|
||||
_aecAttached.value = false
|
||||
MicrophoneOwnershipCoordinator.release(lease)
|
||||
microphoneLease = null
|
||||
return
|
||||
}
|
||||
|
||||
_aecAttached.value = false
|
||||
rmsGate.reset()
|
||||
wasCalibrating = true
|
||||
readerJob = scope.launch(readerDispatcher) {
|
||||
var effectsJob: Job? = null
|
||||
try {
|
||||
try {
|
||||
audioSource.start()
|
||||
@@ -185,7 +239,10 @@ class BargeInListener internal constructor(
|
||||
return@launch
|
||||
}
|
||||
Log.i(TAG, "Barge-in AudioRecord reader started")
|
||||
maybeAttachEffects()
|
||||
// Do not block generation-phase listening while waiting for an
|
||||
// AudioTrack session that does not exist until playback. The
|
||||
// effects attach races harmlessly beside the reader.
|
||||
effectsJob = launch { maybeAttachEffects() }
|
||||
|
||||
while (isActive) {
|
||||
val read = try {
|
||||
@@ -222,10 +279,41 @@ class BargeInListener internal constructor(
|
||||
Log.w(TAG, "VadEngine.analyze failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (result.probability > 0f) {
|
||||
val gated = rmsGate.observe(
|
||||
frame = frameBuffer,
|
||||
rawSpeech = result.probability > 0f,
|
||||
nowMs = System.currentTimeMillis(),
|
||||
playbackGraceMs = playbackGraceMs,
|
||||
confirmedSpeech = result.isSpeech,
|
||||
playbackActiveOverride = playbackActiveProvider?.invoke(),
|
||||
)
|
||||
if (diagnosticsEnabled) {
|
||||
if (wasCalibrating && !gated.calibrating) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"voice-vad calibrated quiet floor=${gated.floor.toInt()} " +
|
||||
"mult=${rmsGate.thresholdMultiplier}",
|
||||
)
|
||||
}
|
||||
wasCalibrating = gated.calibrating
|
||||
if (
|
||||
gated.detected || gated.playbackGrace ||
|
||||
gated.rms >= gated.threshold * 0.5f
|
||||
) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"voice-vad rms=${gated.rms.toInt()} floor=${gated.floor.toInt()} " +
|
||||
"trigger=${gated.threshold.toInt()} raw=${result.probability > 0f} " +
|
||||
"confirmed=${result.isSpeech} detected=${gated.detected} " +
|
||||
"grace=${gated.playbackGrace} " +
|
||||
"phase=${if (gated.playback) "playback" else "generation"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (gated.maybeSpeech) {
|
||||
_maybeSpeech.tryEmit(Unit)
|
||||
}
|
||||
if (result.isSpeech) {
|
||||
if (gated.detected) {
|
||||
_bargeInDetected.tryEmit(Unit)
|
||||
}
|
||||
// Give the dispatcher a chance to observe cancellation
|
||||
@@ -237,11 +325,19 @@ class BargeInListener internal constructor(
|
||||
yield()
|
||||
}
|
||||
} finally {
|
||||
// The reader reaches this block with its Job cancelled.
|
||||
// Teardown still has to wait for the sibling AEC poll before
|
||||
// releasing the AudioRecord and microphone lease.
|
||||
withContext(NonCancellable) {
|
||||
effectsJob?.cancelAndJoin()
|
||||
}
|
||||
// Release effects + AudioRecord in the reverse of attach order
|
||||
// so the AudioSessionId is still valid when AEC teardown runs.
|
||||
releaseEffects()
|
||||
runCatching { audioSource.stop() }
|
||||
runCatching { audioSource.release() }
|
||||
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
|
||||
microphoneLease = null
|
||||
_aecAttached.value = false
|
||||
}
|
||||
}
|
||||
@@ -258,8 +354,16 @@ class BargeInListener internal constructor(
|
||||
if (job?.isActive == true) {
|
||||
Log.i(TAG, "Stopping barge-in AudioRecord reader")
|
||||
}
|
||||
// AudioRecord.read() may be blocked in native code, so stop the source
|
||||
// before cancellation to make the reader observe shutdown promptly.
|
||||
runCatching { audioSource.stop() }
|
||||
job?.cancel()
|
||||
readerJob = null
|
||||
if (job == null) {
|
||||
runCatching { audioSource.release() }
|
||||
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
|
||||
microphoneLease = null
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwner
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -40,24 +42,37 @@ class RealtimePcmRecorder(
|
||||
maxDurationMs: Long = 15_000,
|
||||
onLevel: ((Float) -> Unit)? = null,
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val microphoneLease =
|
||||
MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.RealtimeDiagnostics)
|
||||
?: error("Microphone is in use by another voice feature")
|
||||
val minBuffer = try {
|
||||
AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
} catch (t: Throwable) {
|
||||
MicrophoneOwnershipCoordinator.release(microphoneLease)
|
||||
throw t
|
||||
}
|
||||
val maxBytes = ((sampleRate * maxDurationMs) / 1000L * 2L).toInt()
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
val recorder = try {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
} catch (t: Throwable) {
|
||||
MicrophoneOwnershipCoordinator.release(microphoneLease)
|
||||
throw t
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream(minBuffer * 4)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
@@ -77,32 +92,46 @@ class RealtimePcmRecorder(
|
||||
capturing = false
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
MicrophoneOwnershipCoordinator.release(microphoneLease)
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun capture(durationMs: Long = 800): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val microphoneLease =
|
||||
MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.RealtimeDiagnostics)
|
||||
?: error("Microphone is in use by another voice feature")
|
||||
val minBuffer = try {
|
||||
AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
} catch (t: Throwable) {
|
||||
MicrophoneOwnershipCoordinator.release(microphoneLease)
|
||||
throw t
|
||||
}
|
||||
val targetBytes = ((sampleRate * durationMs) / 1000L * 2L)
|
||||
.toInt()
|
||||
.coerceAtLeast(minBuffer)
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
val recorder = try {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
} catch (t: Throwable) {
|
||||
MicrophoneOwnershipCoordinator.release(microphoneLease)
|
||||
throw t
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream(targetBytes)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
@@ -123,6 +152,7 @@ class RealtimePcmRecorder(
|
||||
} finally {
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
MicrophoneOwnershipCoordinator.release(microphoneLease)
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Turn-scoped RMS gate layered in front of the model VAD.
|
||||
*
|
||||
* The first quiet frames establish a room floor before playback. That floor is
|
||||
* frozen as soon as playback begins so speaker output can never teach the gate
|
||||
* to ignore the user. Detection uses a majority window rather than requiring
|
||||
* perfectly consecutive frames, which tolerates short consonant/syllable dips.
|
||||
*/
|
||||
internal class RmsBargeInGate(
|
||||
private val calibrationFrames: Int = DEFAULT_CALIBRATION_FRAMES,
|
||||
private val decisionWindowFrames: Int = DEFAULT_DECISION_WINDOW_FRAMES,
|
||||
private val requiredWindowRatio: Float = DEFAULT_REQUIRED_WINDOW_RATIO,
|
||||
) {
|
||||
private val ambient = ArrayDeque<Float>(MAX_AMBIENT_FRAMES)
|
||||
private val decisions = ArrayDeque<Boolean>(decisionWindowFrames)
|
||||
|
||||
private var quietFloor: Float = DEFAULT_QUIET_FLOOR_RMS
|
||||
private var calibrated = false
|
||||
private var playbackActive = false
|
||||
private var playbackStartedAtMs: Long? = null
|
||||
private var playbackStoppedAtMs: Long? = null
|
||||
|
||||
var thresholdMultiplier: Float = DEFAULT_THRESHOLD_MULTIPLIER
|
||||
set(value) {
|
||||
field = value.coerceIn(MIN_THRESHOLD_MULTIPLIER, MAX_THRESHOLD_MULTIPLIER)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
ambient.clear()
|
||||
decisions.clear()
|
||||
quietFloor = DEFAULT_QUIET_FLOOR_RMS
|
||||
calibrated = false
|
||||
playbackActive = false
|
||||
playbackStartedAtMs = null
|
||||
playbackStoppedAtMs = null
|
||||
}
|
||||
|
||||
fun markPlaybackStarted(nowMs: Long) {
|
||||
updatePlaybackPhase(active = true, nowMs = nowMs)
|
||||
}
|
||||
|
||||
fun observe(
|
||||
frame: ShortArray,
|
||||
rawSpeech: Boolean,
|
||||
nowMs: Long,
|
||||
playbackGraceMs: Long,
|
||||
confirmedSpeech: Boolean = rawSpeech,
|
||||
playbackActiveOverride: Boolean? = null,
|
||||
): RmsGateResult {
|
||||
val rms = rms(frame)
|
||||
playbackActiveOverride?.let { reportedActive ->
|
||||
// A renderer marks playback just before its first write so speaker
|
||||
// output cannot enter calibration. Do not let a provider that has
|
||||
// not observed the first audible frame yet undo that protection
|
||||
// during the configured grace window.
|
||||
val withinStartupGrace = playbackActive && playbackStartedAtMs?.let {
|
||||
nowMs - it < playbackGraceMs
|
||||
} == true
|
||||
if (reportedActive || !withinStartupGrace) {
|
||||
updatePlaybackPhase(active = reportedActive, nowMs = nowMs)
|
||||
}
|
||||
}
|
||||
val playback = playbackActive
|
||||
var justCalibrated = false
|
||||
|
||||
if (!playback && !calibrated) {
|
||||
addAmbient(rms)
|
||||
if (ambient.size >= calibrationFrames) {
|
||||
freezeCalibration()
|
||||
justCalibrated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!playback && (!calibrated || justCalibrated)) {
|
||||
return RmsGateResult(
|
||||
maybeSpeech = false,
|
||||
detected = false,
|
||||
rms = rms,
|
||||
floor = quietFloor,
|
||||
threshold = (quietFloor * thresholdMultiplier).coerceIn(
|
||||
MIN_GENERATION_THRESHOLD_RMS,
|
||||
MAX_THRESHOLD_RMS,
|
||||
),
|
||||
calibrating = !calibrated,
|
||||
playbackGrace = false,
|
||||
playback = false,
|
||||
)
|
||||
}
|
||||
|
||||
var threshold = if (playback) {
|
||||
(quietFloor * thresholdMultiplier).coerceIn(
|
||||
MIN_PLAYBACK_THRESHOLD_RMS,
|
||||
MAX_THRESHOLD_RMS,
|
||||
)
|
||||
} else {
|
||||
(quietFloor * thresholdMultiplier).coerceIn(
|
||||
MIN_GENERATION_THRESHOLD_RMS,
|
||||
MAX_THRESHOLD_RMS,
|
||||
)
|
||||
}
|
||||
|
||||
// Match upstream ambient drift: after initial calibration, keep the
|
||||
// 90th-percentile floor current only while the room is quiet and no
|
||||
// playback can contaminate it.
|
||||
if (!playback && calibrated && !justCalibrated && rms < threshold) {
|
||||
addAmbient(rms)
|
||||
quietFloor = robustFloor(ambient)
|
||||
threshold = (quietFloor * thresholdMultiplier).coerceIn(
|
||||
MIN_GENERATION_THRESHOLD_RMS,
|
||||
MAX_THRESHOLD_RMS,
|
||||
)
|
||||
}
|
||||
val inPlaybackGrace = playbackStartedAtMs?.let { nowMs - it < playbackGraceMs } == true
|
||||
val aboveRaw = rawSpeech && rms >= threshold && !inPlaybackGrace
|
||||
val aboveConfirmed = confirmedSpeech && rms >= threshold && !inPlaybackGrace
|
||||
|
||||
decisions.addLast(aboveConfirmed)
|
||||
while (decisions.size > decisionWindowFrames) decisions.removeAt(0)
|
||||
val required = (decisionWindowFrames * requiredWindowRatio).roundToInt().coerceAtLeast(1)
|
||||
val detected = aboveConfirmed && decisions.count { it } >= required
|
||||
|
||||
return RmsGateResult(
|
||||
maybeSpeech = aboveRaw,
|
||||
detected = detected,
|
||||
rms = rms,
|
||||
floor = quietFloor,
|
||||
threshold = threshold,
|
||||
calibrating = !playback && !calibrated,
|
||||
playbackGrace = inPlaybackGrace,
|
||||
playback = playback,
|
||||
)
|
||||
}
|
||||
|
||||
private fun freezeCalibration() {
|
||||
if (!calibrated) {
|
||||
quietFloor = robustFloor(ambient)
|
||||
calibrated = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlaybackPhase(active: Boolean, nowMs: Long) {
|
||||
if (active == playbackActive) return
|
||||
if (active) {
|
||||
freezeCalibration()
|
||||
val gapMs = playbackStoppedAtMs?.let { nowMs - it }
|
||||
playbackStartedAtMs = if (gapMs == null || gapMs >= PLAYBACK_GRACE_REARM_GAP_MS) {
|
||||
nowMs
|
||||
} else {
|
||||
null
|
||||
}
|
||||
playbackActive = true
|
||||
decisions.clear()
|
||||
} else {
|
||||
playbackActive = false
|
||||
playbackStartedAtMs = null
|
||||
playbackStoppedAtMs = nowMs
|
||||
decisions.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addAmbient(rms: Float) {
|
||||
ambient.addLast(rms)
|
||||
while (ambient.size > MAX_AMBIENT_FRAMES) ambient.removeAt(0)
|
||||
}
|
||||
|
||||
private fun robustFloor(values: Collection<Float>): Float {
|
||||
if (values.isEmpty()) return DEFAULT_QUIET_FLOOR_RMS
|
||||
val sorted = values.sorted()
|
||||
val percentileIndex = (ceil(sorted.size * 0.9).toInt() - 1).coerceIn(sorted.indices)
|
||||
return sorted[percentileIndex].coerceAtLeast(MIN_QUIET_FLOOR_RMS)
|
||||
}
|
||||
|
||||
private fun rms(frame: ShortArray): Float {
|
||||
if (frame.isEmpty()) return 0f
|
||||
var sum = 0.0
|
||||
frame.forEach { sample ->
|
||||
val value = sample.toDouble()
|
||||
sum += value * value
|
||||
}
|
||||
return sqrt(sum / frame.size).toFloat()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_THRESHOLD_MULTIPLIER = 3f
|
||||
const val DEFAULT_PLAYBACK_GRACE_MS = 500L
|
||||
internal const val DEFAULT_CALIBRATION_FRAMES = 14
|
||||
internal const val DEFAULT_DECISION_WINDOW_FRAMES = 10
|
||||
internal const val DEFAULT_REQUIRED_WINDOW_RATIO = 0.8f
|
||||
internal const val MIN_PLAYBACK_THRESHOLD_RMS = 1_500f
|
||||
internal const val MAX_THRESHOLD_RMS = 4_000f
|
||||
internal const val MIN_GENERATION_THRESHOLD_RMS = 400f
|
||||
internal const val DEFAULT_QUIET_FLOOR_RMS = 200f
|
||||
internal const val MIN_QUIET_FLOOR_RMS = 200f
|
||||
internal const val MAX_AMBIENT_FRAMES = 100
|
||||
internal const val PLAYBACK_GRACE_REARM_GAP_MS = 1_000L
|
||||
internal const val MIN_THRESHOLD_MULTIPLIER = 1f
|
||||
internal const val MAX_THRESHOLD_MULTIPLIER = 8f
|
||||
}
|
||||
}
|
||||
|
||||
internal data class RmsGateResult(
|
||||
val maybeSpeech: Boolean,
|
||||
val detected: Boolean,
|
||||
val rms: Float,
|
||||
val floor: Float,
|
||||
val threshold: Float,
|
||||
val calibrating: Boolean,
|
||||
val playbackGrace: Boolean,
|
||||
val playback: Boolean,
|
||||
)
|
||||
@@ -8,6 +8,9 @@ import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.wake.MicrophoneLease
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwner
|
||||
import com.hermesandroid.relay.wake.MicrophoneOwnershipCoordinator
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -57,6 +60,7 @@ class VoiceRecorder(
|
||||
private val bufferLock = Any()
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var microphoneLease: MicrophoneLease? = null
|
||||
private var echoCanceler: AcousticEchoCanceler? = null
|
||||
private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private var currentOutputFile: File? = null
|
||||
@@ -79,12 +83,21 @@ class VoiceRecorder(
|
||||
releaseRecorder()
|
||||
}
|
||||
}
|
||||
val lease = MicrophoneOwnershipCoordinator.tryAcquire(MicrophoneOwner.VoiceCapture)
|
||||
?: throw IllegalStateException("Microphone is in use by another voice feature")
|
||||
microphoneLease = lease
|
||||
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
val minBuffer = try {
|
||||
AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
|
||||
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
|
||||
} catch (t: Throwable) {
|
||||
MicrophoneOwnershipCoordinator.release(lease)
|
||||
microphoneLease = null
|
||||
throw t
|
||||
}
|
||||
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.wav")
|
||||
currentOutputFile = outFile
|
||||
@@ -95,21 +108,29 @@ class VoiceRecorder(
|
||||
stopRequested.set(false)
|
||||
_amplitude.value = 0f
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
val recorder = try {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
} catch (t: Throwable) {
|
||||
MicrophoneOwnershipCoordinator.release(lease)
|
||||
microphoneLease = null
|
||||
throw t
|
||||
}
|
||||
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
recorder.release()
|
||||
currentOutputFile = null
|
||||
MicrophoneOwnershipCoordinator.release(lease)
|
||||
microphoneLease = null
|
||||
throw IllegalStateException("AudioRecord failed to initialize")
|
||||
}
|
||||
|
||||
@@ -118,6 +139,8 @@ class VoiceRecorder(
|
||||
} catch (e: Exception) {
|
||||
recorder.release()
|
||||
currentOutputFile = null
|
||||
MicrophoneOwnershipCoordinator.release(lease)
|
||||
microphoneLease = null
|
||||
throw e
|
||||
}
|
||||
|
||||
@@ -281,6 +304,8 @@ class VoiceRecorder(
|
||||
try { record.release() } catch (_: Exception) { }
|
||||
}
|
||||
audioRecord = null
|
||||
microphoneLease?.let(MicrophoneOwnershipCoordinator::release)
|
||||
microphoneLease = null
|
||||
readThread = null
|
||||
readDone = null
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -48,6 +50,7 @@ data class ConnectionAuthSecrets(
|
||||
val refreshToken: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val profileApiKeys: Map<String, String> = emptyMap(),
|
||||
val pairedSessionMetaJson: String? = null,
|
||||
)
|
||||
|
||||
@@ -114,6 +117,7 @@ class AuthManager(
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val KEY_PROFILE_API_KEYS = "profile_api_server_keys"
|
||||
private const val HINT_API_KEY_PRESENT = "api_key_present"
|
||||
private const val KEY_PAIRED_META = "paired_session_meta_json"
|
||||
// Marker (in the connection-0 token store) recording that the one-shot
|
||||
@@ -132,6 +136,29 @@ class AuthManager(
|
||||
*/
|
||||
const val CONNECTION_ID_LEGACY: String = "legacy"
|
||||
|
||||
internal fun encodeProfileApiKeys(keys: Map<String, String>): String =
|
||||
Json.encodeToString(
|
||||
keys.mapNotNull { (profile, key) ->
|
||||
val normalizedProfile = profile.trim()
|
||||
val normalizedKey = key.trim()
|
||||
if (normalizedProfile.isBlank() || normalizedKey.isBlank()) null
|
||||
else normalizedProfile to normalizedKey
|
||||
}.toMap(),
|
||||
)
|
||||
|
||||
internal fun decodeProfileApiKeys(raw: String?): Map<String, String> {
|
||||
if (raw.isNullOrBlank()) return emptyMap()
|
||||
return runCatching { Json.decodeFromString<Map<String, String>>(raw) }
|
||||
.getOrDefault(emptyMap())
|
||||
.mapNotNull { (profile, key) ->
|
||||
val normalizedProfile = profile.trim()
|
||||
val normalizedKey = key.trim()
|
||||
if (normalizedProfile.isBlank() || normalizedKey.isBlank()) null
|
||||
else normalizedProfile to normalizedKey
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
internal fun shouldPreservePairedSessionOnAuthFail(
|
||||
currentState: AuthState,
|
||||
rawReason: String,
|
||||
@@ -173,6 +200,7 @@ class AuthManager(
|
||||
refreshToken = store.getString(KEY_REFRESH_TOKEN),
|
||||
deviceId = store.getString(KEY_DEVICE_ID),
|
||||
apiKey = store.getString(KEY_API_KEY),
|
||||
profileApiKeys = decodeProfileApiKeys(store.getString(KEY_PROFILE_API_KEYS)),
|
||||
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
|
||||
)
|
||||
}
|
||||
@@ -188,6 +216,11 @@ class AuthManager(
|
||||
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
|
||||
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
|
||||
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
|
||||
writeOrRemove(
|
||||
store,
|
||||
KEY_PROFILE_API_KEYS,
|
||||
secrets.profileApiKeys.takeIf { it.isNotEmpty() }?.let(::encodeProfileApiKeys),
|
||||
)
|
||||
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
|
||||
}
|
||||
}
|
||||
@@ -290,6 +323,7 @@ class AuthManager(
|
||||
|
||||
private var _store: SessionTokenStore? = null
|
||||
private val storeMutex = Mutex()
|
||||
private val profileApiKeysMutex = Mutex()
|
||||
|
||||
/**
|
||||
* The encrypted-store filename for this connection — shared by [store]
|
||||
@@ -416,6 +450,7 @@ class AuthManager(
|
||||
KEY_REFRESH_TOKEN,
|
||||
KEY_DEVICE_ID,
|
||||
KEY_API_KEY,
|
||||
KEY_PROFILE_API_KEYS,
|
||||
KEY_PAIRED_META,
|
||||
)
|
||||
var migrated = false
|
||||
@@ -947,6 +982,27 @@ class AuthManager(
|
||||
recordApiKeyHint(false)
|
||||
}
|
||||
|
||||
suspend fun getProfileApiKey(profileName: String): String? =
|
||||
decodeProfileApiKeys(store().getString(KEY_PROFILE_API_KEYS))[profileName.trim()]
|
||||
|
||||
suspend fun setProfileApiKey(profileName: String, key: String) {
|
||||
val normalizedProfile = profileName.trim()
|
||||
require(normalizedProfile.isNotBlank()) { "Profile name must not be blank" }
|
||||
profileApiKeysMutex.withLock {
|
||||
val tokenStore = store()
|
||||
val keys = decodeProfileApiKeys(tokenStore.getString(KEY_PROFILE_API_KEYS)).toMutableMap()
|
||||
val normalizedKey = key.trim()
|
||||
if (normalizedKey.isBlank()) keys.remove(normalizedProfile)
|
||||
else keys[normalizedProfile] = normalizedKey
|
||||
if (keys.isEmpty()) tokenStore.remove(KEY_PROFILE_API_KEYS)
|
||||
else tokenStore.putString(KEY_PROFILE_API_KEYS, encodeProfileApiKeys(keys))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearProfileApiKey(profileName: String) {
|
||||
setProfileApiKey(profileName, "")
|
||||
}
|
||||
|
||||
val isPaired: Boolean
|
||||
get() = _authState.value is AuthState.Paired
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ enum class AppLanguage(val languageTag: String) {
|
||||
JAPANESE("ja"),
|
||||
SIMPLIFIED_CHINESE("zh-Hans"),
|
||||
SPANISH("es"),
|
||||
RUSSIAN("ru"),
|
||||
;
|
||||
|
||||
fun toLocaleList(): LocaleListCompat = if (languageTag.isEmpty()) {
|
||||
@@ -35,6 +36,7 @@ enum class AppLanguage(val languageTag: String) {
|
||||
"es" -> SPANISH
|
||||
"ja" -> JAPANESE
|
||||
"pt" -> BRAZILIAN_PORTUGUESE
|
||||
"ru" -> RUSSIAN
|
||||
"zh" -> {
|
||||
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
|
||||
locale.script.isEmpty() ||
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -15,15 +17,14 @@ import kotlinx.coroutines.flow.map
|
||||
*
|
||||
* Phase V follow-on — owned by the voice-barge-in plan (Wave 1 / unit B1).
|
||||
*
|
||||
* Barge-in lets the user interrupt TTS playback by speaking. The three knobs
|
||||
* Barge-in lets the user interrupt generation or TTS playback by speaking.
|
||||
* here back the Voice Settings "Interruption" section added by B5 and are
|
||||
* consumed by [com.hermesandroid.relay.viewmodel.VoiceViewModel] (wired in
|
||||
* B4):
|
||||
*
|
||||
* - [enabled] — master toggle for the whole barge-in path. When false, the
|
||||
* listener never starts and TTS plays uninterrupted. Default off at launch
|
||||
* on both flavors so existing users aren't surprised by mic activation
|
||||
* during a speaking turn.
|
||||
* listener never starts and TTS plays uninterrupted. Default on matches
|
||||
* upstream Hermes full-duplex voice; users can opt out here.
|
||||
*
|
||||
* - [sensitivity] — maps to Silero VAD threshold + hysteresis tuning inside
|
||||
* [com.hermesandroid.relay.audio.VadEngine]. [BargeInSensitivity.Off] is
|
||||
@@ -36,6 +37,12 @@ import kotlinx.coroutines.flow.map
|
||||
* barge-in behaves like a hard cancel, which is more abrupt than most
|
||||
* conversational UX expects.
|
||||
*
|
||||
* - [thresholdMultiplier] / [playbackGraceMs] — upstream-compatible RMS
|
||||
* tuning. Defaults are 3x over the calibrated quiet floor and 500 ms.
|
||||
*
|
||||
* - [debugDiagnostics] — opt-in per-block VAD decision logging for logcat,
|
||||
* equivalent to upstream's HERMES_VOICE_DEBUG switch.
|
||||
*
|
||||
* Matches the [BridgePreferences] / [VoicePreferences] / [MediaSettings] style:
|
||||
* single shared DataStore (`relayDataStore`), one key per scalar field, enum
|
||||
* stored as its `name` (cheap + schema-evolvable via fall-back to default on
|
||||
@@ -45,6 +52,9 @@ data class BargeInPreferences(
|
||||
val enabled: Boolean = DEFAULT_ENABLED,
|
||||
val sensitivity: BargeInSensitivity = DEFAULT_SENSITIVITY,
|
||||
val resumeAfterInterruption: Boolean = DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
val thresholdMultiplier: Float = DEFAULT_THRESHOLD_MULTIPLIER,
|
||||
val playbackGraceMs: Long = DEFAULT_PLAYBACK_GRACE_MS,
|
||||
val debugDiagnostics: Boolean = DEFAULT_DEBUG_DIAGNOSTICS,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -62,9 +72,12 @@ enum class BargeInSensitivity {
|
||||
High,
|
||||
}
|
||||
|
||||
const val DEFAULT_ENABLED: Boolean = false
|
||||
const val DEFAULT_ENABLED: Boolean = true
|
||||
val DEFAULT_SENSITIVITY: BargeInSensitivity = BargeInSensitivity.Default
|
||||
const val DEFAULT_RESUME_AFTER_INTERRUPTION: Boolean = true
|
||||
const val DEFAULT_THRESHOLD_MULTIPLIER: Float = 3f
|
||||
const val DEFAULT_PLAYBACK_GRACE_MS: Long = 500L
|
||||
const val DEFAULT_DEBUG_DIAGNOSTICS: Boolean = false
|
||||
|
||||
/**
|
||||
* DataStore-backed repository for [BargeInPreferences].
|
||||
@@ -86,6 +99,10 @@ class BargeInPreferencesRepository(
|
||||
internal val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
|
||||
internal val KEY_RESUME_AFTER_INTERRUPTION =
|
||||
booleanPreferencesKey("barge_in_resume_after_interruption")
|
||||
internal val KEY_THRESHOLD_MULTIPLIER =
|
||||
floatPreferencesKey("barge_in_threshold_multiplier")
|
||||
internal val KEY_PLAYBACK_GRACE_MS = longPreferencesKey("barge_in_playback_grace_ms")
|
||||
internal val KEY_DEBUG_DIAGNOSTICS = booleanPreferencesKey("barge_in_debug_diagnostics")
|
||||
}
|
||||
|
||||
val flow: Flow<BargeInPreferences> = dataStore.data
|
||||
@@ -96,6 +113,14 @@ class BargeInPreferencesRepository(
|
||||
?: DEFAULT_SENSITIVITY,
|
||||
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
|
||||
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
thresholdMultiplier = prefs[KEY_THRESHOLD_MULTIPLIER]
|
||||
?.coerceIn(MIN_THRESHOLD_MULTIPLIER, MAX_THRESHOLD_MULTIPLIER)
|
||||
?: DEFAULT_THRESHOLD_MULTIPLIER,
|
||||
playbackGraceMs = prefs[KEY_PLAYBACK_GRACE_MS]
|
||||
?.coerceIn(MIN_PLAYBACK_GRACE_MS, MAX_PLAYBACK_GRACE_MS)
|
||||
?: DEFAULT_PLAYBACK_GRACE_MS,
|
||||
debugDiagnostics = prefs[KEY_DEBUG_DIAGNOSTICS]
|
||||
?: DEFAULT_DEBUG_DIAGNOSTICS,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
@@ -112,6 +137,33 @@ class BargeInPreferencesRepository(
|
||||
dataStore.edit { it[KEY_RESUME_AFTER_INTERRUPTION] = value }
|
||||
}
|
||||
|
||||
suspend fun setThresholdMultiplier(value: Float) {
|
||||
dataStore.edit {
|
||||
it[KEY_THRESHOLD_MULTIPLIER] = value.coerceIn(
|
||||
MIN_THRESHOLD_MULTIPLIER,
|
||||
MAX_THRESHOLD_MULTIPLIER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPlaybackGraceMs(value: Long) {
|
||||
dataStore.edit {
|
||||
it[KEY_PLAYBACK_GRACE_MS] = value.coerceIn(
|
||||
MIN_PLAYBACK_GRACE_MS,
|
||||
MAX_PLAYBACK_GRACE_MS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDebugDiagnostics(value: Boolean) {
|
||||
dataStore.edit { it[KEY_DEBUG_DIAGNOSTICS] = value }
|
||||
}
|
||||
|
||||
private fun decodeSensitivity(raw: String): BargeInSensitivity =
|
||||
runCatching { BargeInSensitivity.valueOf(raw) }.getOrDefault(DEFAULT_SENSITIVITY)
|
||||
}
|
||||
|
||||
private const val MIN_THRESHOLD_MULTIPLIER = 1f
|
||||
private const val MAX_THRESHOLD_MULTIPLIER = 8f
|
||||
private const val MIN_PLAYBACK_GRACE_MS = 0L
|
||||
private const val MAX_PLAYBACK_GRACE_MS = 3_000L
|
||||
|
||||
@@ -22,6 +22,7 @@ object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||
private val KEY_PET_TERRAIN_OVERLAY = booleanPreferencesKey("pet_terrain_overlay")
|
||||
|
||||
/** Whether the app is running a debug build. */
|
||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||
@@ -32,6 +33,35 @@ object FeatureFlags {
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer-only visualization of the floating pet's measured terrain.
|
||||
*
|
||||
* The persisted request is intentionally weaker than both gates: release
|
||||
* builds can never enable it, and explicitly locking Developer Options
|
||||
* suppresses it immediately.
|
||||
*/
|
||||
fun petTerrainOverlayEnabled(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
petTerrainOverlayEffective(
|
||||
isDevBuild = isDevBuild,
|
||||
devOptionsUnlocked = prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild,
|
||||
requested = prefs[KEY_PET_TERRAIN_OVERLAY] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun petTerrainOverlayEffective(
|
||||
isDevBuild: Boolean,
|
||||
devOptionsUnlocked: Boolean,
|
||||
requested: Boolean,
|
||||
): Boolean = isDevBuild && devOptionsUnlocked && requested
|
||||
|
||||
/** Persist the developer's overlay request. Runtime gates remain authoritative. */
|
||||
suspend fun setPetTerrainOverlayEnabled(context: Context, enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_PET_TERRAIN_OVERLAY] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
/** Unlock Developer Options. */
|
||||
suspend fun unlockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
@@ -43,6 +73,7 @@ object FeatureFlags {
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||
prefs[KEY_PET_TERRAIN_OVERLAY] = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/** Exact cadence values consumed by the floating-pet behavior director. */
|
||||
data class PetBehaviorPacing(
|
||||
val responseVisitDelayMs: Long,
|
||||
val roamIntervalMs: Long,
|
||||
val idleReactionCadenceMs: Long,
|
||||
) {
|
||||
init {
|
||||
require(responseVisitDelayMs > 0L)
|
||||
require(roamIntervalMs > responseVisitDelayMs)
|
||||
require(idleReactionCadenceMs > roamIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing pet activity presets.
|
||||
*
|
||||
* These values control how often an otherwise-idle pet may act. Existing
|
||||
* animation, reduced-motion, touch-exploration, scrolling, and agent-activity
|
||||
* gates remain authoritative and may always defer an action.
|
||||
*/
|
||||
enum class PetTemperament(val pacing: PetBehaviorPacing) {
|
||||
Calm(
|
||||
PetBehaviorPacing(
|
||||
responseVisitDelayMs = 2_500L,
|
||||
roamIntervalMs = 12_000L,
|
||||
idleReactionCadenceMs = 28_000L,
|
||||
),
|
||||
),
|
||||
Balanced(
|
||||
PetBehaviorPacing(
|
||||
responseVisitDelayMs = 1_500L,
|
||||
roamIntervalMs = 8_000L,
|
||||
idleReactionCadenceMs = 18_000L,
|
||||
),
|
||||
),
|
||||
Playful(
|
||||
PetBehaviorPacing(
|
||||
responseVisitDelayMs = 750L,
|
||||
roamIntervalMs = 5_000L,
|
||||
idleReactionCadenceMs = 10_000L,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
val DEFAULT_PET_TEMPERAMENT: PetTemperament = PetTemperament.Balanced
|
||||
const val DEFAULT_PET_SIZE_SCALE: Float = 1f
|
||||
const val MIN_PET_SIZE_SCALE: Float = 0.6f
|
||||
const val MAX_PET_SIZE_SCALE: Float = 1.2f
|
||||
private const val LEGACY_PET_SIZE_BASE_SCALE: Float = 1.25f
|
||||
private const val CURRENT_PET_SIZE_SCALE_VERSION: Int = 2
|
||||
|
||||
internal fun sanitizedPetSizeScale(value: Float?): Float =
|
||||
value?.takeIf(Float::isFinite)?.coerceIn(MIN_PET_SIZE_SCALE, MAX_PET_SIZE_SCALE)
|
||||
?: DEFAULT_PET_SIZE_SCALE
|
||||
|
||||
internal fun decodeStoredPetSizeScale(value: Float?, version: Int?): Float {
|
||||
if (value == null) return DEFAULT_PET_SIZE_SCALE
|
||||
val rebased = if (version == null) value / LEGACY_PET_SIZE_BASE_SCALE else value
|
||||
return sanitizedPetSizeScale(rebased)
|
||||
}
|
||||
|
||||
data class PetBehaviorPreferences(
|
||||
val temperament: PetTemperament = DEFAULT_PET_TEMPERAMENT,
|
||||
val sizeScale: Float = DEFAULT_PET_SIZE_SCALE,
|
||||
) {
|
||||
/**
|
||||
* Runtime seam for the behavior director. A disabled motion gate returns
|
||||
* no pacing rather than weakening the app's accessibility policy.
|
||||
*/
|
||||
fun pacingWhenMotionAllowed(motionAllowed: Boolean): PetBehaviorPacing? =
|
||||
temperament.pacing.takeIf { motionAllowed }
|
||||
}
|
||||
|
||||
/** Additive, phone-local DataStore persistence for pet behavior preferences. */
|
||||
class PetBehaviorPreferencesRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
internal val KEY_TEMPERAMENT = stringPreferencesKey("pet_temperament")
|
||||
internal val KEY_SIZE_SCALE = floatPreferencesKey("pet_size_scale")
|
||||
internal val KEY_SIZE_SCALE_VERSION = intPreferencesKey("pet_size_scale_version")
|
||||
}
|
||||
|
||||
val flow: Flow<PetBehaviorPreferences> = dataStore.data
|
||||
.map { preferences ->
|
||||
PetBehaviorPreferences(
|
||||
temperament = decodeTemperament(preferences[KEY_TEMPERAMENT]),
|
||||
sizeScale = decodeStoredPetSizeScale(
|
||||
value = preferences[KEY_SIZE_SCALE],
|
||||
version = preferences[KEY_SIZE_SCALE_VERSION],
|
||||
),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
val temperament: Flow<PetTemperament> = flow
|
||||
.map { preferences -> preferences.temperament }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val sizeScale: Flow<Float> = flow
|
||||
.map { preferences -> preferences.sizeScale }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setTemperament(temperament: PetTemperament) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_TEMPERAMENT] = temperament.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSizeScale(sizeScale: Float) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_SIZE_SCALE] = sanitizedPetSizeScale(sizeScale)
|
||||
preferences[KEY_SIZE_SCALE_VERSION] = CURRENT_PET_SIZE_SCALE_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeTemperament(raw: String?): PetTemperament =
|
||||
raw?.let { stored -> PetTemperament.entries.firstOrNull { it.name == stored } }
|
||||
?: DEFAULT_PET_TEMPERAMENT
|
||||
}
|
||||
@@ -47,9 +47,10 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* **Hermes profile API metadata.** A relay can advertise an isolated
|
||||
* profile API server without exposing its secret. When [apiServerUrl] is
|
||||
* present, Android routes chat/session traffic to that URL and reuses the
|
||||
* active connection's stored API key. Operators that use distinct API keys
|
||||
* per profile should pair those profile API servers as separate connections.
|
||||
* present, Android routes chat/session traffic to that URL using the active
|
||||
* connection credential. A positively identified shared multiplex
|
||||
* `/p/<profile>` route instead uses a separately encrypted profile credential;
|
||||
* the root connection key is never reused for that route.
|
||||
*/
|
||||
@Serializable
|
||||
data class Profile(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/** Live activity surfaced beside a session without conflating it with selection. */
|
||||
enum class SessionActivityState {
|
||||
Working,
|
||||
NeedsInput,
|
||||
}
|
||||
@@ -13,6 +13,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val DEFAULT_VOICE_STOP_PHRASES: List<String> = listOf("stop")
|
||||
|
||||
/**
|
||||
* User-tunable voice mode preferences.
|
||||
@@ -36,6 +41,8 @@ data class VoiceSettings(
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 1250L,
|
||||
/** Exact phrases that end an active voice chat; empty disables the command. */
|
||||
val stopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES,
|
||||
/**
|
||||
* When true, voice keeps progress visual and waits for the settled Hermes
|
||||
* answer before speaking. Tool status, service updates, and intermediate
|
||||
@@ -200,6 +207,7 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// un-namespaced means switching profiles never churns these.
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_STOP_PHRASES = stringPreferencesKey("voice_stop_phrases")
|
||||
private val KEY_FINAL_ANSWER_ONLY = booleanPreferencesKey("voice_final_answer_only")
|
||||
private val KEY_PRESENTATION_MODE = stringPreferencesKey("voice_presentation_mode")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
@@ -215,6 +223,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
const val DEFAULT_PRESENTATION_MODE = "focus"
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
private val stopPhrasesJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the storage name for a per-profile [base] key under [scope].
|
||||
@@ -280,6 +292,7 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// --- global (shared across profiles) ---
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
stopPhrases = decodeStopPhrases(prefs[KEY_STOP_PHRASES]),
|
||||
finalAnswerOnly = prefs[KEY_FINAL_ANSWER_ONLY] ?: DEFAULT_FINAL_ANSWER_ONLY,
|
||||
presentationMode = VoicePresentationMode.fromStorage(
|
||||
prefs[KEY_PRESENTATION_MODE] ?: DEFAULT_PRESENTATION_MODE,
|
||||
@@ -393,6 +406,15 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setStopPhrases(phrases: List<String>) {
|
||||
val normalized = phrases.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
.toList()
|
||||
dataStore.edit { it[KEY_STOP_PHRASES] = stopPhrasesJson.encodeToString(normalized) }
|
||||
}
|
||||
|
||||
suspend fun setFinalAnswerOnly(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_FINAL_ANSWER_ONLY] = enabled }
|
||||
}
|
||||
@@ -409,6 +431,17 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
|
||||
}
|
||||
|
||||
private fun decodeStopPhrases(raw: String?): List<String> {
|
||||
if (raw == null) return DEFAULT_VOICE_STOP_PHRASES
|
||||
return runCatching { stopPhrasesJson.decodeFromString<List<String>>(raw) }
|
||||
.getOrDefault(DEFAULT_VOICE_STOP_PHRASES)
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically apply the phone-side portion of [preset]. Only fields owned by
|
||||
* the preset are written, so route/provider/model/voice overrides and other
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.diagnostics
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.hermesandroid.relay.reliability.ReliabilityCenter
|
||||
import com.hermesandroid.relay.reliability.ReliabilityRedactor
|
||||
|
||||
enum class DiagnosticCategory(val label: String) {
|
||||
Api("API"),
|
||||
@@ -25,7 +27,16 @@ data class DiagnosticLogEntry(
|
||||
val severity: DiagnosticSeverity,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
/** Human-readable action that produced this event, not merely its subsystem. */
|
||||
val operation: String? = null,
|
||||
val endpointRole: String? = null,
|
||||
/** User/configuration-facing route before protocol/path normalization. */
|
||||
val configuredUrl: String? = null,
|
||||
/** Exact sanitized URL attempted on the wire, including the diagnostic path. */
|
||||
val requestUrl: String? = null,
|
||||
/** Concrete next troubleshooting step for failures with a known interpretation. */
|
||||
val suggestion: String? = null,
|
||||
/** Legacy single-URL field retained for diagnostics that have not needed split context. */
|
||||
val url: String? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
/**
|
||||
@@ -34,7 +45,11 @@ data class DiagnosticLogEntry(
|
||||
* the detail view shows this. Null for non-error / manually-recorded entries.
|
||||
*/
|
||||
val stacktrace: String? = null,
|
||||
)
|
||||
) {
|
||||
/** Best route for mode inference and compact list rendering. */
|
||||
val primaryUrl: String?
|
||||
get() = configuredUrl ?: requestUrl ?: url
|
||||
}
|
||||
|
||||
/**
|
||||
* Current health of a single subsystem on the Diagnostics status timeline.
|
||||
@@ -81,19 +96,29 @@ object DiagnosticsLog {
|
||||
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
operation: String? = null,
|
||||
endpointRole: String? = null,
|
||||
configuredUrl: String? = null,
|
||||
requestUrl: String? = null,
|
||||
suggestion: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
stacktrace: String? = null,
|
||||
) {
|
||||
val safeConfiguredUrl = sanitizeUrl(configuredUrl)
|
||||
val safeRequestUrl = sanitizeUrl(requestUrl)
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
category = category,
|
||||
severity = severity,
|
||||
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
|
||||
detail = clean(detail),
|
||||
operation = clean(operation),
|
||||
endpointRole = clean(endpointRole),
|
||||
url = sanitizeUrl(url),
|
||||
configuredUrl = safeConfiguredUrl,
|
||||
requestUrl = safeRequestUrl,
|
||||
suggestion = clean(suggestion),
|
||||
url = if (safeConfiguredUrl == null && safeRequestUrl == null) sanitizeUrl(url) else null,
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = redactTrace(stacktrace),
|
||||
)
|
||||
@@ -121,20 +146,40 @@ object DiagnosticsLog {
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
throwable: Throwable? = null,
|
||||
operation: String? = null,
|
||||
endpointRole: String? = null,
|
||||
configuredUrl: String? = null,
|
||||
requestUrl: String? = null,
|
||||
suggestion: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
reliabilityContext: String? = null,
|
||||
) {
|
||||
record(
|
||||
category = category,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = title,
|
||||
detail = detail ?: throwable?.message,
|
||||
operation = operation,
|
||||
endpointRole = endpointRole,
|
||||
configuredUrl = configuredUrl,
|
||||
requestUrl = requestUrl,
|
||||
suggestion = suggestion,
|
||||
url = url,
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = throwable?.let { stackTraceText(it) },
|
||||
)
|
||||
if (throwable != null) {
|
||||
runCatching {
|
||||
ReliabilityCenter.recordHandled(
|
||||
title = title,
|
||||
detail = detail ?: throwable.message,
|
||||
throwable = throwable,
|
||||
context = reliabilityContext,
|
||||
routeRole = endpointRole,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stackTraceText(t: Throwable): String =
|
||||
@@ -167,10 +212,8 @@ object DiagnosticsLog {
|
||||
val prefix = noQuery.substring(0, schemeEnd + 3)
|
||||
val rest = noQuery.substring(schemeEnd + 3)
|
||||
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, slash)
|
||||
val path = rest.substring(slash)
|
||||
val safeAuthority = authority.substringAfterLast('@')
|
||||
prefix + safeAuthority + path
|
||||
prefix + "[host]" + path
|
||||
} else {
|
||||
noQuery
|
||||
}
|
||||
@@ -197,7 +240,7 @@ object DiagnosticsLog {
|
||||
*/
|
||||
private fun redactTrace(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val redacted = redact(trimmed)
|
||||
val redacted = ReliabilityRedactor.redact(trimmed, MAX_TRACE_LENGTH)
|
||||
return if (redacted.length > MAX_TRACE_LENGTH) {
|
||||
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
|
||||
} else {
|
||||
@@ -205,8 +248,5 @@ object DiagnosticsLog {
|
||||
}
|
||||
}
|
||||
|
||||
private fun redact(value: String): String =
|
||||
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
private fun redact(value: String): String = ReliabilityRedactor.redact(value, MAX_TRACE_LENGTH)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.hermesandroid.relay.diagnostics
|
||||
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Maps network failure classes to narrow, truthful next steps.
|
||||
*
|
||||
* These messages are diagnostic guidance, not recovery behavior: callers still
|
||||
* own retries, routing, and authentication. Walking the cause chain preserves
|
||||
* useful classification when OkHttp or a coroutine boundary wraps the socket
|
||||
* exception in a higher-level failure.
|
||||
*/
|
||||
object NetworkDiagnosticGuidance {
|
||||
fun forThrowable(throwable: Throwable, target: String): String? {
|
||||
val causes = generateSequence(throwable as Throwable?) { it.cause }.take(12).toList()
|
||||
return when {
|
||||
causes.any { it is ConnectException } ->
|
||||
"Verify $target is running and listening on the configured host and port."
|
||||
causes.any { it is UnknownHostException } ->
|
||||
"Verify the configured hostname resolves from this device."
|
||||
causes.any { it is NoRouteToHostException } ->
|
||||
"Verify the device has a network path to the configured host."
|
||||
causes.any { it is SocketTimeoutException } ->
|
||||
"Check network routing or firewall rules between this device and $target."
|
||||
causes.any { it is SSLException } ->
|
||||
"Verify the TLS scheme, certificate, and trust configuration for $target."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun forHttpStatus(statusCode: Int, target: String): String? = when (statusCode) {
|
||||
401, 403 -> "Verify the configured $target credentials or pair the device again."
|
||||
404 -> "Verify this URL points to the expected $target route and version."
|
||||
429 -> "Wait for the server's backoff period before retrying."
|
||||
in 500..599 -> "Check the $target server logs for the failing request."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
@@ -14,11 +15,14 @@ import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.shared.EndpointSurface
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -44,6 +48,20 @@ enum class ConnectionState {
|
||||
Reconnecting
|
||||
}
|
||||
|
||||
internal fun isRelayRateLimitBackoffActive(untilMs: Long, nowMs: Long): Boolean =
|
||||
untilMs > nowMs
|
||||
|
||||
internal fun canOverrideScheduledRelayReconnect(
|
||||
state: ConnectionState,
|
||||
backoffWaiting: Boolean,
|
||||
rateLimitBackoffActive: Boolean,
|
||||
): Boolean = !rateLimitBackoffActive && when (state) {
|
||||
ConnectionState.Disconnected -> true
|
||||
ConnectionState.Reconnecting -> backoffWaiting
|
||||
ConnectionState.Connecting,
|
||||
ConnectionState.Connected -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OkHttp request for a relay socket URL, or `null` if the URL is
|
||||
* malformed. OkHttp's [Request.Builder.url] throws [IllegalArgumentException]
|
||||
@@ -161,7 +179,11 @@ class ConnectionManager(
|
||||
|
||||
@Volatile
|
||||
private var serverUrl: String? = null
|
||||
private var reconnectAttempt = 0
|
||||
private val reconnectState = RelayReconnectState()
|
||||
@Volatile
|
||||
private var reconnectJob: Job? = null
|
||||
@Volatile
|
||||
private var reconnectBackoffWaiting = false
|
||||
private var shouldReconnect = true
|
||||
// Last HTTP status seen during WSS upgrade, captured in onFailure.
|
||||
// Used by scheduleReconnect() to pick an appropriate backoff — notably
|
||||
@@ -169,14 +191,8 @@ class ConnectionManager(
|
||||
// we don't re-fill the ban bucket and brick our own auth window.
|
||||
@Volatile
|
||||
private var lastUpgradeResponseCode: Int? = null
|
||||
|
||||
// Consecutive relay socket failures (response == null) since the last
|
||||
// successful onOpen. One slow Tailscale/DERP cold-start handshake must not
|
||||
// immediately evict the active route from the SHARED resolver cache (chat +
|
||||
// dashboard ride the same resolver), so we only poison the route after a
|
||||
// couple of consecutive transport-level failures.
|
||||
@Volatile
|
||||
private var consecutiveSocketFailures = 0
|
||||
private var rateLimitBackoffUntilMs: Long = 0L
|
||||
|
||||
// The relay requires the FIRST frame on a socket to be `system/auth` and
|
||||
// rejects the whole connection otherwise ("expected system/auth, got
|
||||
@@ -206,6 +222,10 @@ class ConnectionManager(
|
||||
private val _activeEndpoint = MutableStateFlow<EndpointCandidate?>(null)
|
||||
val activeEndpoint: StateFlow<EndpointCandidate?> = _activeEndpoint.asStateFlow()
|
||||
|
||||
/** Relay-only winner, deliberately separate from the standard route. */
|
||||
@Volatile
|
||||
private var activeRelayEndpoint: EndpointCandidate? = null
|
||||
|
||||
/**
|
||||
* Manual role override. When non-null, the resolver's output is replaced
|
||||
* with whichever candidate in the stored list matches this role (case-
|
||||
@@ -262,9 +282,9 @@ class ConnectionManager(
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
// How many consecutive relay socket failures before we mark the active
|
||||
// endpoint unreachable in the shared resolver cache. Tolerates a single
|
||||
// cold-start blip on a slow remote (Tailscale DERP) link.
|
||||
// How many consecutive failures on one relay socket URL before we mark
|
||||
// that candidate's Relay surface unreachable. Standard Dashboard/API
|
||||
// reachability is cached independently and remains healthy.
|
||||
private const val MARK_UNREACHABLE_AFTER_FAILURES = 2
|
||||
// Settle window before re-resolving after a network event. Long
|
||||
// enough to coalesce the onAvailable burst of a handoff, short
|
||||
@@ -325,17 +345,19 @@ class ConnectionManager(
|
||||
// behavior for freshly-upgraded installs and for v1/v2 QRs where
|
||||
// the synthesized list just collapses to the same URL anyway.
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val resolvedRelayUrl = resolved?.relay?.url?.takeIf { it.isNotBlank() }
|
||||
val resolved = resolveBestEndpointSafe(EndpointSurface.Standard)
|
||||
val relayResolved = resolveBestEndpointSafe(EndpointSurface.Relay)
|
||||
val resolvedRelayUrl = relayResolved?.relay?.url?.takeIf { it.isNotBlank() }
|
||||
val targetUrl = resolvedRelayUrl ?: url.takeIf { it.isNotBlank() }
|
||||
activeRelayEndpoint = relayResolved
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
|
||||
"route=${resolved.primaryRouteUrl()} (relay fallback would have been $url)")
|
||||
Log.i(TAG, "connect: standard resolver picked role=${resolved.role} " +
|
||||
"route=${resolved.primaryRouteUrl()}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
|
||||
title = context?.getString(R.string.conn_diag_route_selected) ?: "Route selected",
|
||||
endpointRole = resolved.role,
|
||||
url = resolved.primaryRouteUrl(),
|
||||
)
|
||||
@@ -350,6 +372,13 @@ class ConnectionManager(
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
relayResolved?.let { relayRoute ->
|
||||
Log.i(
|
||||
TAG,
|
||||
"connect: relay resolver picked role=${relayRoute.role} " +
|
||||
"url=${relayRoute.relay?.url}",
|
||||
)
|
||||
}
|
||||
if (targetUrl != null) {
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else {
|
||||
@@ -358,6 +387,41 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an ordinary scheduled reconnect with an immediate attempt.
|
||||
*
|
||||
* Foregrounding the app or opening Relay status is an explicit signal that
|
||||
* the route may be usable again, so exponential/slow-poll backoff should not
|
||||
* make the user wait. A server-issued 429 is different: retrying early would
|
||||
* extend the server block, so that protected backoff is never overridden.
|
||||
*/
|
||||
fun reconnectNowIfAllowed(url: String): Boolean {
|
||||
val rateLimitActive = isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
if (!canOverrideScheduledRelayReconnect(
|
||||
state = _connectionState.value,
|
||||
backoffWaiting = reconnectBackoffWaiting,
|
||||
rateLimitBackoffActive = rateLimitActive,
|
||||
)
|
||||
) {
|
||||
if (rateLimitActive) {
|
||||
Log.i(TAG, "reconnectNowIfAllowed: preserving rate-limit backoff")
|
||||
}
|
||||
return false
|
||||
}
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
// connectToUrlOnMainPath suppresses duplicate opens while the manager is
|
||||
// Reconnecting. Move to the honest idle state before starting the fresh
|
||||
// resolver/open path; the ViewModel's grace window prevents UI flicker.
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
connect(url)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
@@ -368,6 +432,7 @@ class ConnectionManager(
|
||||
private fun connectToUrlOnMainPath(
|
||||
url: String,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
preserveReconnectBackoff: Boolean = false,
|
||||
) {
|
||||
val isInsecure = url.startsWith("ws://") && !url.startsWith("wss://")
|
||||
if (isInsecure && !_insecureMode.value) {
|
||||
@@ -377,7 +442,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
|
||||
detail = "ws:// is disabled",
|
||||
url = url,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
suggestion = "Use wss:// or explicitly allow plain ws:// for a trusted LAN or VPN.",
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -388,7 +455,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
|
||||
detail = "URL must start with ws:// or wss://",
|
||||
url = url,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
suggestion = "Edit or re-pair the Relay route with a ws:// or wss:// URL.",
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -398,6 +467,14 @@ class ConnectionManager(
|
||||
// hits the HTTP root and comes back as 404 Not Found during the
|
||||
// upgrade handshake. We still accept an explicit path if present.
|
||||
val normalized = normalizeRelayUrl(url)
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "connect: preserving active rate-limit backoff")
|
||||
return
|
||||
}
|
||||
val existingState = _connectionState.value
|
||||
if (serverUrl == normalized &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
@@ -416,20 +493,28 @@ class ConnectionManager(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
|
||||
url = normalized,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
requestUrl = normalized,
|
||||
)
|
||||
} else {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
|
||||
url = normalized,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
requestUrl = normalized,
|
||||
)
|
||||
}
|
||||
|
||||
serverUrl = normalized
|
||||
shouldReconnect = true
|
||||
reconnectAttempt = 0
|
||||
if (preserveReconnectBackoff) {
|
||||
reconnectState.beginAutomaticRouteSwap(normalized)
|
||||
} else {
|
||||
reconnectState.beginExplicitConnect(normalized)
|
||||
}
|
||||
doConnect(normalized, previousSocket, replaceReason)
|
||||
}
|
||||
|
||||
@@ -445,9 +530,12 @@ class ConnectionManager(
|
||||
* Wraps the DataStore read in a 1-second timeout; if DataStore stalls
|
||||
* for any reason we don't block the connect loop forever.
|
||||
*/
|
||||
suspend fun resolveBestEndpoint(): EndpointCandidate? = resolveBestEndpointSafe()
|
||||
suspend fun resolveBestEndpoint(): EndpointCandidate? =
|
||||
resolveBestEndpointSafe(EndpointSurface.Standard)
|
||||
|
||||
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
|
||||
private suspend fun resolveBestEndpointSafe(
|
||||
surface: EndpointSurface,
|
||||
): EndpointCandidate? {
|
||||
val resolver = endpointResolver ?: return null
|
||||
val ctx = context ?: return null
|
||||
|
||||
@@ -486,14 +574,14 @@ class ConnectionManager(
|
||||
}
|
||||
if (preferred != null) {
|
||||
// Single-element list still respects the 2s probe gate.
|
||||
val winner = resolver.resolve(listOf(preferred))
|
||||
val winner = resolver.resolve(listOf(preferred), surface)
|
||||
if (winner != null) return winner
|
||||
Log.i(TAG, "manualRoleOverride=$preferredRole not reachable — " +
|
||||
"falling through to strict-priority resolve")
|
||||
}
|
||||
}
|
||||
|
||||
return resolver.resolve(endpoints)
|
||||
return resolver.resolve(endpoints, surface)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -521,7 +609,8 @@ class ConnectionManager(
|
||||
suspend fun probeAndReconnectNow(): EndpointCandidate? {
|
||||
endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val resolved = resolveBestEndpointSafe(EndpointSurface.Standard)
|
||||
val relayResolved = resolveBestEndpointSafe(EndpointSurface.Relay)
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// — keep the live route published rather than downgrading every
|
||||
@@ -529,7 +618,8 @@ class ConnectionManager(
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
|
||||
if (relayResolved != null) activeRelayEndpoint = relayResolved
|
||||
val targetUrl = relayResolved?.relay?.url ?: current ?: return resolved
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
@@ -566,7 +656,7 @@ class ConnectionManager(
|
||||
*/
|
||||
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
|
||||
if (clearProbeCache) endpointResolver?.clearCache()
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val resolved = resolveBestEndpointSafe(EndpointSurface.Standard)
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// (slow resume, mid-handoff blip) — keep publishing the live
|
||||
@@ -590,9 +680,9 @@ class ConnectionManager(
|
||||
|
||||
fun getManualRoleOverride(): String? = _manualRoleOverride.value
|
||||
|
||||
private fun markActiveEndpointUnreachable(reason: String) {
|
||||
val active = _activeEndpoint.value ?: return
|
||||
endpointResolver?.markUnreachable(active)
|
||||
private fun markActiveRelayEndpointUnreachable(reason: String) {
|
||||
val active = activeRelayEndpoint ?: return
|
||||
endpointResolver?.markUnreachable(active, EndpointSurface.Relay)
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
@@ -615,9 +705,9 @@ class ConnectionManager(
|
||||
// Tailscale's tun churns onAvailable repeatedly — coalesces into a
|
||||
// single cache wipe + re-probe instead of one per event. onLost
|
||||
// manages its own cache (clear + markUnreachable) and passes false.
|
||||
if (wipeCache) endpointResolver?.clearCache()
|
||||
if (wipeCache) endpointResolver.clearCache()
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val resolved = resolveBestEndpointSafe(EndpointSurface.Standard)
|
||||
if (resolved == null) {
|
||||
// Hysteresis for the AUTOMATIC (network-callback) path. A
|
||||
// transient cold-route probe miss must NOT null the published
|
||||
@@ -662,11 +752,34 @@ class ConnectionManager(
|
||||
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||
// the swap path never re-checked it.)
|
||||
if (!shouldReconnect) return@launch
|
||||
val relayUrl = resolved.relay?.url?.takeIf { it.isNotBlank() } ?: return@launch
|
||||
val relayResolved = resolveBestEndpointSafe(EndpointSurface.Relay)
|
||||
if (relayResolved != null) activeRelayEndpoint = relayResolved
|
||||
val relayUrl = relayResolved?.relay?.url?.takeIf { it.isNotBlank() }
|
||||
?: return@launch
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
// Keep publishing the newly resolved standard/Relay routes, but
|
||||
// leave the protected retry job intact. It will resolve the
|
||||
// latest Relay winner again when the server cooldown expires.
|
||||
Log.i(TAG, "network change: preserving rate-limit retry job")
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(relayUrl)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(relayUrl, closeReason)
|
||||
if (reconnectBackoffWaiting) {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
}
|
||||
connectToUrlOnMainPath(
|
||||
relayUrl,
|
||||
closeReason,
|
||||
preserveReconnectBackoff = true,
|
||||
)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
reconnectGate()
|
||||
) {
|
||||
@@ -708,7 +821,9 @@ class ConnectionManager(
|
||||
Log.i(TAG, "network loss sustained past grace — marking active endpoint unreachable and resolving fallback")
|
||||
sustainedLossDeclared = true
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost (sustained)")
|
||||
_activeEndpoint.value?.let { active ->
|
||||
endpointResolver?.markUnreachable(active, EndpointSurface.Standard)
|
||||
}
|
||||
// wipeCache=false: we just cleared + poisoned the dead route
|
||||
// above; re-wiping inside the job would drop that negative
|
||||
// entry and let the dead route win the resolve again.
|
||||
@@ -762,6 +877,11 @@ class ConnectionManager(
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
rateLimitBackoffUntilMs = 0L
|
||||
lastUpgradeResponseCode = null
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
@@ -779,6 +899,8 @@ class ConnectionManager(
|
||||
// the ViewModel on the next connection load.
|
||||
_manualRoleOverride.value = null
|
||||
_activeEndpoint.value = null
|
||||
activeRelayEndpoint = null
|
||||
reconnectState.reset()
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
@@ -815,19 +937,28 @@ class ConnectionManager(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
scheduledReconnect: Boolean = false,
|
||||
) {
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "doConnect: preserving active rate-limit backoff")
|
||||
return
|
||||
}
|
||||
val existingState = _connectionState.value
|
||||
if (previousSocketToClose == null &&
|
||||
serverUrl == url &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
(existingState == ConnectionState.Reconnecting && !scheduledReconnect))
|
||||
) {
|
||||
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
|
||||
return
|
||||
}
|
||||
|
||||
_connectionState.value = if (reconnectAttempt > 0) {
|
||||
_connectionState.value = if (reconnectState.reconnectAttempt > 0) {
|
||||
ConnectionState.Reconnecting
|
||||
} else {
|
||||
ConnectionState.Connecting
|
||||
@@ -863,7 +994,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Invalid relay URL",
|
||||
detail = "The relay address could not be parsed; re-pair to refresh it.",
|
||||
url = url,
|
||||
operation = "Build Relay WebSocket request",
|
||||
configuredUrl = url,
|
||||
suggestion = "Edit or re-pair the Relay route to replace the invalid address.",
|
||||
)
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
@@ -884,16 +1017,20 @@ class ConnectionManager(
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
reconnectState.connected(url)
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
rateLimitBackoffUntilMs = 0L
|
||||
lastUpgradeResponseCode = null
|
||||
consecutiveSocketFailures = 0
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
|
||||
url = url,
|
||||
operation = "Relay WebSocket handshake",
|
||||
requestUrl = url,
|
||||
)
|
||||
|
||||
// TOFU: record the peer cert fingerprint if we don't have one
|
||||
@@ -954,7 +1091,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
|
||||
detail = "code=$code reason=$reason",
|
||||
url = url,
|
||||
operation = "Relay WebSocket session",
|
||||
requestUrl = url,
|
||||
suggestion = if (code == 1000) null else "Check the Relay server logs for the matching close code and reason.",
|
||||
)
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
@@ -977,20 +1116,24 @@ class ConnectionManager(
|
||||
t.message,
|
||||
code?.let { "HTTP $it" },
|
||||
).joinToString(": "),
|
||||
url = url,
|
||||
operation = "Relay WebSocket handshake",
|
||||
requestUrl = url,
|
||||
suggestion = code?.let {
|
||||
NetworkDiagnosticGuidance.forHttpStatus(it, "Relay")
|
||||
} ?: NetworkDiagnosticGuidance.forThrowable(t, "Relay"),
|
||||
)
|
||||
lastUpgradeResponseCode = code
|
||||
if (response == null) {
|
||||
// Transport-level failure (no HTTP upgrade response): on a
|
||||
// remote (Tailscale) link the first handshake can fail cold.
|
||||
// Don't evict the only working route from the shared resolver
|
||||
// on a single blip — wait for it to repeat. A genuinely
|
||||
// sustained network loss is handled separately by onLost.
|
||||
consecutiveSocketFailures++
|
||||
if (consecutiveSocketFailures >= MARK_UNREACHABLE_AFTER_FAILURES) {
|
||||
markActiveEndpointUnreachable("socket failure x$consecutiveSocketFailures")
|
||||
// Don't evict this Relay surface on a single blip — wait
|
||||
// for the same socket URL to fail again. Standard route
|
||||
// health is separate and is never poisoned here.
|
||||
val failureCount = reconnectState.recordSocketFailure(url)
|
||||
if (failureCount >= MARK_UNREACHABLE_AFTER_FAILURES) {
|
||||
markActiveRelayEndpointUnreachable("socket failure x$failureCount")
|
||||
} else {
|
||||
Log.i(TAG, "relay socket failure $consecutiveSocketFailures/$MARK_UNREACHABLE_AFTER_FAILURES — not yet poisoning route")
|
||||
Log.i(TAG, "relay socket failure $failureCount/$MARK_UNREACHABLE_AFTER_FAILURES — not yet poisoning route")
|
||||
}
|
||||
}
|
||||
authenticated = false
|
||||
@@ -1029,7 +1172,12 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
val url = serverUrl ?: return
|
||||
reconnectAttempt++
|
||||
val reconnectAttempt = reconnectState.nextReconnectAttempt()
|
||||
// Keep the socket lifecycle visibly in-flight for the whole backoff
|
||||
// window. Callers such as reconnectIfStale() treat Disconnected as an
|
||||
// invitation to call connect() again; leaving this state Disconnected
|
||||
// let screen entry restart both the route resolve and the retry counter.
|
||||
_connectionState.value = ConnectionState.Reconnecting
|
||||
|
||||
// Server-issued 429 means we're IP-banned — keep retrying at our
|
||||
// normal exponential cadence and we'll re-fill the ban bucket on
|
||||
@@ -1040,6 +1188,7 @@ class ConnectionManager(
|
||||
// block window instead of re-filling the ban bucket at our normal
|
||||
// cadence.
|
||||
lastUpgradeResponseCode == 429 -> {
|
||||
rateLimitBackoffUntilMs = SystemClock.elapsedRealtime() + RATE_LIMIT_BACKOFF_MS
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
@@ -1077,13 +1226,16 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
reconnectJob?.cancel()
|
||||
reconnectBackoffWaiting = true
|
||||
val scheduledJob = scope.launch {
|
||||
delay(backoffMs)
|
||||
reconnectBackoffWaiting = false
|
||||
// Re-check the gate after the backoff — by the time the delay
|
||||
// expires, auth state may have changed (e.g., user hit Revoke
|
||||
// during the retry window).
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val resolved = resolveBestEndpointSafe(EndpointSurface.Relay)
|
||||
val targetUrl = resolved?.relay?.url
|
||||
if (resolved != null) {
|
||||
// Mirror scheduleNetworkReResolve: clear the sustained-loss
|
||||
@@ -1092,24 +1244,28 @@ class ConnectionManager(
|
||||
// in onLost's grace job but can be cleared on EITHER success
|
||||
// edge — network-callback or relay-timer.)
|
||||
sustainedLossDeclared = false
|
||||
_activeEndpoint.value = resolved
|
||||
} else if (sustainedLossDeclared || _activeEndpoint.value == null) {
|
||||
// Same hysteresis as scheduleNetworkReResolve: a transient
|
||||
// miss during a relay reconnect must not flip every effective
|
||||
// URL back to the dead saved host. Keep the last-known route;
|
||||
// we fall through to doConnect(url) and retry it with backoff.
|
||||
_activeEndpoint.value = null
|
||||
activeRelayEndpoint = resolved
|
||||
}
|
||||
if (targetUrl != null && normalizeRelayUrl(targetUrl) != url) {
|
||||
Log.i(TAG, "scheduleReconnect: switching $url → ${normalizeRelayUrl(targetUrl)}")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
connectToUrlOnMainPath(
|
||||
targetUrl,
|
||||
preserveReconnectBackoff = true,
|
||||
)
|
||||
} else {
|
||||
doConnect(url)
|
||||
doConnect(url, scheduledReconnect = true)
|
||||
}
|
||||
} else if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate turned false during backoff — aborting retry")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
reconnectJob = scheduledJob
|
||||
scheduledJob.invokeOnCompletion {
|
||||
if (reconnectJob === scheduledJob) {
|
||||
reconnectJob = null
|
||||
reconnectBackoffWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
@@ -59,6 +61,10 @@ class RelayHttpClient(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayHttpClient"
|
||||
const val MAX_MODEL_CAPABILITY_ROWS = 64
|
||||
private const val MAX_MODEL_CAPABILITY_PROVIDER_CHARS = 128
|
||||
private const val MAX_MODEL_CAPABILITY_MODEL_CHARS = 512
|
||||
private const val MAX_MODEL_CAPABILITY_PROFILE_CHARS = 128
|
||||
private val sessionsJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -710,6 +716,37 @@ class RelayHttpClient(
|
||||
@SerialName("age_seconds") val ageSeconds: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilityRequestRow(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilitiesRequest(
|
||||
@SerialName("schema_version") val schemaVersion: Int = 1,
|
||||
val profile: String? = null,
|
||||
val refresh: Boolean = false,
|
||||
val models: List<ModelCapabilityRequestRow>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilityRow(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
val reasoning: Boolean? = null,
|
||||
@SerialName("reasoning_efforts") val reasoningEfforts: List<String> = emptyList(),
|
||||
@SerialName("reasoning_efforts_exact") val reasoningEffortsExact: Boolean = false,
|
||||
val source: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilitiesResponse(
|
||||
@SerialName("schema_version") val schemaVersion: Int = 1,
|
||||
@SerialName("contract_version") val contractVersion: String = "",
|
||||
val capabilities: List<ModelCapabilityRow> = emptyList(),
|
||||
)
|
||||
|
||||
/** Fetch the installed plugin/protocol/profile capability contract. */
|
||||
suspend fun fetchRelayInfo(): Result<RelayInfo?> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
@@ -743,6 +780,64 @@ class RelayHttpClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional provider/model reasoning overlay; 404 and no pairing are fail-soft. */
|
||||
suspend fun fetchModelCapabilities(
|
||||
models: List<ModelCapabilityRequestRow>,
|
||||
profile: String? = null,
|
||||
refresh: Boolean = false,
|
||||
): Result<ModelCapabilitiesResponse?> = withContext(Dispatchers.IO) {
|
||||
val boundedModels = models
|
||||
.asSequence()
|
||||
.map {
|
||||
ModelCapabilityRequestRow(
|
||||
it.provider.trim().take(MAX_MODEL_CAPABILITY_PROVIDER_CHARS),
|
||||
it.model.trim().take(MAX_MODEL_CAPABILITY_MODEL_CHARS),
|
||||
)
|
||||
}
|
||||
.filter { it.provider.isNotEmpty() && it.model.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(MAX_MODEL_CAPABILITY_ROWS)
|
||||
.toList()
|
||||
if (boundedModels.isEmpty()) return@withContext Result.success(null)
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
val token = sessionTokenProvider()
|
||||
if (relayUrl.isEmpty() || token.isNullOrBlank()) return@withContext Result.success(null)
|
||||
val base = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = runCatching { "$base/relay/model-capabilities".toHttpUrl() }.getOrElse {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
val payload = ModelCapabilitiesRequest(
|
||||
profile = profile?.trim()?.take(MAX_MODEL_CAPABILITY_PROFILE_CHARS)
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
refresh = refresh,
|
||||
models = boundedModels,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(sessionsJson.encodeToString(payload).toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
try {
|
||||
okHttpClient.newBuilder().callTimeout(4, java.util.concurrent.TimeUnit.SECONDS).build()
|
||||
.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) return@withContext Result.success(null)
|
||||
if (!response.isSuccessful) return@withContext Result.failure(IOException("HTTP ${response.code}"))
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = body.takeIf { it.isNotBlank() }?.let {
|
||||
sessionsJson.decodeFromString(ModelCapabilitiesResponse.serializer(), it)
|
||||
}
|
||||
if (parsed?.schemaVersion != 1) Result.success(null) else Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchModelCapabilities failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the relay whether a newer plugin release is available — it compares its
|
||||
* installed version against the latest `plugin-v*` GitHub release (cached an
|
||||
@@ -1139,6 +1234,7 @@ class RelayHttpClient(
|
||||
logSuccess: Boolean = true,
|
||||
): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
val trimmed = relayUrl.trim()
|
||||
val operation = "Relay health probe before WebSocket connection"
|
||||
if (trimmed.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalArgumentException("Relay URL is empty")
|
||||
@@ -1159,7 +1255,9 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
|
||||
detail = e.message,
|
||||
url = relayUrl,
|
||||
operation = operation,
|
||||
configuredUrl = relayUrl,
|
||||
suggestion = "Enter a Relay URL beginning with ws:// or wss://.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
@@ -1180,6 +1278,7 @@ class RelayHttpClient(
|
||||
.get()
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val requestUrl = request.url.toString()
|
||||
|
||||
try {
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
@@ -1189,8 +1288,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "HTTP ${response.code}",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forHttpStatus(response.code, "Relay"),
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay responded HTTP ${response.code}")
|
||||
@@ -1203,8 +1305,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Empty response",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Verify this route points to a Hermes-Relay server and inspect its logs.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
@@ -1220,8 +1325,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Non-JSON response",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Verify this route points to a Hermes-Relay server rather than another HTTP service.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
|
||||
@@ -1234,8 +1342,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "status=${status ?: "missing"}",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Check the Relay service health and server logs.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
|
||||
@@ -1248,8 +1359,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Missing version field",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Verify this route points to a current Hermes-Relay server.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
|
||||
@@ -1265,7 +1379,9 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
|
||||
detail = "version=$version clients=$clients sessions=$sessions",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
}
|
||||
@@ -1278,8 +1394,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
|
||||
detail = "No HTTP response in 3s",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(IOException("Relay is not responding (3s timeout)"))
|
||||
} catch (e: java.net.ConnectException) {
|
||||
@@ -1289,8 +1408,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
|
||||
detail = e.message,
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
|
||||
} catch (e: IOException) {
|
||||
@@ -1300,8 +1422,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = e.message ?: "Network error",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
|
||||
} catch (e: Exception) {
|
||||
@@ -1311,8 +1436,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = e.message ?: e.javaClass.simpleName,
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
/**
|
||||
* Route-aware retry state for the Relay WebSocket.
|
||||
*
|
||||
* Automatic LAN/Tailscale fallback keeps the accumulated reconnect attempt so
|
||||
* swapping URLs cannot restart exponential backoff. Socket-failure streaks are
|
||||
* scoped to one URL, so failures on different roles cannot combine and poison
|
||||
* the newly selected route.
|
||||
*/
|
||||
internal class RelayReconnectState {
|
||||
@Volatile
|
||||
var reconnectAttempt: Int = 0
|
||||
private set
|
||||
|
||||
private var socketFailureRoute: String? = null
|
||||
private var consecutiveSocketFailures: Int = 0
|
||||
|
||||
@Synchronized
|
||||
fun beginExplicitConnect(route: String) {
|
||||
reconnectAttempt = 0
|
||||
resetSocketFailures(route)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun beginAutomaticRouteSwap(route: String) {
|
||||
resetSocketFailures(route)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun nextReconnectAttempt(): Int {
|
||||
reconnectAttempt++
|
||||
return reconnectAttempt
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun recordSocketFailure(route: String): Int {
|
||||
if (socketFailureRoute != route) {
|
||||
resetSocketFailures(route)
|
||||
}
|
||||
consecutiveSocketFailures++
|
||||
return consecutiveSocketFailures
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun connected(route: String) {
|
||||
reconnectAttempt = 0
|
||||
resetSocketFailures(route)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
reconnectAttempt = 0
|
||||
resetSocketFailures(null)
|
||||
}
|
||||
|
||||
private fun resetSocketFailures(route: String?) {
|
||||
socketFailureRoute = route
|
||||
consecutiveSocketFailures = 0
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ class RelayVoiceClient(
|
||||
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
|
||||
private val realtimeResumeRetryIntervalMs: Long = REALTIME_RESUME_RETRY_INTERVAL_MS,
|
||||
private val realtimeResumeRetryWindowMs: Long = REALTIME_RESUME_RETRY_WINDOW_MS,
|
||||
private val voiceOutputFirstAudioTimeoutMs: Long = VOICE_OUTPUT_FIRST_AUDIO_TIMEOUT_MS,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -108,6 +109,7 @@ class RelayVoiceClient(
|
||||
private val WAV_AUDIO = "audio/wav".toMediaType()
|
||||
private val OCTET_STREAM = "application/octet-stream".toMediaType()
|
||||
private const val REALTIME_TIMEOUT_MS = 90_000L
|
||||
private const val VOICE_OUTPUT_FIRST_AUDIO_TIMEOUT_MS = 15_000L
|
||||
private const val REALTIME_AGENT_IDLE_TIMEOUT_MS = 90_000L
|
||||
private const val REALTIME_AGENT_MAX_TURN_MS = 5 * 60_000L
|
||||
private const val REALTIME_AGENT_WAIT_SLICE_MS = 1_000L
|
||||
@@ -867,6 +869,7 @@ class RelayVoiceClient(
|
||||
val resumeAttempted = AtomicBoolean(false)
|
||||
val routeProbeRequested = AtomicBoolean(false)
|
||||
val currentSocket = AtomicReference<WebSocket?>()
|
||||
val firstAudioSeen = AtomicBoolean(false)
|
||||
val socketGeneration = AtomicLong(0L)
|
||||
val activeSocketGeneration = AtomicLong(0L)
|
||||
val lastEventId = AtomicLong(0L)
|
||||
@@ -980,6 +983,7 @@ class RelayVoiceClient(
|
||||
}
|
||||
onEvent(event)
|
||||
if (event.isAudioDelta) {
|
||||
firstAudioSeen.set(true)
|
||||
event.audioEventId?.let {
|
||||
lastPlayedAudioEventId.updateAndGet { current -> maxOf(current, it) }
|
||||
}
|
||||
@@ -1123,6 +1127,15 @@ class RelayVoiceClient(
|
||||
onHandoff = onHandoff,
|
||||
completeFailure = ::completeFailure,
|
||||
)
|
||||
val firstAudioWatchdog = launch {
|
||||
delay(voiceOutputFirstAudioTimeoutMs)
|
||||
if (!completed.get() && !firstAudioSeen.get()) {
|
||||
completeFailure(
|
||||
"Voice output produced no audio within ${voiceOutputFirstAudioTimeoutMs}ms",
|
||||
)
|
||||
currentSocket.get()?.cancel()
|
||||
}
|
||||
}
|
||||
try {
|
||||
withTimeout(REALTIME_TIMEOUT_MS) {
|
||||
finished.await()
|
||||
@@ -1132,6 +1145,7 @@ class RelayVoiceClient(
|
||||
socket.close(1001, "timeout")
|
||||
Result.failure(IOException("Voice output timed out", e))
|
||||
} finally {
|
||||
firstAudioWatchdog.cancel()
|
||||
routeWatcher?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
@@ -47,6 +48,16 @@ data class RouteProbeOutcome(
|
||||
val atMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Service whose reachability is being resolved. Standard Hermes surfaces and
|
||||
* Relay are intentionally independent: a healthy Dashboard must not vouch for
|
||||
* a dead Relay listener on the same host.
|
||||
*/
|
||||
enum class EndpointSurface {
|
||||
Standard,
|
||||
Relay,
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the highest-priority **reachable** [EndpointCandidate] from a
|
||||
* per-device list, driven by ADR 24 "Multi-endpoint pairing + network-aware
|
||||
@@ -124,9 +135,14 @@ class EndpointResolver(
|
||||
*/
|
||||
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
|
||||
|
||||
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
|
||||
private fun recordOutcome(
|
||||
candidate: EndpointCandidate,
|
||||
surface: EndpointSurface,
|
||||
reachable: Boolean,
|
||||
detail: String?,
|
||||
) {
|
||||
_probeOutcomes.update { outcomes ->
|
||||
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
|
||||
outcomes + (cacheKey(candidate, surface) to RouteProbeOutcome(
|
||||
reachable = reachable,
|
||||
detail = detail,
|
||||
atMillis = clock(),
|
||||
@@ -167,21 +183,44 @@ class EndpointResolver(
|
||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<primary host>:<port>"`.
|
||||
* Stable cache key for one candidate surface:
|
||||
* `"<surface>|<role>|<surface host>:<port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
* but hostnames are lowercased — two roles pointing at the same
|
||||
* host:port share reachability state.
|
||||
*/
|
||||
internal fun cacheKey(candidate: EndpointCandidate): String =
|
||||
"${candidate.role}|${candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()}"
|
||||
internal fun cacheKey(
|
||||
candidate: EndpointCandidate,
|
||||
surface: EndpointSurface = EndpointSurface.Standard,
|
||||
): String {
|
||||
val authority = when (surface) {
|
||||
EndpointSurface.Standard ->
|
||||
candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()
|
||||
EndpointSurface.Relay ->
|
||||
routeAuthority(candidate.relay?.url).orEmpty()
|
||||
}
|
||||
return "${surface.name.lowercase()}|${candidate.role}|$authority"
|
||||
}
|
||||
|
||||
private fun routeAuthority(rawUrl: String?): String? {
|
||||
val candidate = rawUrl?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val httpUrl = when {
|
||||
candidate.startsWith("ws://", ignoreCase = true) ->
|
||||
"http://${candidate.substringAfter("://")}"
|
||||
candidate.startsWith("wss://", ignoreCase = true) ->
|
||||
"https://${candidate.substringAfter("://")}"
|
||||
else -> candidate
|
||||
}
|
||||
return httpUrl.toHttpUrlOrNull()?.let { url -> "${url.host}:${url.port}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the resolver against [candidates].
|
||||
*
|
||||
* 1. Group by `priority` ascending.
|
||||
* 2. For each priority group, race a HEAD /health probe against every
|
||||
* candidate in the group (2 s per candidate). First 2xx wins; ties
|
||||
* 2. For each priority group, race the selected surface's health probe
|
||||
* against every candidate in the group. First 2xx wins; ties
|
||||
* broken by whichever response lands first.
|
||||
* 3. If the entire group is unreachable, fall through to the next
|
||||
* priority group.
|
||||
@@ -193,37 +232,42 @@ class EndpointResolver(
|
||||
* its tier). An empty [candidates] list returns null immediately without
|
||||
* touching the network.
|
||||
*/
|
||||
suspend fun resolve(candidates: List<EndpointCandidate>): EndpointCandidate? {
|
||||
if (candidates.isEmpty()) return null
|
||||
suspend fun resolve(
|
||||
candidates: List<EndpointCandidate>,
|
||||
surface: EndpointSurface = EndpointSurface.Standard,
|
||||
): EndpointCandidate? {
|
||||
val eligible = candidates.filter { probeTarget(it, surface) != null }
|
||||
if (eligible.isEmpty()) return null
|
||||
|
||||
// Strict priority: sort ascending so priority-0 lands first. Grouping
|
||||
// preserves emitted order within a priority class (DNS SRV parity).
|
||||
val groups = candidates.groupBy { it.priority }.toSortedMap()
|
||||
val groups = eligible.groupBy { it.priority }.toSortedMap()
|
||||
|
||||
for ((priority, group) in groups) {
|
||||
Log.d(TAG, "probing priority=$priority group (size=${group.size})")
|
||||
val winner = raceGroup(group)
|
||||
val winner = raceGroup(group, surface)
|
||||
if (winner != null) {
|
||||
val winnerUrl = probeTarget(winner, surface)?.baseUrl
|
||||
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
||||
"route=${winner.primaryRouteUrl()} priority=$priority")
|
||||
"surface=$surface route=$winnerUrl priority=$priority")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
|
||||
detail = "priority=$priority",
|
||||
endpointRole = winner.role,
|
||||
url = winner.primaryRouteUrl(),
|
||||
url = winnerUrl,
|
||||
)
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "resolve: no reachable candidate across ${candidates.size} record(s)")
|
||||
Log.w(TAG, "resolve: no reachable $surface candidate across ${eligible.size} record(s)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_no_reachable) ?: "No reachable endpoint",
|
||||
detail = "${candidates.size} configured route(s) failed health probes",
|
||||
detail = "${eligible.size} configured $surface route(s) failed health probes",
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -237,17 +281,20 @@ class EndpointResolver(
|
||||
* for "first 2xx wins" so latency matters. The losing probes' results
|
||||
* still land in the cache, though, so the next call benefits.
|
||||
*/
|
||||
private suspend fun raceGroup(group: List<EndpointCandidate>): EndpointCandidate? {
|
||||
private suspend fun raceGroup(
|
||||
group: List<EndpointCandidate>,
|
||||
surface: EndpointSurface,
|
||||
): EndpointCandidate? {
|
||||
if (group.isEmpty()) return null
|
||||
if (group.size == 1) {
|
||||
val only = group.first()
|
||||
return if (isReachable(only)) only else null
|
||||
return if (isReachable(only, surface)) only else null
|
||||
}
|
||||
|
||||
// Fast-path: any cached-reachable candidate wins immediately without
|
||||
// touching the network.
|
||||
for (candidate in group) {
|
||||
val cached = probeCache[cacheKey(candidate)]
|
||||
val cached = probeCache[cacheKey(candidate, surface)]
|
||||
if (cached != null && cached.expiresAt > clock() && cached.reachable) {
|
||||
return candidate
|
||||
}
|
||||
@@ -256,7 +303,7 @@ class EndpointResolver(
|
||||
return coroutineScope {
|
||||
val deferred = group.map { candidate ->
|
||||
async(Dispatchers.IO) {
|
||||
if (isReachable(candidate)) candidate else null
|
||||
if (isReachable(candidate, surface)) candidate else null
|
||||
}
|
||||
}
|
||||
// Collect results in arrival order: iterate through awaitAll +
|
||||
@@ -276,8 +323,11 @@ class EndpointResolver(
|
||||
* [probeCache] first; on miss or expiry, runs a HEAD /health probe and
|
||||
* records the result.
|
||||
*/
|
||||
private suspend fun isReachable(candidate: EndpointCandidate): Boolean {
|
||||
val key = cacheKey(candidate)
|
||||
private suspend fun isReachable(
|
||||
candidate: EndpointCandidate,
|
||||
surface: EndpointSurface,
|
||||
): Boolean {
|
||||
val key = cacheKey(candidate, surface)
|
||||
val now = clock()
|
||||
val cached = probeCache[key]
|
||||
if (cached != null && cached.expiresAt > now) {
|
||||
@@ -285,7 +335,7 @@ class EndpointResolver(
|
||||
return cached.reachable
|
||||
}
|
||||
|
||||
val reachable = probe(candidate)
|
||||
val reachable = probe(candidate, surface)
|
||||
val ttl = if (reachable) CACHE_TTL_MS else NEGATIVE_CACHE_TTL_MS
|
||||
probeCache[key] = CacheEntry(expiresAt = now + ttl, reachable = reachable)
|
||||
return reachable
|
||||
@@ -299,9 +349,16 @@ class EndpointResolver(
|
||||
* Returns false on any failure (timeout, I/O, non-2xx, invalid URL).
|
||||
* We never raise: a bad record shouldn't crash the connect loop.
|
||||
*/
|
||||
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
||||
private suspend fun probe(
|
||||
candidate: EndpointCandidate,
|
||||
surface: EndpointSurface,
|
||||
): Boolean {
|
||||
val startedAtMs = clock()
|
||||
val target = probeTarget(candidate)
|
||||
val operation = when (surface) {
|
||||
EndpointSurface.Standard -> "Dashboard or API route health probe"
|
||||
EndpointSurface.Relay -> "Relay route health probe"
|
||||
}
|
||||
val target = probeTarget(candidate, surface)
|
||||
val url = target?.requestUrl?.toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
@@ -310,10 +367,12 @@ class EndpointResolver(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
|
||||
detail = "No valid Dashboard, API, or Relay URL",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.primaryRouteUrl(),
|
||||
configuredUrl = candidate.primaryRouteUrl(),
|
||||
suggestion = "Edit or re-pair this route so it contains a valid service URL.",
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid route URL")
|
||||
recordOutcome(candidate, surface, reachable = false, detail = "Invalid route URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
@@ -345,12 +404,20 @@ class EndpointResolver(
|
||||
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
|
||||
title = probeTitle,
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = if (ok) {
|
||||
null
|
||||
} else {
|
||||
NetworkDiagnosticGuidance.forHttpStatus(resp.code, surface.diagnosticTarget())
|
||||
},
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
surface,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
|
||||
)
|
||||
@@ -362,11 +429,14 @@ class EndpointResolver(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
@@ -375,11 +445,14 @@ class EndpointResolver(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
@@ -388,19 +461,28 @@ class EndpointResolver(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
detail = humanProbeFailure(e),
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, surface.diagnosticTarget()),
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
||||
recordOutcome(candidate, surface, reachable = false, detail = humanProbeFailure(e))
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Choose the standard Dashboard/Gateway surface first when advertised. */
|
||||
private fun probeTarget(candidate: EndpointCandidate): ProbeTarget? {
|
||||
private fun probeTarget(
|
||||
candidate: EndpointCandidate,
|
||||
surface: EndpointSurface,
|
||||
): ProbeTarget? {
|
||||
if (surface == EndpointSurface.Relay) {
|
||||
return relayProbeTarget(candidate)
|
||||
}
|
||||
candidate.dashboard?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
@@ -421,6 +503,10 @@ class EndpointResolver(
|
||||
)
|
||||
}
|
||||
|
||||
return relayProbeTarget(candidate)
|
||||
}
|
||||
|
||||
private fun relayProbeTarget(candidate: EndpointCandidate): ProbeTarget? {
|
||||
candidate.relay?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
@@ -458,6 +544,11 @@ class EndpointResolver(
|
||||
else -> e.javaClass.simpleName
|
||||
}
|
||||
|
||||
private fun EndpointSurface.diagnosticTarget(): String = when (this) {
|
||||
EndpointSurface.Standard -> "Dashboard or API server"
|
||||
EndpointSurface.Relay -> "Relay"
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
@@ -467,13 +558,21 @@ class EndpointResolver(
|
||||
* transition can skip the known-dead active route without suppressing a
|
||||
* valid fallback for the whole positive cache window.
|
||||
*/
|
||||
fun markUnreachable(candidate: EndpointCandidate) {
|
||||
val key = cacheKey(candidate)
|
||||
fun markUnreachable(
|
||||
candidate: EndpointCandidate,
|
||||
surface: EndpointSurface = EndpointSurface.Standard,
|
||||
) {
|
||||
val key = cacheKey(candidate, surface)
|
||||
probeCache[key] = CacheEntry(
|
||||
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
|
||||
recordOutcome(
|
||||
candidate,
|
||||
surface,
|
||||
reachable = false,
|
||||
detail = "Network changed — assumed offline",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+29
-7
@@ -65,13 +65,14 @@ object ProfileApiUrlResolver {
|
||||
val dedicated = resolveForConnection(profileApiUrl, base)
|
||||
if (dedicated != null) return dedicated
|
||||
|
||||
val profile = selectedProfileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
|
||||
?: return base
|
||||
val isKnownMultiplexProfile = gatewayMode.equals("multiplex", ignoreCase = true) &&
|
||||
servedProfiles.any { it.equals(profile, ignoreCase = false) }
|
||||
if (!isKnownMultiplexProfile) return base
|
||||
val profile = selectedProfileName?.trim() ?: return base
|
||||
if (!usesMultiplexProfileKey(
|
||||
profileApiUrl = profileApiUrl,
|
||||
selectedProfileName = profile,
|
||||
gatewayMode = gatewayMode,
|
||||
servedProfiles = servedProfiles,
|
||||
)
|
||||
) return base
|
||||
|
||||
val root = base?.toHttpUrlOrNull() ?: return base
|
||||
return root.newBuilder()
|
||||
@@ -82,6 +83,27 @@ object ProfileApiUrlResolver {
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile-specific credential is required only for the positively
|
||||
* identified shared `/p/<profile>` mirror. Dedicated profile APIs retain
|
||||
* the connection credential contract, while default/legacy/unknown routes
|
||||
* stay on the root client.
|
||||
*/
|
||||
fun usesMultiplexProfileKey(
|
||||
profileApiUrl: String?,
|
||||
selectedProfileName: String?,
|
||||
gatewayMode: String?,
|
||||
servedProfiles: Collection<String>,
|
||||
): Boolean {
|
||||
if (normalize(profileApiUrl) != null) return false
|
||||
val profile = selectedProfileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
|
||||
?: return false
|
||||
return gatewayMode.equals("multiplex", ignoreCase = true) &&
|
||||
servedProfiles.any { it == profile }
|
||||
}
|
||||
|
||||
private fun isLocalBindHost(host: String): Boolean {
|
||||
return when (host.lowercase().trim('[', ']')) {
|
||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
|
||||
|
||||
@@ -218,8 +218,14 @@ class ChatHandler {
|
||||
*/
|
||||
private val activeAnnotationTools = mutableMapOf<String, String>()
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
// All Chat and Voice transcript publications pass through this invariant
|
||||
// boundary. A caller may address a row by its mutable server/domain id or
|
||||
// its retained client uiKey, but Compose must never observe both aliases.
|
||||
private val _messages = RenderedMessageState(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.flow
|
||||
|
||||
private fun ChatMessage.matchesIdentity(reference: String): Boolean =
|
||||
id == reference || uiKey == reference
|
||||
|
||||
/**
|
||||
* Latest gateway `status.update` lifecycle line for the in-flight turn
|
||||
@@ -320,47 +326,54 @@ class ChatHandler {
|
||||
fun boolField(name: String): Boolean? = (payload[name] as? JsonPrimitive)?.booleanOrNull
|
||||
val toolName = textField("tool_name", "tool", "name") ?: "unknown"
|
||||
val callId = textField("call_id", "tool_call_id") ?: toolName
|
||||
// The caller owns one client placeholder id for the whole Relay stream.
|
||||
// message.started can replace that domain id with the server id while
|
||||
// uiKey deliberately retains the client id. Resolve the current domain
|
||||
// id before every event so the tail keeps mutating the same visible row.
|
||||
val currentMessageId = _messages.value.findLast {
|
||||
it.matchesIdentity(messageId)
|
||||
}?.id ?: messageId
|
||||
|
||||
when (envelope.event) {
|
||||
"message.started" -> {
|
||||
val msgObj = payload["message"] as? JsonObject
|
||||
val serverMsgId = (msgObj?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
if (!serverMsgId.isNullOrBlank()) replaceMessageId(messageId, serverMsgId)
|
||||
if (!serverMsgId.isNullOrBlank()) replaceMessageId(currentMessageId, serverMsgId)
|
||||
}
|
||||
"assistant.delta" -> {
|
||||
textField("delta", "content", "text")?.let { onTextDelta(messageId, it) }
|
||||
textField("delta", "content", "text")?.let { onTextDelta(currentMessageId, it) }
|
||||
}
|
||||
"tool.progress" -> {
|
||||
textField("delta", "thinking_delta", "thinking", "text", "message")?.let {
|
||||
onThinkingDelta(messageId, it)
|
||||
onThinkingDelta(currentMessageId, it)
|
||||
}
|
||||
}
|
||||
"tool.pending", "tool.started" -> onToolCallStart(messageId, callId, toolName)
|
||||
"tool.completed" -> onToolCallComplete(messageId, callId, textField("result_preview", "summary", "message"))
|
||||
"tool.failed" -> onToolCallFailed(messageId, callId, textField("error", "message") ?: "Tool failed")
|
||||
"tool.pending", "tool.started" -> onToolCallStart(currentMessageId, callId, toolName)
|
||||
"tool.completed" -> onToolCallComplete(currentMessageId, callId, textField("result_preview", "summary", "message"))
|
||||
"tool.failed" -> onToolCallFailed(currentMessageId, callId, textField("error", "message") ?: "Tool failed")
|
||||
"memory.updated", "skill.loaded" -> {
|
||||
val label = when (envelope.event) {
|
||||
"memory.updated" -> "Memory"
|
||||
else -> "Skill"
|
||||
}
|
||||
addMessageBadges(messageId, listOf(label))
|
||||
addMessageBadges(currentMessageId, listOf(label))
|
||||
}
|
||||
"artifact.created" -> {
|
||||
addMessageBadges(messageId, listOf("Artifact"))
|
||||
addMessageBadges(currentMessageId, listOf("Artifact"))
|
||||
textField("url", "path", "preview", "title")?.takeIf { it.isNotBlank() }?.let {
|
||||
onThinkingDelta(messageId, "Artifact: $it")
|
||||
onThinkingDelta(currentMessageId, "Artifact: $it")
|
||||
}
|
||||
}
|
||||
"assistant.completed" -> {
|
||||
if (boolField("interrupted") == true) {
|
||||
onStreamError("Response interrupted")
|
||||
} else {
|
||||
onTurnComplete(messageId)
|
||||
onTurnComplete(currentMessageId)
|
||||
}
|
||||
}
|
||||
"run.completed", "done" -> onStreamComplete(messageId)
|
||||
"run.completed", "done" -> onStreamComplete(currentMessageId)
|
||||
"error" -> {
|
||||
addMessageBadges(messageId, listOf("Error"))
|
||||
addMessageBadges(currentMessageId, listOf("Error"))
|
||||
onStreamError(textField("message", "error") ?: "Unknown error")
|
||||
}
|
||||
"session.created", "run.started" -> Unit
|
||||
@@ -372,6 +385,7 @@ class ChatHandler {
|
||||
|
||||
fun addUserMessage(message: ChatMessage) {
|
||||
_messages.update { list ->
|
||||
if (list.any { it.uiKey == message.uiKey }) return@update list
|
||||
(list + message).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
@@ -384,7 +398,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun updateDeliveryStatus(messageId: String, status: MessageDeliveryStatus) {
|
||||
_messages.update { list ->
|
||||
list.map { if (it.id == messageId) it.copy(deliveryStatus = status) else it }
|
||||
list.map { if (it.matchesIdentity(messageId)) it.copy(deliveryStatus = status) else it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +406,7 @@ class ChatHandler {
|
||||
fun setBackgroundTask(messageId: String, task: BackgroundTaskState) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId) message.copy(backgroundTask = task) else message
|
||||
if (message.matchesIdentity(messageId)) message.copy(backgroundTask = task) else message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,7 +418,7 @@ class ChatHandler {
|
||||
) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId && message.backgroundTask != null) {
|
||||
if (message.matchesIdentity(messageId) && message.backgroundTask != null) {
|
||||
message.copy(backgroundTask = transform(message.backgroundTask))
|
||||
} else {
|
||||
message
|
||||
@@ -485,7 +499,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun appendAskCardMessage(messageId: String, card: HermesCard) {
|
||||
_messages.update { list ->
|
||||
if (list.any { it.id == messageId }) return@update list
|
||||
if (list.any { it.matchesIdentity(messageId) }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -507,7 +521,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun truncateMessagesFrom(messageId: String) {
|
||||
_messages.update { list ->
|
||||
val idx = list.indexOfFirst { it.id == messageId }
|
||||
val idx = list.indexOfFirst { it.matchesIdentity(messageId) }
|
||||
if (idx < 0) list else list.take(idx)
|
||||
}
|
||||
}
|
||||
@@ -515,7 +529,7 @@ class ChatHandler {
|
||||
fun replaceMessageContent(messageId: String, content: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { message ->
|
||||
if (message.id == messageId) {
|
||||
if (message.matchesIdentity(messageId)) {
|
||||
message.copy(content = content)
|
||||
} else {
|
||||
message
|
||||
@@ -535,8 +549,9 @@ class ChatHandler {
|
||||
content: String,
|
||||
) {
|
||||
_messages.update { messages ->
|
||||
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||
val interim = messages.firstOrNull { it.matchesIdentity(interimMessageId) }
|
||||
?: return@update messages
|
||||
val current = messages.firstOrNull { it.matchesIdentity(currentMessageId) }
|
||||
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||
val merged = interim.copy(
|
||||
@@ -555,14 +570,18 @@ class ChatHandler {
|
||||
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||
)
|
||||
messages
|
||||
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||
.map { if (it.id == interimMessageId) merged else it }
|
||||
.filterNot {
|
||||
current != null &&
|
||||
current.uiKey != interim.uiKey &&
|
||||
it.matchesIdentity(currentMessageId)
|
||||
}
|
||||
.map { if (it.matchesIdentity(interimMessageId)) merged else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a provisional client-side message that never became a real turn. */
|
||||
fun removeMessage(messageId: String) {
|
||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||
_messages.update { messages -> messages.filterNot { it.matchesIdentity(messageId) } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -744,7 +763,7 @@ class ChatHandler {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
changed = true
|
||||
// A realtimeTurn trace is attached ONLY for provider-only
|
||||
// (non-Hermes-backed) turns — Hermes-backed ones leave it
|
||||
@@ -942,6 +961,7 @@ class ChatHandler {
|
||||
fun addPlaceholderMessage(message: ChatMessage) {
|
||||
_isStreaming.value = true
|
||||
_messages.update { list ->
|
||||
if (list.any { it.uiKey == message.uiKey }) return@update list
|
||||
(list + message).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
@@ -957,11 +977,17 @@ class ChatHandler {
|
||||
fun restoreInFlightTurn(
|
||||
checkpoint: ChatTurnCheckpoint,
|
||||
upstreamAssistantText: String? = null,
|
||||
corrections: List<String> = emptyList(),
|
||||
) {
|
||||
val user = checkpoint.user
|
||||
val assistant = checkpoint.assistant
|
||||
val upstreamText = upstreamAssistantText.orEmpty()
|
||||
val currentAssistant = _messages.value.lastOrNull { it.id == assistant.id }
|
||||
// A history poll may already have adopted the server id while the
|
||||
// checkpoint still names the original client identity. They are one
|
||||
// logical row, resolved through either side of that alias.
|
||||
val currentAssistant = _messages.value.lastOrNull {
|
||||
it.matchesIdentity(assistant.id) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
val restoredContent = listOf(
|
||||
assistant.content,
|
||||
upstreamText,
|
||||
@@ -1043,7 +1069,8 @@ class ChatHandler {
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: checkpointMoaReferences
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
id = currentAssistant?.id ?: assistant.id,
|
||||
uiKey = currentAssistant?.uiKey ?: assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = restoredContent,
|
||||
timestamp = assistant.timestamp,
|
||||
@@ -1070,10 +1097,12 @@ class ChatHandler {
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
_messages.update { current ->
|
||||
val withoutOldAssistant = current.filterNot { it.id == assistant.id }
|
||||
val withoutOldAssistant = current.filterNot {
|
||||
it.matchesIdentity(assistant.id) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
val users = withoutOldAssistant.filter { it.role == MessageRole.USER }
|
||||
val positionalUser = users.getOrNull(checkpoint.priorUserMessageCount)
|
||||
val hasUser = withoutOldAssistant.any { it.id == user.id } ||
|
||||
val hasUser = withoutOldAssistant.any { it.matchesIdentity(user.id) } ||
|
||||
positionalUser?.content?.trim() == user.content.trim()
|
||||
val withUser = if (hasUser) {
|
||||
withoutOldAssistant
|
||||
@@ -1085,13 +1114,53 @@ class ChatHandler {
|
||||
timestamp = user.timestamp,
|
||||
)
|
||||
}
|
||||
val insertBeforeAsk = withUser.indexOfFirst {
|
||||
// Newer gateways retain the original prompt in inflight.user and
|
||||
// expose every accepted active-turn redirect separately. Restore
|
||||
// those corrections as ordinary user bubbles before the assistant
|
||||
// row. Consume matching already-present rows first so repeated
|
||||
// resume/checkpoint passes cannot duplicate them.
|
||||
val originalUserIndex = withUser.indexOfFirst { it.matchesIdentity(user.id) }
|
||||
.takeIf { it >= 0 }
|
||||
?: withUser.indexOfFirst {
|
||||
it.role == MessageRole.USER && it.content.trim() == user.content.trim()
|
||||
}
|
||||
val existingAfterOriginal = withUser
|
||||
.drop((originalUserIndex + 1).coerceAtLeast(0))
|
||||
.filter { it.role == MessageRole.USER }
|
||||
.map { it.content.trim() }
|
||||
.toMutableList()
|
||||
val restoredCorrections = corrections
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.mapIndexedNotNull { index, correction ->
|
||||
val existingIndex = existingAfterOriginal.indexOf(correction)
|
||||
if (existingIndex >= 0) {
|
||||
existingAfterOriginal.removeAt(existingIndex)
|
||||
null
|
||||
} else {
|
||||
ChatMessage(
|
||||
id = "${user.id}-correction-${index + 1}",
|
||||
role = MessageRole.USER,
|
||||
content = correction,
|
||||
timestamp = user.timestamp + index + 1L,
|
||||
)
|
||||
}
|
||||
}
|
||||
val firstAskIndex = withUser.indexOfFirst {
|
||||
it.clientOnly && it.id.startsWith("ask-")
|
||||
}
|
||||
val withCorrections = if (firstAskIndex >= 0) {
|
||||
withUser.toMutableList().apply { addAll(firstAskIndex, restoredCorrections) }
|
||||
} else {
|
||||
withUser + restoredCorrections
|
||||
}
|
||||
val insertBeforeAsk = withCorrections.indexOfFirst {
|
||||
it.clientOnly && it.id.startsWith("ask-")
|
||||
}
|
||||
val restored = if (insertBeforeAsk >= 0) {
|
||||
withUser.toMutableList().apply { add(insertBeforeAsk, restoredAssistant) }
|
||||
withCorrections.toMutableList().apply { add(insertBeforeAsk, restoredAssistant) }
|
||||
} else {
|
||||
withUser + restoredAssistant
|
||||
withCorrections + restoredAssistant
|
||||
}
|
||||
restored.let { list ->
|
||||
if (list.size > MAX_MESSAGES) list.drop(list.size - MAX_MESSAGES) else list
|
||||
@@ -1163,7 +1232,7 @@ class ChatHandler {
|
||||
fun replaceMessageId(oldId: String, newId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == oldId && msg.content.isBlank() && msg.isStreaming) {
|
||||
if (msg.matchesIdentity(oldId) && msg.content.isBlank() && msg.isStreaming) {
|
||||
msg.copy(id = newId)
|
||||
} else msg
|
||||
}
|
||||
@@ -1186,7 +1255,7 @@ class ChatHandler {
|
||||
fun mutateMessage(messageId: String, transform: (ChatMessage) -> ChatMessage) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) transform(msg) else msg
|
||||
if (msg.matchesIdentity(messageId)) transform(msg) else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1974,12 +2043,12 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(content = msg.content + processedDelta)
|
||||
} else {
|
||||
msg
|
||||
@@ -2212,7 +2281,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(cards = msg.cards + card)
|
||||
} else msg
|
||||
}
|
||||
@@ -2238,7 +2307,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
@@ -2305,7 +2374,7 @@ class ChatHandler {
|
||||
private fun stripLineFromContent(messageId: String, line: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
// Remove the line (with surrounding newlines) from content
|
||||
val cleaned = msg.content
|
||||
.replace("\n$line\n", "\n")
|
||||
@@ -2442,7 +2511,7 @@ class ChatHandler {
|
||||
// that raced with stripLineFromContent during streaming.
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
@@ -2496,7 +2565,7 @@ class ChatHandler {
|
||||
private fun finalizeAnnotations(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
|
||||
val existingToolNames = msg.toolCalls.map { it.name }.toSet()
|
||||
val newToolCalls = mutableListOf<ToolCall>()
|
||||
@@ -2579,7 +2648,7 @@ class ChatHandler {
|
||||
.take(4)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = cleaned)
|
||||
} else {
|
||||
msg
|
||||
@@ -2595,7 +2664,7 @@ class ChatHandler {
|
||||
if (cleaned.isEmpty()) return
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = (msg.badges + cleaned).distinct().take(4))
|
||||
} else {
|
||||
msg
|
||||
@@ -2629,11 +2698,11 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(toolCalls = msg.toolCalls + placeholder)
|
||||
} else {
|
||||
msg
|
||||
@@ -2664,7 +2733,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
// Adopt a pending "preparing" placeholder for this name
|
||||
@@ -2678,7 +2747,7 @@ class ChatHandler {
|
||||
.indexOfFirst { it.isGenerating && !it.isComplete && it.name.isEmpty() }
|
||||
.takeIf { it >= 0 }
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
if (genIdx != null) {
|
||||
val calls = msg.toolCalls.toMutableList()
|
||||
calls[genIdx] = calls[genIdx].copy(
|
||||
@@ -2736,7 +2805,7 @@ class ChatHandler {
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
@@ -2811,11 +2880,11 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -2850,7 +2919,7 @@ class ChatHandler {
|
||||
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val hasLaneCalls = msg.toolCalls.any { it.taskIndex == event.taskIndex }
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
@@ -2901,14 +2970,14 @@ class ChatHandler {
|
||||
// Snapshot the matching tool call's name BEFORE mutating — we need it
|
||||
// to decide whether to emit a phone-action result bubble below.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
.firstOrNull { it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -2945,14 +3014,14 @@ class ChatHandler {
|
||||
// Snapshot tool name before the update so the phone-action bubble
|
||||
// can label the failure correctly.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
.firstOrNull { it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -2985,7 +3054,7 @@ class ChatHandler {
|
||||
fun onToolOutputRisk(messageId: String, outputRisk: GatewayToolOutputRisk) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == outputRisk.toolCallId) {
|
||||
call.copy(
|
||||
@@ -3016,7 +3085,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
@@ -3045,7 +3114,7 @@ class ChatHandler {
|
||||
fun markStopped(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && "Stopped" !in msg.badges) {
|
||||
if (msg.matchesIdentity(messageId) && "Stopped" !in msg.badges) {
|
||||
msg.copy(badges = msg.badges + "Stopped")
|
||||
} else {
|
||||
msg
|
||||
@@ -3072,7 +3141,7 @@ class ChatHandler {
|
||||
fun markError(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && "Error" !in msg.badges) {
|
||||
if (msg.matchesIdentity(messageId) && "Error" !in msg.badges) {
|
||||
msg.copy(badges = msg.badges + "Error", clientOnly = true)
|
||||
} else {
|
||||
msg
|
||||
@@ -3098,7 +3167,7 @@ class ChatHandler {
|
||||
_messages.update { messages ->
|
||||
messages
|
||||
.filterNot { msg ->
|
||||
msg.id == messageId &&
|
||||
msg.matchesIdentity(messageId) &&
|
||||
msg.role == MessageRole.ASSISTANT &&
|
||||
msg.toolCalls.isEmpty() &&
|
||||
msg.backgroundTask == null &&
|
||||
@@ -3106,7 +3175,7 @@ class ChatHandler {
|
||||
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
|
||||
}
|
||||
.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
if (msg.matchesIdentity(messageId) || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
@@ -3170,10 +3239,12 @@ class ChatHandler {
|
||||
fun onThinkingDelta(messageId: String, delta: String) {
|
||||
_isStreaming.value = true
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
val existing = messages.findLast {
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(
|
||||
thinkingContent = msg.thinkingContent + delta,
|
||||
isThinkingStreaming = true
|
||||
@@ -3198,7 +3269,7 @@ class ChatHandler {
|
||||
fun onUsageReceived(messageId: String, inputTokens: Int?, outputTokens: Int?, totalTokens: Int?, cost: Double?) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
@@ -3231,7 +3302,7 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
val alreadyDispatched = msg.cardDispatches.any {
|
||||
it.cardKey == cardKey && it.actionValue == actionValue
|
||||
}
|
||||
|
||||
+41
-18
@@ -365,8 +365,10 @@ class DashboardApiClient(
|
||||
* `available=false` with an empty list when the server has no API key
|
||||
* configured; the API key itself never leaves the server.
|
||||
*/
|
||||
suspend fun getElevenLabsVoices(): Result<ElevenLabsVoices> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/audio/elevenlabs/voices").mapCatching { parseElevenLabsVoices(it) }
|
||||
suspend fun getElevenLabsVoices(profile: String? = null): Result<ElevenLabsVoices> =
|
||||
withContext(Dispatchers.IO) {
|
||||
getJson("/api/audio/elevenlabs/voices${profileQuery(profile)}")
|
||||
.mapCatching { parseElevenLabsVoices(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,25 +720,36 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun listSessions(
|
||||
profile: String? = null,
|
||||
limit: Int = 200,
|
||||
limit: Int = SESSION_LIST_WINDOW_LIMIT,
|
||||
archived: String? = null,
|
||||
): Result<List<SessionItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val query = buildList {
|
||||
add("limit=${limit.coerceIn(1, 200)}")
|
||||
add("order=recent")
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
// Upstream `archived` filter: exclude (default) | only | include.
|
||||
// Omitted unless requested so older hosts see an unchanged request.
|
||||
val archivedMode = archived?.trim().orEmpty()
|
||||
if (archivedMode.isNotBlank()) add("archived=${pathSegment(archivedMode)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
|
||||
val sessions = linkedMapOf<String, SessionItem>()
|
||||
for (page in sessionListPages(limit)) {
|
||||
val query = buildList {
|
||||
// Upstream dashboard GET /api/sessions rejects pages over 100.
|
||||
// Keep Android's 200-row drawer window via two bounded pages.
|
||||
add("limit=${page.limit}")
|
||||
add("offset=${page.offset}")
|
||||
add("order=recent")
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
// Upstream `archived` filter: exclude (default) | only | include.
|
||||
// Omitted unless requested so older hosts see an unchanged request.
|
||||
val archivedMode = archived?.trim().orEmpty()
|
||||
if (archivedMode.isNotBlank()) add("archived=${pathSegment(archivedMode)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
val pageResult = getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
|
||||
}
|
||||
if (pageResult.isFailure) return@withContext pageResult
|
||||
val pageSessions = pageResult.getOrThrow()
|
||||
pageSessions.forEach { sessions.putIfAbsent(it.id, it) }
|
||||
if (pageSessions.size < page.limit) break
|
||||
}
|
||||
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1058,7 +1071,12 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
fun gatewayWebSocketUrl(baseUrl: String, ticket: String, path: String = "/api/ws"): String? {
|
||||
fun gatewayWebSocketUrl(
|
||||
baseUrl: String,
|
||||
ticket: String,
|
||||
path: String = "/api/ws",
|
||||
profile: String? = null,
|
||||
): String? {
|
||||
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return null
|
||||
val websocketPrefix = when (httpUrl.scheme) {
|
||||
"https" -> "wss://"
|
||||
@@ -1074,6 +1092,11 @@ class DashboardApiClient(
|
||||
val url = httpUrl.newBuilder()
|
||||
.encodedPath(encodedPath)
|
||||
.addQueryParameter("ticket", ticket)
|
||||
.apply {
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
addQueryParameter("profile", it)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
.toString()
|
||||
return websocketPrefix + url.substringAfter("://")
|
||||
|
||||
+165
-19
@@ -31,6 +31,7 @@ import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -259,6 +260,9 @@ class GatewayChatClient(
|
||||
private val _serverProvider = MutableStateFlow<String?>(null)
|
||||
val serverProvider: StateFlow<String?> = _serverProvider.asStateFlow()
|
||||
|
||||
private val _serverModelIdentity = MutableStateFlow<GatewayModelIdentity?>(null)
|
||||
val serverModelIdentity: StateFlow<GatewayModelIdentity?> = _serverModelIdentity.asStateFlow()
|
||||
|
||||
/**
|
||||
* Active reasoning EFFORT from `session.info` (string; "" when reasoning is
|
||||
* disabled). The reasoning DISPLAY mode is NOT on session.info — it stays a
|
||||
@@ -268,6 +272,10 @@ class GatewayChatClient(
|
||||
private val _serverReasoningEffort = MutableStateFlow<String?>(null)
|
||||
val serverReasoningEffort: StateFlow<String?> = _serverReasoningEffort.asStateFlow()
|
||||
|
||||
private val _serverReasoningIdentity = MutableStateFlow<GatewayReasoningIdentity?>(null)
|
||||
val serverReasoningIdentity: StateFlow<GatewayReasoningIdentity?> =
|
||||
_serverReasoningIdentity.asStateFlow()
|
||||
|
||||
/**
|
||||
* Server-reported credential warning (upstream `session.info.credential_warning`)
|
||||
* — present ONLY when the active provider's key is missing/invalid, absent
|
||||
@@ -501,6 +509,10 @@ class GatewayChatClient(
|
||||
* into the session's USER messages (counted from the first user
|
||||
* message). The server drops that message and everything after it
|
||||
* before running [text] as a fresh turn.
|
||||
* @param queuedFollowUp true only when Android is draining a prompt the
|
||||
* user explicitly queued behind an active turn. Newer gateways use the
|
||||
* additive `queued:true` marker to preserve run-after semantics while
|
||||
* the previous turn is still settling; older gateways ignore it.
|
||||
* @param onPreflightFailure invoked INSTEAD of starting the turn when the
|
||||
* gateway could not be reached / authenticated / the prompt could not
|
||||
* be submitted — i.e. nothing started server-side, so the caller can
|
||||
@@ -514,6 +526,7 @@ class GatewayChatClient(
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
attachments: List<GatewayAttachment> = emptyList(),
|
||||
truncateBeforeUserOrdinal: Int? = null,
|
||||
queuedFollowUp: Boolean = false,
|
||||
onPreflightFailure: (reason: String) -> Unit,
|
||||
): ActiveTurnHandle {
|
||||
val turn = GatewayTurn(dispatchOn(callbacks))
|
||||
@@ -550,6 +563,7 @@ class GatewayChatClient(
|
||||
put("session_id", liveSessionId ?: error("no live session"))
|
||||
put("text", text)
|
||||
truncateBeforeUserOrdinal?.let { put("truncate_before_user_ordinal", it) }
|
||||
if (queuedFollowUp) put("queued", true)
|
||||
},
|
||||
// Long-running RPC, not a generic 15s ack — see the
|
||||
// constant's doc. The idle watchdog (armed above, reset by
|
||||
@@ -573,8 +587,22 @@ class GatewayChatClient(
|
||||
}
|
||||
if (activeTurn === turn) activeTurn = null
|
||||
turn.disarmWatchdog()
|
||||
val submitError = submitted.exceptionOrNull()
|
||||
if ((submitError as? GatewayRpcException)?.code == ACTIVE_SESSION_CAP_REJECTION) {
|
||||
// Authoritative policy rejection: the gateway received
|
||||
// the prompt and deliberately refused to create the
|
||||
// first turn. Falling back to SSE would bypass the cap
|
||||
// and duplicate the optimistic user turn on another
|
||||
// transport. Surface the holder-aware upstream message
|
||||
// through the normal failed-turn callback instead.
|
||||
turn.tracer.done("submit-rejected")
|
||||
turn.callbacks.onError(
|
||||
submitError.message ?: "Hermes rejected the new session",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
throw GatewayPreflightException(
|
||||
submitted.exceptionOrNull()?.message ?: "prompt.submit failed",
|
||||
submitError?.message ?: "prompt.submit failed",
|
||||
)
|
||||
}
|
||||
turn.tracer.mark("submit")
|
||||
@@ -881,6 +909,16 @@ class GatewayChatClient(
|
||||
user = value.stringField("user").orEmpty(),
|
||||
assistant = value.stringField("assistant").orEmpty(),
|
||||
streaming = value.booleanField("streaming") == true,
|
||||
corrections = (value["corrections"] as? JsonArray)
|
||||
?.mapNotNull { correction ->
|
||||
(correction as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.take(MAX_RECOVERED_CORRECTION_CHARS)
|
||||
}
|
||||
?.take(MAX_RECOVERED_CORRECTIONS)
|
||||
.orEmpty(),
|
||||
status = value.stringField("status"),
|
||||
error = value.stringField("error"),
|
||||
recoverable = value.booleanField("recoverable") == true,
|
||||
@@ -1159,6 +1197,59 @@ class GatewayChatClient(
|
||||
.onSuccess { commandsCatalogCache = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the upstream gateway's cropped preview for a Petdex pet.
|
||||
*
|
||||
* A missing thumbnail is represented by a successful `null`, matching the
|
||||
* gateway's fail-open `{ "ok": false }` response. RPC errors, including
|
||||
* method-not-found on older upstream gateways, remain failures so callers
|
||||
* can distinguish an unavailable capability from a missing image.
|
||||
*/
|
||||
suspend fun petThumbnail(
|
||||
slug: String,
|
||||
spritesheetUrl: String? = null,
|
||||
profile: String? = currentSessionProfile(),
|
||||
): Result<String?> {
|
||||
val normalizedSlug = slug.trim()
|
||||
if (!PETDEX_SLUG.matches(normalizedSlug)) {
|
||||
return Result.failure(IllegalArgumentException("invalid Petdex slug"))
|
||||
}
|
||||
|
||||
val normalizedUrl = spritesheetUrl?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (normalizedUrl != null && !isTrustedPetdexAssetUrl(normalizedUrl)) {
|
||||
return Result.failure(IllegalArgumentException("invalid Petdex spritesheet URL"))
|
||||
}
|
||||
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
|
||||
return rpc(
|
||||
"pet.thumb",
|
||||
buildJsonObject {
|
||||
put("slug", normalizedSlug)
|
||||
normalizedUrl?.let { put("url", it) }
|
||||
profile?.trim()?.takeIf { it.isNotEmpty() }?.let { put("profile", it) }
|
||||
},
|
||||
).mapCatching { response ->
|
||||
val ok = (response["ok"] as? JsonPrimitive)?.booleanOrNull
|
||||
?: throw GatewayRpcException("pet.thumb returned an invalid response")
|
||||
val responseSlug = (response["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
if (responseSlug != normalizedSlug) {
|
||||
throw GatewayRpcException("pet.thumb returned a mismatched slug")
|
||||
}
|
||||
if (!ok) return@mapCatching null
|
||||
|
||||
val dataUri = (response["dataUri"] as? JsonPrimitive)?.contentOrNull
|
||||
if (dataUri == null || !isValidPetThumbnailDataUri(dataUri)) {
|
||||
throw GatewayRpcException("pet.thumb returned an invalid thumbnail")
|
||||
}
|
||||
dataUri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current chat session's running and recently-finished background
|
||||
* processes. Callers never provide a session id: this wrapper resolves and
|
||||
@@ -1270,6 +1361,22 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List personalities through upstream's slash completer. Unlike the
|
||||
* dashboard config schema, this resolves the CLI config that contains both
|
||||
* built-in and profile-defined personalities, matching `/personality` in
|
||||
* the desktop and TUI.
|
||||
*/
|
||||
suspend fun personalityOptions(): Result<List<String>> {
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
return Result.failure(GatewayRpcException("not connected"))
|
||||
}
|
||||
return rpc(
|
||||
"complete.slash",
|
||||
buildJsonObject { put("text", "/personality ") },
|
||||
).map(::parseGatewayPersonalityOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the personality the way the desktop + TUI do (`config.set
|
||||
* {key:"personality"}`). The gateway persists `display.personality` +
|
||||
@@ -1321,20 +1428,7 @@ class GatewayChatClient(
|
||||
return rpc("model.options", params).map { result ->
|
||||
val providers = (result["providers"] as? JsonArray).orEmpty().mapNotNull { el ->
|
||||
val obj = el as? JsonObject ?: return@mapNotNull null
|
||||
val slug = obj.stringField("slug") ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = obj.stringField("name") ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = obj.stringField("warning"),
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
|
||||
)
|
||||
parseGatewayModelProvider(obj)
|
||||
}
|
||||
GatewayModelOptions(
|
||||
providers = providers,
|
||||
@@ -1684,12 +1778,23 @@ class GatewayChatClient(
|
||||
_serverPersonality.value =
|
||||
(info.stringField("personality") ?: "").ifBlank { "none" }
|
||||
}
|
||||
info.stringField("model")?.takeIf { it.isNotBlank() }?.let { _serverModel.value = it }
|
||||
info.stringField("provider")?.takeIf { it.isNotBlank() }?.let { _serverProvider.value = it }
|
||||
val model = info.stringField("model")?.takeIf { it.isNotBlank() }
|
||||
val provider = info.stringField("provider")?.takeIf { it.isNotBlank() }
|
||||
model?.let { _serverModel.value = it }
|
||||
provider?.let { _serverProvider.value = it }
|
||||
if (model != null && provider != null) {
|
||||
_serverModelIdentity.value = GatewayModelIdentity(model = model, provider = provider)
|
||||
}
|
||||
// reasoning effort: ignore "" (reasoning disabled) so it can't clobber
|
||||
// the chip; display mode is config.get-only, not here.
|
||||
info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
|
||||
?.let { _serverReasoningEffort.value = it }
|
||||
val reasoningEffort = info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
|
||||
reasoningEffort?.let { _serverReasoningEffort.value = it }
|
||||
if (model != null && provider != null && reasoningEffort != null) {
|
||||
_serverReasoningIdentity.value = GatewayReasoningIdentity(
|
||||
identity = GatewayModelIdentity(model = model, provider = provider),
|
||||
effort = reasoningEffort,
|
||||
)
|
||||
}
|
||||
// credential_warning: present only when the provider key is missing/
|
||||
// invalid. ABSENT means healthy — clear to null so it self-resolves.
|
||||
_serverCredentialWarning.value =
|
||||
@@ -2895,6 +3000,22 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseGatewayPersonalityOptions(result: JsonObject): List<String> =
|
||||
(result["items"] as? JsonArray)
|
||||
.orEmpty()
|
||||
.mapNotNull { item ->
|
||||
(item as? JsonObject)
|
||||
?.stringField("text")
|
||||
?.trim()
|
||||
?.removePrefix("/personality")
|
||||
?.trim()
|
||||
?.takeIf {
|
||||
it.isNotBlank() &&
|
||||
it.lowercase() !in setOf("none", "default", "neutral")
|
||||
}
|
||||
}
|
||||
.distinctBy { it.lowercase() }
|
||||
|
||||
/** Outcome of an active-turn correction — Rejected and Failed both mean "queue locally instead". */
|
||||
enum class SteerResult {
|
||||
/** Server accepted the active-turn correction. */
|
||||
@@ -2931,6 +3052,31 @@ internal class GatewayConnectAttemptException(message: String) : Exception(messa
|
||||
internal class GatewayRpcException(message: String, val code: Int? = null) : Exception(message)
|
||||
|
||||
private const val JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
private const val ACTIVE_SESSION_CAP_REJECTION = 4090
|
||||
private const val MAX_RECOVERED_CORRECTIONS = 32
|
||||
private const val MAX_RECOVERED_CORRECTION_CHARS = 32_768
|
||||
private const val PET_THUMB_DATA_PREFIX = "data:image/png;base64,"
|
||||
private const val MAX_PET_THUMB_BASE64_CHARS = 512 * 1024
|
||||
private val PETDEX_SLUG = Regex("[a-z0-9][a-z0-9-]{0,127}")
|
||||
private val STANDARD_BASE64 = Regex("[A-Za-z0-9+/]*={0,2}")
|
||||
|
||||
private fun isTrustedPetdexAssetUrl(raw: String): Boolean {
|
||||
val url = raw.toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" &&
|
||||
url.host == "assets.petdex.dev" &&
|
||||
url.port == 443 &&
|
||||
url.username.isEmpty() &&
|
||||
url.password.isEmpty()
|
||||
}
|
||||
|
||||
private fun isValidPetThumbnailDataUri(raw: String): Boolean {
|
||||
if (!raw.startsWith(PET_THUMB_DATA_PREFIX)) return false
|
||||
val payload = raw.substring(PET_THUMB_DATA_PREFIX.length)
|
||||
return payload.isNotEmpty() &&
|
||||
payload.length <= MAX_PET_THUMB_BASE64_CHARS &&
|
||||
payload.length % 4 == 0 &&
|
||||
STANDARD_BASE64.matches(payload)
|
||||
}
|
||||
|
||||
data class GatewayCompressResult(
|
||||
val status: String,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* Shared types for the Gateway chat transport — upstream hermes-agent's
|
||||
@@ -122,6 +127,8 @@ data class GatewayInflightTurn(
|
||||
val user: String,
|
||||
val assistant: String,
|
||||
val streaming: Boolean,
|
||||
/** Accepted active-turn redirects in display order; additive on newer Hermes. */
|
||||
val corrections: List<String> = emptyList(),
|
||||
val status: String? = null,
|
||||
val error: String? = null,
|
||||
val recoverable: Boolean = false,
|
||||
@@ -345,8 +352,45 @@ data class GatewayModelProvider(
|
||||
val unavailableModels: List<String> = emptyList(),
|
||||
val freeTier: Boolean = false,
|
||||
val totalModels: Int = 0,
|
||||
/** Per-model capability rows keyed by the exact model id. */
|
||||
val capabilities: Map<String, GatewayModelCapabilities> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Shared tolerant parser for the gateway RPC and API-server REST twins. */
|
||||
internal fun parseGatewayModelProvider(obj: JsonObject): GatewayModelProvider? {
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val capabilities = (obj["capabilities"] as? JsonObject).orEmpty().mapNotNull { (model, raw) ->
|
||||
val row = raw as? JsonObject ?: return@mapNotNull null
|
||||
val effortsElement = row["reasoning_efforts"]
|
||||
val efforts = if (effortsElement is JsonArray) {
|
||||
effortsElement.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
model to GatewayModelCapabilities(
|
||||
reasoning = (row["reasoning"] as? JsonPrimitive)?.booleanOrNull,
|
||||
reasoningEfforts = efforts,
|
||||
reasoningEffortsExact =
|
||||
(row["reasoning_efforts_exact"] as? JsonPrimitive)?.booleanOrNull,
|
||||
)
|
||||
}.toMap()
|
||||
return GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
|
||||
capabilities = capabilities,
|
||||
)
|
||||
}
|
||||
|
||||
data class GatewayMoaReference(
|
||||
val index: Int?,
|
||||
val count: Int?,
|
||||
@@ -362,6 +406,15 @@ data class GatewayModelOptions(
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Coherent model identity from a single `session.info` payload. */
|
||||
data class GatewayModelIdentity(val model: String, val provider: String)
|
||||
|
||||
/** Model identity and effort observed together in one `session.info` payload. */
|
||||
data class GatewayReasoningIdentity(
|
||||
val identity: GatewayModelIdentity,
|
||||
val effort: String,
|
||||
)
|
||||
|
||||
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||
internal fun isCurrentModelOptionsResponse(
|
||||
requestGeneration: Long,
|
||||
|
||||
@@ -215,22 +215,7 @@ internal fun parseApiProviderModelOptionsBody(
|
||||
val rows = root["providers"] as? JsonArray ?: return null
|
||||
val providers = rows.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull() ?: 0,
|
||||
)
|
||||
parseGatewayModelProvider(obj)
|
||||
}
|
||||
return ApiProviderModelOptions(
|
||||
providers = providers,
|
||||
@@ -254,7 +239,8 @@ enum class ApiModelRoutingErrorCode {
|
||||
class ApiModelRoutingException(
|
||||
val code: ApiModelRoutingErrorCode,
|
||||
message: String,
|
||||
) : IOException(message)
|
||||
cause: Throwable? = null,
|
||||
) : IOException(message, cause)
|
||||
|
||||
sealed interface ApiModelSelectionAck {
|
||||
data object ServerDefault : ApiModelSelectionAck
|
||||
@@ -596,27 +582,35 @@ class HermesApiClient(
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessionsResult(limit: Int = 200): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
suspend fun listSessionsResult(limit: Int = SESSION_LIST_WINDOW_LIMIT): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
val sessions = linkedMapOf<String, SessionItem>()
|
||||
for (page in sessionListPages(limit)) {
|
||||
val request = authRequest(
|
||||
"$baseUrl/api/sessions?limit=${page.limit}&offset=${page.offset}",
|
||||
).get().build()
|
||||
val pageSessions = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList()
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
pageSessions.forEach { sessions.putIfAbsent(it.id, it) }
|
||||
if (pageSessions.size < page.limit) break
|
||||
}
|
||||
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 200): List<SessionItem> =
|
||||
suspend fun listSessions(limit: Int = SESSION_LIST_WINDOW_LIMIT): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(
|
||||
@@ -807,6 +801,7 @@ class HermesApiClient(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
e,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -1000,9 +995,7 @@ class HermesApiClient(
|
||||
// Personalities: config.agent.personalities { name: "system prompt", ... }
|
||||
val agent = config["agent"] as? JsonObject
|
||||
val personalitiesObj = agent?.get("personalities") as? JsonObject
|
||||
val prompts = personalitiesObj?.entries?.associate { (key, value) ->
|
||||
key to ((value as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "")
|
||||
} ?: emptyMap()
|
||||
val prompts = parsePersonalityPrompts(personalitiesObj)
|
||||
|
||||
// Default display identity. Upstream Hermes currently uses
|
||||
// config.display.personality for the active persona and often
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* Parse both upstream personality formats: the original prompt string and the
|
||||
* structured form introduced for richer descriptions, tone, and style.
|
||||
*/
|
||||
internal fun parsePersonalityPrompts(personalities: JsonObject?): Map<String, String> =
|
||||
personalities
|
||||
?.mapValues { (_, value) -> personalityPrompt(value) }
|
||||
.orEmpty()
|
||||
|
||||
private fun personalityPrompt(value: JsonElement): String = when (value) {
|
||||
is JsonPrimitive -> value.contentOrNull.orEmpty()
|
||||
is JsonObject -> listOfNotNull(
|
||||
value.stringValue("system_prompt"),
|
||||
value.stringValue("tone")?.let { "Tone: $it" },
|
||||
value.stringValue("style")?.let { "Style: $it" },
|
||||
).joinToString("\n")
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun JsonObject.stringValue(key: String): String? =
|
||||
(this[key] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
/** Canonical reasoning-effort values accepted by upstream Hermes. */
|
||||
object ReasoningEfforts {
|
||||
const val DEFAULT = "medium"
|
||||
|
||||
val canonical: List<String> =
|
||||
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||
|
||||
fun normalize(value: String?): String {
|
||||
val normalized = value?.trim()?.lowercase().orEmpty()
|
||||
return normalized.takeIf { it in canonical } ?: DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider/model capability row advertised by upstream `model.options`. */
|
||||
data class GatewayModelCapabilities(
|
||||
/** Legacy capability flag. Null means the server did not advertise support either way. */
|
||||
val reasoning: Boolean? = null,
|
||||
/** Exact selectable values on newer servers. Null means use the canonical compatibility list. */
|
||||
val reasoningEfforts: List<String>? = null,
|
||||
/** Explicit false means the advertised list is advisory rather than selectable. */
|
||||
val reasoningEffortsExact: Boolean? = null,
|
||||
)
|
||||
|
||||
data class ReasoningEffortAvailability(
|
||||
val supported: Boolean?,
|
||||
val choices: List<String>,
|
||||
val exact: Boolean,
|
||||
) {
|
||||
fun accepts(effort: String): Boolean = supported != false && (!exact || effort in choices)
|
||||
}
|
||||
|
||||
/** Exact provider/model identity to which a confirmed effort belongs. */
|
||||
data class ReasoningEffortIdentity(val provider: String, val model: String)
|
||||
|
||||
/**
|
||||
* Resolve the active provider/model's advertised reasoning contract.
|
||||
*
|
||||
* Older servers expose either no capability entry or only `reasoning: true`;
|
||||
* those receive the full canonical compatibility list. An explicit false
|
||||
* disables the control. A `reasoning_efforts` array is authoritative.
|
||||
*/
|
||||
fun resolveReasoningEffortAvailability(
|
||||
providers: List<GatewayModelProvider>,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
relayCapabilities: Map<ReasoningEffortIdentity, GatewayModelCapabilities> = emptyMap(),
|
||||
): ReasoningEffortAvailability {
|
||||
val normalizedProvider = provider?.trim().orEmpty()
|
||||
val normalizedModel = model?.trim().orEmpty()
|
||||
if (normalizedProvider.isEmpty() || normalizedModel.isEmpty()) {
|
||||
return ReasoningEffortAvailability(
|
||||
supported = null,
|
||||
choices = ReasoningEfforts.canonical,
|
||||
exact = false,
|
||||
)
|
||||
}
|
||||
val providerRow = providers.firstOrNull {
|
||||
it.slug.equals(normalizedProvider, ignoreCase = true)
|
||||
}
|
||||
|
||||
val upstream = providerRow?.capabilities?.get(normalizedModel)
|
||||
?: providerRow?.capabilities?.entries?.firstOrNull {
|
||||
it.key.equals(normalizedModel, ignoreCase = true)
|
||||
}?.value
|
||||
val identity = ReasoningEffortIdentity(
|
||||
provider = normalizedProvider.lowercase(),
|
||||
model = normalizedModel,
|
||||
)
|
||||
val relay = relayCapabilities[identity]
|
||||
|
||||
fun exactAvailability(capabilities: GatewayModelCapabilities): ReasoningEffortAvailability {
|
||||
val choices = capabilities.reasoningEfforts.orEmpty()
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it in ReasoningEfforts.canonical }
|
||||
.distinct()
|
||||
return ReasoningEffortAvailability(
|
||||
supported = capabilities.reasoning ?: choices.isNotEmpty(),
|
||||
choices = choices,
|
||||
exact = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Contract precedence: authoritative upstream, authoritative Relay overlay,
|
||||
// explicit upstream suppression, then compatibility fallback.
|
||||
if (upstream?.reasoningEfforts != null && upstream.reasoningEffortsExact == true) {
|
||||
return exactAvailability(upstream)
|
||||
}
|
||||
if (relay?.reasoningEfforts != null && relay.reasoningEffortsExact == true) {
|
||||
return exactAvailability(relay)
|
||||
}
|
||||
if (upstream?.reasoning == false) {
|
||||
return ReasoningEffortAvailability(supported = false, choices = emptyList(), exact = false)
|
||||
}
|
||||
|
||||
return ReasoningEffortAvailability(
|
||||
supported = upstream?.reasoning,
|
||||
choices = ReasoningEfforts.canonical,
|
||||
exact = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* The publication boundary for Chat and Voice transcript rows.
|
||||
*
|
||||
* [ChatMessage.id] may change when the server adopts a client-created row,
|
||||
* while [ChatMessage.uiKey] is that row's stable render identity. Every value
|
||||
* emitted from this state therefore has exactly one row per render identity.
|
||||
*/
|
||||
internal class RenderedMessageState(initialValue: List<ChatMessage>) {
|
||||
private val mutable = MutableStateFlow(normalizeRenderedMessages(initialValue))
|
||||
val flow: StateFlow<List<ChatMessage>> = mutable.asStateFlow()
|
||||
|
||||
var value: List<ChatMessage>
|
||||
get() = mutable.value
|
||||
set(value) {
|
||||
mutable.value = normalizeRenderedMessages(value)
|
||||
}
|
||||
|
||||
fun update(transform: (List<ChatMessage>) -> List<ChatMessage>) {
|
||||
mutable.update { current -> normalizeRenderedMessages(transform(current)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce aliases before they can escape to keyed Compose consumers.
|
||||
*
|
||||
* The first slot owns transcript position and the latest snapshot owns row
|
||||
* state. This is the same ordering contract used for replayed server history.
|
||||
*/
|
||||
internal fun normalizeRenderedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||
if (messages.size < 2) return messages
|
||||
|
||||
val firstSlotByUiKey = HashMap<String, Int>()
|
||||
val normalized = ArrayList<ChatMessage>(messages.size)
|
||||
for (message in messages) {
|
||||
val existingSlot = firstSlotByUiKey[message.uiKey]
|
||||
if (existingSlot == null) {
|
||||
firstSlotByUiKey[message.uiKey] = normalized.size
|
||||
normalized += message
|
||||
} else {
|
||||
normalized[existingSlot] = message
|
||||
}
|
||||
}
|
||||
return if (normalized.size == messages.size) messages else normalized
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
internal const val SESSION_LIST_PAGE_LIMIT = 100
|
||||
internal const val SESSION_LIST_WINDOW_LIMIT = 200
|
||||
|
||||
internal data class SessionListPage(
|
||||
val limit: Int,
|
||||
val offset: Int,
|
||||
)
|
||||
|
||||
internal fun sessionListPages(requestedLimit: Int): List<SessionListPage> {
|
||||
val window = requestedLimit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)
|
||||
return buildList {
|
||||
var offset = 0
|
||||
while (offset < window) {
|
||||
add(
|
||||
SessionListPage(
|
||||
limit = minOf(SESSION_LIST_PAGE_LIMIT, window - offset),
|
||||
offset = offset,
|
||||
),
|
||||
)
|
||||
offset += SESSION_LIST_PAGE_LIMIT
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-22
@@ -21,16 +21,27 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.BufferedSink
|
||||
import okio.ByteString
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal fun standardHermesAudioUrl(
|
||||
baseUrl: String,
|
||||
path: String,
|
||||
profile: String?,
|
||||
) = "$baseUrl$path".toHttpUrlOrNull()?.newBuilder()?.apply {
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { addQueryParameter("profile", it) }
|
||||
}?.build()
|
||||
|
||||
/**
|
||||
* Standard (no-plugin) voice client — speaks the upstream **dashboard web
|
||||
* server** contract that hermes-desktop's voice mode uses:
|
||||
@@ -51,11 +62,9 @@ class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
||||
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
||||
// ignores extra fields, so this is harmless today and forward-compatible if
|
||||
// upstream ever adds profile-aware TTS. Until then, standard voice remains
|
||||
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
|
||||
// Active chat profile name (null = default/launch). Current upstream audio
|
||||
// routes accept it as a query parameter so TTS, STT, streaming speech, and
|
||||
// provider catalogs resolve through the same Hermes home as chat.
|
||||
private val profileProvider: () -> String? = { null },
|
||||
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
|
||||
private val json: Json = Json {
|
||||
@@ -86,17 +95,18 @@ class StandardHermesVoiceClient(
|
||||
// malformed dashboard URL (a non-address pasted into that field, #131),
|
||||
// and this runs before executeJson()'s try/catch, so the throw would
|
||||
// escape withContext(IO) onto the calling coroutine and crash the app.
|
||||
val httpUrl = "$baseUrl/api/audio/transcribe".toHttpUrlOrNull()
|
||||
val httpUrl = dashboardAudioUrl(baseUrl, "/api/audio/transcribe")
|
||||
?: return@withContext Result.failure(IOException("Hermes dashboard URL is not a valid address: $baseUrl"))
|
||||
|
||||
val dataUrl = buildAudioDataUrl(audioFile)
|
||||
val payload = buildJsonObject {
|
||||
put("data_url", dataUrl)
|
||||
put("mime_type", mediaTypeForAudioFile(audioFile))
|
||||
}
|
||||
val mimeType = mediaTypeForAudioFile(audioFile)
|
||||
val request = Request.Builder()
|
||||
.url(httpUrl)
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
// Stream the file through Base64 directly into OkHttp's sink. Building
|
||||
// a JsonObject first retained the file bytes, a Base64 String, the JSON
|
||||
// serializer's growing char buffer, and the final request bytes at the
|
||||
// same time. A near-limit recording could therefore exhaust Android's
|
||||
// 256 MB heap before the request reached the network (#271).
|
||||
.post(standardHermesTranscriptionRequestBody(audioFile, mimeType))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
@@ -122,14 +132,11 @@ class StandardHermesVoiceClient(
|
||||
|
||||
// See transcribe(): guard the throwing url(String) so a malformed
|
||||
// dashboard URL is a clean Result.failure, never a Main-thread crash.
|
||||
val httpUrl = "$baseUrl/api/audio/speak".toHttpUrlOrNull()
|
||||
val httpUrl = dashboardAudioUrl(baseUrl, "/api/audio/speak")
|
||||
?: return@withContext Result.failure(IOException("Hermes dashboard URL is not a valid address: $baseUrl"))
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("text", cleanText)
|
||||
// Defensive only — upstream /api/audio/speak ignores it (text-only
|
||||
// TTSSpeakRequest). Omitted for the default profile.
|
||||
profileProvider()?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", it) }
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(httpUrl)
|
||||
@@ -176,6 +183,7 @@ class StandardHermesVoiceClient(
|
||||
baseUrl = baseUrl,
|
||||
ticket = ticket,
|
||||
path = "/api/audio/speak-stream",
|
||||
profile = activeProfile(),
|
||||
) ?: throw IOException("Could not build Hermes speech stream URL")
|
||||
val request = Request.Builder().url(websocketUrl).build()
|
||||
StandardHermesSpeechStream(
|
||||
@@ -195,6 +203,12 @@ class StandardHermesVoiceClient(
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun activeProfile(): String? =
|
||||
profileProvider()?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun dashboardAudioUrl(baseUrl: String, path: String) =
|
||||
standardHermesAudioUrl(baseUrl, path, activeProfile())
|
||||
|
||||
private fun callClient(baseUrl: String): OkHttpClient =
|
||||
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
|
||||
|
||||
@@ -244,12 +258,6 @@ class StandardHermesVoiceClient(
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun buildAudioDataUrl(audioFile: File): String {
|
||||
val mimeType = mediaTypeForAudioFile(audioFile)
|
||||
val encoded = Base64.getEncoder().encodeToString(audioFile.readBytes())
|
||||
return "data:$mimeType;base64,$encoded"
|
||||
}
|
||||
|
||||
private fun mediaTypeForAudioFile(file: File): String =
|
||||
when (file.extension.lowercase()) {
|
||||
"wav" -> "audio/wav"
|
||||
@@ -294,6 +302,58 @@ class StandardHermesVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON request body for upstream `/api/audio/transcribe`.
|
||||
*
|
||||
* The endpoint requires a Base64 data URL inside JSON rather than multipart
|
||||
* upload. Encoding into [BufferedSink] keeps peak memory bounded by the copy
|
||||
* buffer instead of materializing several 33+ MB representations at once.
|
||||
*/
|
||||
internal fun standardHermesTranscriptionRequestBody(
|
||||
audioFile: File,
|
||||
mimeType: String,
|
||||
): RequestBody {
|
||||
val prefix = "{\"data_url\":\"data:$mimeType;base64,"
|
||||
val suffix = "\",\"mime_type\":\"$mimeType\"}"
|
||||
val contentType = "application/json".toMediaType()
|
||||
val fileLength = audioFile.length()
|
||||
val encodedLength = ((fileLength + 2L) / 3L) * 4L
|
||||
val bodyLength = prefix.toByteArray(Charsets.UTF_8).size.toLong() +
|
||||
encodedLength +
|
||||
suffix.toByteArray(Charsets.UTF_8).size.toLong()
|
||||
|
||||
return object : RequestBody() {
|
||||
override fun contentType() = contentType
|
||||
|
||||
override fun contentLength(): Long = bodyLength
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
sink.writeUtf8(prefix)
|
||||
Base64.getEncoder().wrap(NonClosingSinkOutputStream(sink)).use { encoded ->
|
||||
audioFile.inputStream().use { input ->
|
||||
input.copyTo(encoded)
|
||||
}
|
||||
}
|
||||
sink.writeUtf8(suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets the Base64 encoder finish padding without closing OkHttp's request sink. */
|
||||
private class NonClosingSinkOutputStream(
|
||||
private val sink: BufferedSink,
|
||||
) : OutputStream() {
|
||||
override fun write(value: Int) {
|
||||
sink.writeByte(value)
|
||||
}
|
||||
|
||||
override fun write(bytes: ByteArray, offset: Int, length: Int) {
|
||||
sink.write(bytes, offset, length)
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
|
||||
private class StandardHermesSpeechStream(
|
||||
private val request: Request,
|
||||
private val callbacks: VoiceSpeechStreamCallbacks,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.hermesandroid.relay.petdex
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
class PetdexCatalogClient internal constructor(
|
||||
private val fetcher: PetdexFetcher,
|
||||
private val nowMs: () -> Long,
|
||||
) {
|
||||
constructor() : this(SecurePetdexFetcher(), System::currentTimeMillis)
|
||||
|
||||
@Volatile
|
||||
private var cached: CachedCatalog? = null
|
||||
|
||||
suspend fun fetchCatalog(forceRefresh: Boolean = false): List<PetdexPet> {
|
||||
val now = nowMs()
|
||||
cached?.takeIf { !forceRefresh && now - it.loadedAtMs < CACHE_TTL_MS }?.let { return it.pets }
|
||||
|
||||
val pets = runCatching {
|
||||
val bytes = fetcher.fetch(V2_URL, MAX_V2_BYTES, PetdexRemoteKind.Catalog)
|
||||
PetdexCatalogParser.parseV2(bytes.decodeToString())
|
||||
}.getOrElse { v2Error ->
|
||||
if (v2Error is CancellationException) throw v2Error
|
||||
runCatching {
|
||||
val bytes = fetcher.fetch(V1_URL, MAX_V1_BYTES, PetdexRemoteKind.Catalog)
|
||||
PetdexCatalogParser.parseV1(bytes.decodeToString())
|
||||
}.getOrElse { v1Error ->
|
||||
if (v1Error is CancellationException) throw v1Error
|
||||
throw PetdexException("Couldn't load the Petdex catalog.", v1Error).also {
|
||||
it.addSuppressed(v2Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = CachedCatalog(nowMs(), pets)
|
||||
return pets
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cached = null
|
||||
}
|
||||
|
||||
private data class CachedCatalog(val loadedAtMs: Long, val pets: List<PetdexPet>)
|
||||
|
||||
private companion object {
|
||||
const val V2_URL = "https://petdex.dev/api/manifest/v2"
|
||||
const val V1_URL = "https://petdex.dev/api/manifest"
|
||||
const val CACHE_TTL_MS = 5 * 60 * 1000L
|
||||
const val MAX_V2_BYTES = 4L * 1024 * 1024
|
||||
const val MAX_V1_BYTES = 8L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
internal object PetdexCatalogParser {
|
||||
private const val MAX_CATALOG_ITEMS = 10_000
|
||||
private const val MAX_MANIFEST_FIELDS = 32
|
||||
private const val MAX_FIELD_NAME_LENGTH = 64
|
||||
private const val MAX_ROW_FIELDS = 32
|
||||
private const val MAX_DISPLAY_NAME_LENGTH = 256
|
||||
private const val MAX_KIND_LENGTH = 64
|
||||
private const val MAX_CREATOR_LENGTH = 256
|
||||
private const val MAX_ASSET_URL_LENGTH = 2_048
|
||||
private val slugPattern = Regex("[a-z0-9][a-z0-9-]{0,127}")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun parseV2(raw: String): List<PetdexPet> {
|
||||
val manifest = json.decodeFromString(V2Manifest.serializer(), raw)
|
||||
if (manifest.v != 2) throw PetdexException("Unsupported Petdex catalog version.")
|
||||
if (manifest.pets.size > MAX_CATALOG_ITEMS) throw PetdexException("Petdex catalog has too many entries.")
|
||||
if (manifest.fields.isEmpty() || manifest.fields.size > MAX_MANIFEST_FIELDS ||
|
||||
manifest.fields.any { it.isBlank() || it.length > MAX_FIELD_NAME_LENGTH }
|
||||
) {
|
||||
throw PetdexException("Petdex catalog has invalid fields.")
|
||||
}
|
||||
val fieldIndex = manifest.fields.withIndex().associate { it.value to it.index }
|
||||
if (!fieldIndex.keys.containsAll(REQUIRED_V2_FIELDS)) {
|
||||
throw PetdexException("Petdex catalog is missing required fields.")
|
||||
}
|
||||
val base = manifest.assetBase.toHttpUrlOrNull()
|
||||
?.takeIf { PetdexUrlPolicy.isTrusted(it.toString(), PetdexRemoteKind.Asset) }
|
||||
?: throw PetdexException("Petdex catalog has an untrusted asset base.")
|
||||
return manifest.pets.mapNotNull row@{ row ->
|
||||
if (row.size > MAX_ROW_FIELDS || row.size > manifest.fields.size) return@row null
|
||||
fun value(name: String): String = fieldIndex[name]
|
||||
?.let(row::getOrNull)
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
.orEmpty()
|
||||
buildPet(
|
||||
slug = value("slug"),
|
||||
displayName = value("displayName"),
|
||||
kind = value("kind"),
|
||||
submittedBy = value("submittedBy"),
|
||||
spritesheetUrl = resolveAsset(base.toString(), value("spritesheet")),
|
||||
petJsonUrl = resolveAsset(base.toString(), value("petJson")),
|
||||
zipUrl = value("zip").takeIf(String::isNotBlank)?.let { resolveAsset(base.toString(), it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseV1(raw: String): List<PetdexPet> {
|
||||
val manifest = json.decodeFromString(V1Manifest.serializer(), raw)
|
||||
if (manifest.pets.size > MAX_CATALOG_ITEMS) throw PetdexException("Petdex catalog has too many entries.")
|
||||
return manifest.pets.mapNotNull { entry ->
|
||||
buildPet(
|
||||
slug = entry.slug,
|
||||
displayName = entry.displayName,
|
||||
kind = entry.kind,
|
||||
submittedBy = entry.submittedBy.orEmpty(),
|
||||
spritesheetUrl = entry.spritesheetUrl,
|
||||
petJsonUrl = entry.petJsonUrl,
|
||||
zipUrl = entry.zipUrl?.takeIf(String::isNotBlank),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPet(
|
||||
slug: String,
|
||||
displayName: String,
|
||||
kind: String,
|
||||
submittedBy: String,
|
||||
spritesheetUrl: String,
|
||||
petJsonUrl: String,
|
||||
zipUrl: String?,
|
||||
): PetdexPet? {
|
||||
if (!slugPattern.matches(slug)) return null
|
||||
if (displayName.length > MAX_DISPLAY_NAME_LENGTH || kind.length > MAX_KIND_LENGTH ||
|
||||
submittedBy.length > MAX_CREATOR_LENGTH || spritesheetUrl.length > MAX_ASSET_URL_LENGTH ||
|
||||
petJsonUrl.length > MAX_ASSET_URL_LENGTH || (zipUrl?.length ?: 0) > MAX_ASSET_URL_LENGTH
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (!PetdexUrlPolicy.isTrusted(spritesheetUrl, PetdexRemoteKind.Asset)) return null
|
||||
if (!PetdexUrlPolicy.isTrusted(petJsonUrl, PetdexRemoteKind.Asset)) return null
|
||||
if (zipUrl != null && !PetdexUrlPolicy.isTrusted(zipUrl, PetdexRemoteKind.Asset)) return null
|
||||
return PetdexPet(
|
||||
slug = slug,
|
||||
displayName = displayName.ifBlank { slug },
|
||||
kind = kind.ifBlank { "pet" },
|
||||
submittedBy = submittedBy,
|
||||
spritesheetUrl = spritesheetUrl,
|
||||
petJsonUrl = petJsonUrl,
|
||||
zipUrl = zipUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveAsset(base: String, reference: String): String {
|
||||
if (reference.isBlank()) return ""
|
||||
val absolute = reference.toHttpUrlOrNull()
|
||||
if (absolute != null) return absolute.toString()
|
||||
return base.toHttpUrlOrNull()?.resolve(reference)?.toString().orEmpty()
|
||||
}
|
||||
|
||||
private val REQUIRED_V2_FIELDS = setOf("slug", "displayName", "spritesheet", "petJson")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class V2Manifest(
|
||||
val v: Int,
|
||||
val assetBase: String,
|
||||
val fields: List<String>,
|
||||
val pets: List<List<JsonElement>>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class V1Manifest(val pets: List<V1Pet> = emptyList())
|
||||
|
||||
@Serializable
|
||||
private data class V1Pet(
|
||||
val slug: String = "",
|
||||
val displayName: String = "",
|
||||
val kind: String = "",
|
||||
val submittedBy: String? = null,
|
||||
val spritesheetUrl: String = "",
|
||||
val petJsonUrl: String = "",
|
||||
val zipUrl: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.petdex
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal enum class PetdexRemoteKind { Catalog, Asset }
|
||||
|
||||
internal fun interface PetdexFetcher {
|
||||
suspend fun fetch(url: String, maxBytes: Long, kind: PetdexRemoteKind): ByteArray
|
||||
}
|
||||
|
||||
internal object PetdexUrlPolicy {
|
||||
private val catalogHosts = setOf("petdex.dev", "assets.petdex.dev")
|
||||
private val assetHosts = setOf("assets.petdex.dev")
|
||||
|
||||
fun isTrusted(url: String, kind: PetdexRemoteKind): Boolean {
|
||||
val parsed = url.toHttpUrlOrNull() ?: return false
|
||||
if (!parsed.isHttps) return false
|
||||
if (parsed.port != 443 || parsed.username.isNotEmpty() || parsed.password.isNotEmpty()) return false
|
||||
val hosts = if (kind == PetdexRemoteKind.Catalog) catalogHosts else assetHosts
|
||||
return parsed.host in hosts
|
||||
}
|
||||
}
|
||||
|
||||
internal class SecurePetdexFetcher(
|
||||
client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build(),
|
||||
) : PetdexFetcher {
|
||||
private val http = client.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
|
||||
override suspend fun fetch(url: String, maxBytes: Long, kind: PetdexRemoteKind): ByteArray =
|
||||
withContext(Dispatchers.IO) {
|
||||
var current = trustedUrl(url, kind)
|
||||
repeat(MAX_REDIRECTS + 1) { redirectCount ->
|
||||
val request = Request.Builder()
|
||||
.url(current)
|
||||
.header("User-Agent", "Hermes-Relay-Android-Petdex")
|
||||
.get()
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (response.code in 300..399) {
|
||||
if (redirectCount == MAX_REDIRECTS) throw PetdexException("Too many Petdex redirects.")
|
||||
current = redirectTarget(response, current, kind)
|
||||
} else {
|
||||
if (!response.isSuccessful) throw PetdexException("Petdex request failed (${response.code}).")
|
||||
return@withContext readBounded(
|
||||
response.body.byteStream(),
|
||||
response.body.contentLength(),
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw PetdexException("Too many Petdex redirects.")
|
||||
}
|
||||
|
||||
private fun trustedUrl(raw: String, kind: PetdexRemoteKind): HttpUrl {
|
||||
if (!PetdexUrlPolicy.isTrusted(raw, kind)) throw PetdexException("Untrusted Petdex URL.")
|
||||
return raw.toHttpUrlOrNull() ?: throw PetdexException("Invalid Petdex URL.")
|
||||
}
|
||||
|
||||
private fun redirectTarget(response: Response, current: HttpUrl, kind: PetdexRemoteKind): HttpUrl {
|
||||
val location = response.header("Location") ?: throw PetdexException("Petdex redirect had no destination.")
|
||||
val resolved = current.resolve(location) ?: throw PetdexException("Invalid Petdex redirect.")
|
||||
return trustedUrl(resolved.toString(), kind)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_REDIRECTS = 3
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readBounded(input: InputStream, declaredLength: Long, maxBytes: Long): ByteArray {
|
||||
if (declaredLength < -1L) throw PetdexException("Petdex response had an invalid length.")
|
||||
if (maxBytes <= 0L || declaredLength > maxBytes) throw PetdexException("Petdex download is too large.")
|
||||
val initial = when {
|
||||
declaredLength in 1..maxBytes -> declaredLength.toInt()
|
||||
else -> minOf(maxBytes, 32L * 1024L).toInt()
|
||||
}
|
||||
val out = ByteArrayOutputStream(initial)
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > maxBytes) throw PetdexException("Petdex download is too large.")
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.hermesandroid.relay.petdex
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetClipSpec
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetLoader
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetReactiveSpec
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetSpec
|
||||
import com.hermesandroid.relay.ui.components.avatar.toAvatar
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class PetdexInstaller internal constructor(
|
||||
private val fetcher: PetdexFetcher,
|
||||
) {
|
||||
constructor() : this(SecurePetdexFetcher())
|
||||
|
||||
suspend fun install(context: Context, pet: PetdexPet): PetdexInstallResult =
|
||||
installTo(PetLoader.userDir(context), pet)
|
||||
|
||||
internal suspend fun installTo(petsDir: File, pet: PetdexPet): PetdexInstallResult = installMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
validateCatalogPet(pet)
|
||||
val metadataBytes = fetcher.fetch(pet.petJsonUrl, MAX_METADATA_BYTES, PetdexRemoteKind.Asset)
|
||||
val remoteMetadata = parseMetadata(metadataBytes)
|
||||
val spriteBytes = fetcher.fetch(pet.spritesheetUrl, MAX_SPRITESHEET_BYTES, PetdexRemoteKind.Asset)
|
||||
val image = inspectSpritesheet(spriteBytes)
|
||||
val layout = PetdexAtlasLayout.fromDimensions(image.width, image.height)
|
||||
?: throw PetdexException("That Petdex pet uses an unsupported spritesheet layout.")
|
||||
val extension = image.extension
|
||||
val spec = layout.toPetSpec(pet, remoteMetadata, "spritesheet.$extension")
|
||||
installAtomically(petsDir, pet.installedAvatarId, spec, spriteBytes, extension)
|
||||
PetdexInstallResult.Success(spec.id, spec.label)
|
||||
}.getOrElse { error ->
|
||||
if (error is CancellationException) throw error
|
||||
PetdexInstallResult.Failure(error.message ?: "Couldn't install that Petdex pet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateCatalogPet(pet: PetdexPet) {
|
||||
if (!PETDEX_SLUG.matches(pet.slug)) throw PetdexException("Invalid Petdex pet id.")
|
||||
if (!PetdexUrlPolicy.isTrusted(pet.petJsonUrl, PetdexRemoteKind.Asset) ||
|
||||
!PetdexUrlPolicy.isTrusted(pet.spritesheetUrl, PetdexRemoteKind.Asset)
|
||||
) {
|
||||
throw PetdexException("Untrusted Petdex asset URL.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMetadata(bytes: ByteArray): PetdexMetadata {
|
||||
val metadata = try {
|
||||
json.decodeFromString(PetdexMetadata.serializer(), bytes.decodeToString())
|
||||
} catch (t: Throwable) {
|
||||
throw PetdexException("Petdex metadata isn't valid JSON.", t)
|
||||
}
|
||||
if (metadata.id.length > MAX_METADATA_ID_LENGTH ||
|
||||
metadata.displayName.length > MAX_METADATA_NAME_LENGTH ||
|
||||
metadata.description.length > MAX_METADATA_DESCRIPTION_LENGTH
|
||||
) {
|
||||
throw PetdexException("Petdex metadata fields are too large.")
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
private fun inspectSpritesheet(bytes: ByteArray): InspectedImage {
|
||||
val extension = when {
|
||||
bytes.hasPrefix(PNG_MAGIC) -> "png"
|
||||
bytes.hasWebpMagic() -> "webp"
|
||||
else -> throw PetdexException("Petdex spritesheet isn't PNG or WebP.")
|
||||
}
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
if (options.outWidth <= 0 || options.outHeight <= 0) {
|
||||
throw PetdexException("Petdex spritesheet couldn't be decoded.")
|
||||
}
|
||||
val pixels = options.outWidth.toLong() * options.outHeight.toLong()
|
||||
if (pixels > MAX_SPRITESHEET_PIXELS) throw PetdexException("Petdex spritesheet is too large.")
|
||||
return InspectedImage(options.outWidth, options.outHeight, extension)
|
||||
}
|
||||
|
||||
private fun installAtomically(
|
||||
petsDir: File,
|
||||
avatarId: String,
|
||||
spec: PetSpec,
|
||||
spriteBytes: ByteArray,
|
||||
extension: String,
|
||||
) {
|
||||
petsDir.mkdirs()
|
||||
val staging = File(petsDir, ".petdex-install-${UUID.randomUUID()}")
|
||||
val backup = File(petsDir, ".petdex-backup-${UUID.randomUUID()}")
|
||||
val target = File(petsDir, avatarId)
|
||||
try {
|
||||
check(staging.mkdir()) { "Couldn't prepare Petdex install." }
|
||||
File(staging, "spritesheet.$extension").writeBytes(spriteBytes)
|
||||
File(staging, "pet.json").writeText(json.encodeToString(spec))
|
||||
spec.toAvatar(staging) // Validate the exact installed representation before swapping it in.
|
||||
|
||||
if (target.exists() && !target.renameTo(backup)) {
|
||||
throw PetdexException("Couldn't replace the existing Petdex pet.")
|
||||
}
|
||||
if (!staging.renameTo(target)) {
|
||||
if (backup.exists()) backup.renameTo(target)
|
||||
throw PetdexException("Couldn't finish the Petdex install.")
|
||||
}
|
||||
backup.deleteRecursively()
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
if (backup.exists() && !target.exists()) backup.renameTo(target)
|
||||
if (backup.exists() && target.exists()) backup.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private data class InspectedImage(val width: Int, val height: Int, val extension: String)
|
||||
|
||||
private companion object {
|
||||
val PETDEX_SLUG = Regex("[a-z0-9][a-z0-9-]{0,127}")
|
||||
val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47)
|
||||
const val MAX_METADATA_BYTES = 256L * 1024
|
||||
const val MAX_METADATA_ID_LENGTH = 128
|
||||
const val MAX_METADATA_NAME_LENGTH = 256
|
||||
const val MAX_METADATA_DESCRIPTION_LENGTH = 4_096
|
||||
const val MAX_SPRITESHEET_BYTES = 32L * 1024 * 1024
|
||||
const val MAX_SPRITESHEET_PIXELS = 16L * 1024 * 1024
|
||||
val installMutex = Mutex()
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class PetdexMetadata(
|
||||
val id: String = "",
|
||||
val displayName: String = "",
|
||||
val description: String = "",
|
||||
)
|
||||
|
||||
internal data class PetdexAtlasLayout(
|
||||
val columns: Int,
|
||||
val rows: Map<String, Int>,
|
||||
val frameCounts: Map<String, Int> = emptyMap(),
|
||||
) {
|
||||
fun toPetSpec(pet: PetdexPet, metadata: PetdexMetadata, sheetName: String): PetSpec {
|
||||
fun clip(rowName: String): PetClipSpec? = rows[rowName]?.let { row ->
|
||||
val frameCount = frameCounts[rowName] ?: LEGACY_FRAMES_PER_STATE
|
||||
val loopDurationMs = PETDEX_CURRENT_LOOP_DURATIONS_MS[rowName]
|
||||
?: LEGACY_LOOP_DURATION_MS
|
||||
PetClipSpec(
|
||||
sheet = sheetName,
|
||||
frameWidth = FRAME_WIDTH,
|
||||
frameHeight = FRAME_HEIGHT,
|
||||
frameCount = minOf(frameCount, columns),
|
||||
startFrame = row * columns,
|
||||
fps = frameCount * 1000f / loopDurationMs,
|
||||
)
|
||||
}
|
||||
requireNotNull(clip("idle"))
|
||||
// Preserve the source atlas taxonomy. PetLoader owns the backwards-
|
||||
// compatible semantic mapping from these upstream names to Android
|
||||
// activity and locomotion, so directional travel rows are not discarded
|
||||
// or confused with the in-place `running` agent-work row.
|
||||
val states = rows.keys.mapNotNull { rowName ->
|
||||
clip(rowName)?.let { rowName to it }
|
||||
}.toMap()
|
||||
return PetSpec(
|
||||
schemaVersion = 1,
|
||||
id = pet.installedAvatarId,
|
||||
label = pet.displayName.ifBlank { metadata.displayName.ifBlank { pet.slug } },
|
||||
description = metadata.description,
|
||||
source = "petdex",
|
||||
sourceUrl = pet.sourceUrl,
|
||||
creator = pet.submittedBy,
|
||||
reactive = PetReactiveSpec(voice = true, tools = true, intensity = false),
|
||||
states = states,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FRAME_WIDTH = 192
|
||||
const val FRAME_HEIGHT = 208
|
||||
private const val LEGACY_FRAMES_PER_STATE = 6
|
||||
private const val LEGACY_LOOP_DURATION_MS = 1100f
|
||||
|
||||
private val CURRENT_ROWS = mapOf(
|
||||
"idle" to 0,
|
||||
"running-right" to 1,
|
||||
"running-left" to 2,
|
||||
"waving" to 3,
|
||||
"jumping" to 4,
|
||||
"failed" to 5,
|
||||
"waiting" to 6,
|
||||
"running" to 7,
|
||||
"review" to 8,
|
||||
)
|
||||
private val LEGACY_ROWS = mapOf(
|
||||
"idle" to 0,
|
||||
"wave" to 1,
|
||||
"run" to 2,
|
||||
"failed" to 3,
|
||||
"review" to 4,
|
||||
"jump" to 5,
|
||||
)
|
||||
|
||||
fun fromDimensions(width: Int, height: Int): PetdexAtlasLayout? = when {
|
||||
width == 8 * FRAME_WIDTH &&
|
||||
(height == 9 * FRAME_HEIGHT || height == 11 * FRAME_HEIGHT) ->
|
||||
PetdexAtlasLayout(8, CURRENT_ROWS, PETDEX_CURRENT_FRAME_COUNTS)
|
||||
width == 9 * FRAME_WIDTH && height == 8 * FRAME_HEIGHT -> PetdexAtlasLayout(9, LEGACY_ROWS)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical used cells in the current Codex/Petdex 8-column atlas. */
|
||||
internal val PETDEX_CURRENT_FRAME_COUNTS: Map<String, Int> = mapOf(
|
||||
"idle" to 6,
|
||||
"running-right" to 8,
|
||||
"running-left" to 8,
|
||||
"waving" to 4,
|
||||
"jumping" to 5,
|
||||
"failed" to 8,
|
||||
"waiting" to 6,
|
||||
"running" to 6,
|
||||
"review" to 6,
|
||||
)
|
||||
|
||||
/** Total authored duration of each current row; Android approximates variable frame timing with average fps. */
|
||||
internal val PETDEX_CURRENT_LOOP_DURATIONS_MS: Map<String, Float> = mapOf(
|
||||
"idle" to 1100f,
|
||||
"running-right" to 1060f,
|
||||
"running-left" to 1060f,
|
||||
"waving" to 700f,
|
||||
"jumping" to 840f,
|
||||
"failed" to 1220f,
|
||||
"waiting" to 1010f,
|
||||
"running" to 820f,
|
||||
"review" to 1030f,
|
||||
)
|
||||
|
||||
private fun ByteArray.hasPrefix(prefix: ByteArray): Boolean =
|
||||
size >= prefix.size && prefix.indices.all { this[it] == prefix[it] }
|
||||
|
||||
private fun ByteArray.hasWebpMagic(): Boolean =
|
||||
size >= 12 &&
|
||||
copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
|
||||
copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.hermesandroid.relay.petdex
|
||||
|
||||
/** Public Petdex catalog entry. Installing is always an explicit user action. */
|
||||
data class PetdexPet(
|
||||
val slug: String,
|
||||
val displayName: String,
|
||||
val kind: String,
|
||||
val submittedBy: String,
|
||||
val spritesheetUrl: String,
|
||||
val petJsonUrl: String,
|
||||
val zipUrl: String? = null,
|
||||
) {
|
||||
val sourceUrl: String get() = "https://petdex.dev/pets/$slug"
|
||||
val installedAvatarId: String get() = "petdex-$slug"
|
||||
}
|
||||
|
||||
sealed interface PetdexInstallResult {
|
||||
data class Success(val avatarId: String, val label: String) : PetdexInstallResult
|
||||
data class Failure(val reason: String) : PetdexInstallResult
|
||||
}
|
||||
|
||||
internal class PetdexException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.hermesandroid.relay.plugins.document
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A declarative Android surface supplied by a Hermes plugin.
|
||||
*
|
||||
* Documents contain no executable code, URLs, Android intents, or gateway calls. The host
|
||||
* renders the bounded element vocabulary and decides how (or whether) to handle actions and
|
||||
* asset references.
|
||||
*/
|
||||
@Serializable
|
||||
data class PluginDocument(
|
||||
val schemaVersion: Int = CURRENT_SCHEMA_VERSION,
|
||||
val pages: List<PluginPage>,
|
||||
val initialState: Map<String, PluginValue> = emptyMap(),
|
||||
/** Host-owned revision for generated pages; ordinary installed plugins may omit it. */
|
||||
@SerialName("host_revision") val hostRevision: Int? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val CURRENT_SCHEMA_VERSION = 1
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PluginPage(
|
||||
val id: String,
|
||||
val title: PluginText,
|
||||
val content: PluginElement,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed interface PluginValue {
|
||||
@Serializable
|
||||
@SerialName("string")
|
||||
data class StringValue(val value: String) : PluginValue
|
||||
|
||||
@Serializable
|
||||
@SerialName("boolean")
|
||||
data class BooleanValue(val value: Boolean) : PluginValue
|
||||
|
||||
@Serializable
|
||||
@SerialName("number")
|
||||
data class NumberValue(val value: Double) : PluginValue
|
||||
|
||||
@Serializable
|
||||
@SerialName("null")
|
||||
data object NullValue : PluginValue
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface PluginText {
|
||||
@Serializable
|
||||
@SerialName("literal")
|
||||
data class Literal(val value: String) : PluginText
|
||||
|
||||
@Serializable
|
||||
@SerialName("binding")
|
||||
data class Binding(
|
||||
val key: String,
|
||||
val fallback: String = "",
|
||||
) : PluginText
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface PluginCondition {
|
||||
@Serializable
|
||||
@SerialName("truthy")
|
||||
data class Truthy(val key: String) : PluginCondition
|
||||
|
||||
@Serializable
|
||||
@SerialName("equals")
|
||||
data class Equals(val key: String, val value: PluginValue) : PluginCondition
|
||||
|
||||
@Serializable
|
||||
@SerialName("not")
|
||||
data class Not(val condition: PluginCondition) : PluginCondition
|
||||
|
||||
@Serializable
|
||||
@SerialName("all")
|
||||
data class All(val conditions: List<PluginCondition>) : PluginCondition
|
||||
|
||||
@Serializable
|
||||
@SerialName("any")
|
||||
data class Any(val conditions: List<PluginCondition>) : PluginCondition
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PluginAnimation(
|
||||
val enter: PluginAnimationKind = PluginAnimationKind.NONE,
|
||||
val change: PluginAnimationKind = PluginAnimationKind.NONE,
|
||||
val speed: PluginAnimationSpeed = PluginAnimationSpeed.NORMAL,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class PluginAnimationKind {
|
||||
NONE,
|
||||
FADE,
|
||||
SCALE,
|
||||
SLIDE_VERTICAL,
|
||||
HIGHLIGHT,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class PluginAnimationSpeed { FAST, NORMAL, SLOW }
|
||||
|
||||
@Serializable
|
||||
data class PluginAction(
|
||||
val id: String,
|
||||
val arguments: Map<String, PluginValue> = emptyMap(),
|
||||
val confirmation: PluginText? = null,
|
||||
val request: PluginActionRequest? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginActionRequest(
|
||||
val method: String = "POST",
|
||||
val path: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed interface PluginElement {
|
||||
val id: String
|
||||
val visibleWhen: PluginCondition?
|
||||
val animation: PluginAnimation
|
||||
|
||||
@Serializable
|
||||
@SerialName("group")
|
||||
data class Group(
|
||||
override val id: String,
|
||||
val direction: PluginDirection = PluginDirection.COLUMN,
|
||||
val children: List<PluginElement>,
|
||||
val spacing: PluginSpacing = PluginSpacing.MEDIUM,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("card")
|
||||
data class Card(
|
||||
override val id: String,
|
||||
val child: PluginElement,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("text")
|
||||
data class Text(
|
||||
override val id: String,
|
||||
val text: PluginText,
|
||||
val style: PluginTextStyle = PluginTextStyle.BODY,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("badge")
|
||||
data class Badge(
|
||||
override val id: String,
|
||||
val text: PluginText,
|
||||
val tone: PluginTone = PluginTone.NEUTRAL,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("button")
|
||||
data class Button(
|
||||
override val id: String,
|
||||
val label: PluginText,
|
||||
val action: PluginAction,
|
||||
val style: PluginButtonStyle = PluginButtonStyle.PRIMARY,
|
||||
val enabledWhen: PluginCondition? = null,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("text_input")
|
||||
data class TextInput(
|
||||
override val id: String,
|
||||
val label: PluginText,
|
||||
val binding: String,
|
||||
val placeholder: PluginText? = null,
|
||||
val maxLength: Int = DEFAULT_INPUT_LIMIT,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("toggle")
|
||||
data class Toggle(
|
||||
override val id: String,
|
||||
val label: PluginText,
|
||||
val binding: String,
|
||||
val enabledWhen: PluginCondition? = null,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("progress")
|
||||
data class Progress(
|
||||
override val id: String,
|
||||
val valueBinding: String? = null,
|
||||
val value: Double? = null,
|
||||
val label: PluginText? = null,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("image")
|
||||
data class Image(
|
||||
override val id: String,
|
||||
val asset: PluginAssetReference,
|
||||
val contentDescription: PluginText,
|
||||
val aspectRatio: Float = 16f / 9f,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("divider")
|
||||
data class Divider(
|
||||
override val id: String,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
@Serializable
|
||||
@SerialName("spacer")
|
||||
data class Spacer(
|
||||
override val id: String,
|
||||
val size: PluginSpacing = PluginSpacing.MEDIUM,
|
||||
override val visibleWhen: PluginCondition? = null,
|
||||
override val animation: PluginAnimation = PluginAnimation(),
|
||||
) : PluginElement
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_INPUT_LIMIT = 2_000
|
||||
}
|
||||
}
|
||||
|
||||
/** An opaque, plugin-scoped asset id. The host resolves it; documents cannot supply URLs. */
|
||||
@Serializable
|
||||
data class PluginAssetReference(val id: String)
|
||||
|
||||
@Serializable
|
||||
enum class PluginDirection { ROW, COLUMN }
|
||||
|
||||
@Serializable
|
||||
enum class PluginSpacing { NONE, SMALL, MEDIUM, LARGE }
|
||||
|
||||
@Serializable
|
||||
enum class PluginTextStyle { BODY, LABEL, TITLE, HEADLINE }
|
||||
|
||||
@Serializable
|
||||
enum class PluginTone { NEUTRAL, INFO, SUCCESS, WARNING, DANGER }
|
||||
|
||||
@Serializable
|
||||
enum class PluginButtonStyle { PRIMARY, SECONDARY, TEXT }
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.hermesandroid.relay.plugins.document
|
||||
|
||||
data class PluginDocumentState(
|
||||
val values: Map<String, PluginValue> = emptyMap(),
|
||||
) {
|
||||
operator fun get(key: String): PluginValue = values[key] ?: PluginValue.NullValue
|
||||
|
||||
fun updated(key: String, value: PluginValue): PluginDocumentState =
|
||||
copy(values = values + (key to value))
|
||||
|
||||
fun resolve(text: PluginText): String = when (text) {
|
||||
is PluginText.Literal -> text.value
|
||||
is PluginText.Binding -> this[text.key].displayString() ?: text.fallback
|
||||
}
|
||||
|
||||
fun matches(condition: PluginCondition?): Boolean = when (condition) {
|
||||
null -> true
|
||||
is PluginCondition.Truthy -> this[condition.key].isTruthy()
|
||||
is PluginCondition.Equals -> this[condition.key] == condition.value
|
||||
is PluginCondition.Not -> !matches(condition.condition)
|
||||
is PluginCondition.All -> condition.conditions.all(::matches)
|
||||
is PluginCondition.Any -> condition.conditions.any(::matches)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(document: PluginDocument): PluginDocumentState =
|
||||
PluginDocumentState(document.initialState)
|
||||
}
|
||||
}
|
||||
|
||||
fun PluginValue.displayString(): String? = when (this) {
|
||||
is PluginValue.StringValue -> value
|
||||
is PluginValue.BooleanValue -> value.toString()
|
||||
is PluginValue.NumberValue -> value.toString().removeSuffix(".0")
|
||||
PluginValue.NullValue -> null
|
||||
}
|
||||
|
||||
fun PluginValue.isTruthy(): Boolean = when (this) {
|
||||
is PluginValue.StringValue -> value.isNotBlank()
|
||||
is PluginValue.BooleanValue -> value
|
||||
is PluginValue.NumberValue -> value != 0.0 && !value.isNaN()
|
||||
PluginValue.NullValue -> false
|
||||
}
|
||||
|
||||
sealed interface PluginDocumentValidation {
|
||||
data object Valid : PluginDocumentValidation
|
||||
data class Invalid(val errors: List<String>) : PluginDocumentValidation
|
||||
}
|
||||
|
||||
/** Rejects pathological or ambiguous documents before they reach Compose. */
|
||||
object PluginDocumentValidator {
|
||||
const val MAX_PAGES = 32
|
||||
const val MAX_ELEMENTS = 500
|
||||
const val MAX_DEPTH = 16
|
||||
const val MAX_CHILDREN = 100
|
||||
const val MAX_TEXT_LENGTH = 16_000
|
||||
const val MAX_STATE_ENTRIES = 250
|
||||
|
||||
private val safeIdentifier = Regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
fun validate(document: PluginDocument): PluginDocumentValidation {
|
||||
val errors = mutableListOf<String>()
|
||||
if (document.schemaVersion != PluginDocument.CURRENT_SCHEMA_VERSION) {
|
||||
errors += "Unsupported schema version: ${document.schemaVersion}"
|
||||
}
|
||||
if (document.pages.isEmpty() || document.pages.size > MAX_PAGES) {
|
||||
errors += "Document must contain 1..$MAX_PAGES pages"
|
||||
}
|
||||
if (document.initialState.size > MAX_STATE_ENTRIES) {
|
||||
errors += "Initial state exceeds $MAX_STATE_ENTRIES entries"
|
||||
}
|
||||
|
||||
val ids = mutableSetOf<String>()
|
||||
var elements = 0
|
||||
document.initialState.keys.forEach { validateIdentifier("state key", it, errors) }
|
||||
document.pages.forEach { page ->
|
||||
validateIdentifier("page id", page.id, errors)
|
||||
validateText(page.title, errors)
|
||||
walk(page.content, 1) { element, depth ->
|
||||
elements += 1
|
||||
if (depth > MAX_DEPTH) errors += "Element ${element.id} exceeds depth $MAX_DEPTH"
|
||||
validateIdentifier("element id", element.id, errors)
|
||||
if (!ids.add(element.id)) errors += "Duplicate element id: ${element.id}"
|
||||
validateElement(element, errors)
|
||||
}
|
||||
}
|
||||
if (elements > MAX_ELEMENTS) errors += "Document exceeds $MAX_ELEMENTS elements"
|
||||
return if (errors.isEmpty()) PluginDocumentValidation.Valid
|
||||
else PluginDocumentValidation.Invalid(errors.distinct())
|
||||
}
|
||||
|
||||
private fun validateElement(element: PluginElement, errors: MutableList<String>) {
|
||||
validateCondition(element.visibleWhen, errors)
|
||||
when (element) {
|
||||
is PluginElement.Group -> if (element.children.size > MAX_CHILDREN) {
|
||||
errors += "Element ${element.id} exceeds $MAX_CHILDREN children"
|
||||
}
|
||||
is PluginElement.Card -> Unit
|
||||
is PluginElement.Text -> validateText(element.text, errors)
|
||||
is PluginElement.Badge -> validateText(element.text, errors)
|
||||
is PluginElement.Button -> {
|
||||
validateText(element.label, errors)
|
||||
validateIdentifier("action id", element.action.id, errors)
|
||||
element.action.arguments.keys.forEach { validateIdentifier("action argument", it, errors) }
|
||||
element.action.confirmation?.let { validateText(it, errors) }
|
||||
validateCondition(element.enabledWhen, errors)
|
||||
}
|
||||
is PluginElement.TextInput -> {
|
||||
validateText(element.label, errors)
|
||||
element.placeholder?.let { validateText(it, errors) }
|
||||
validateIdentifier("binding", element.binding, errors)
|
||||
if (element.maxLength !in 1..PluginElement.DEFAULT_INPUT_LIMIT) {
|
||||
errors += "Input ${element.id} has an invalid maxLength"
|
||||
}
|
||||
}
|
||||
is PluginElement.Toggle -> {
|
||||
validateText(element.label, errors)
|
||||
validateIdentifier("binding", element.binding, errors)
|
||||
validateCondition(element.enabledWhen, errors)
|
||||
}
|
||||
is PluginElement.Progress -> {
|
||||
element.valueBinding?.let { validateIdentifier("binding", it, errors) }
|
||||
element.label?.let { validateText(it, errors) }
|
||||
if (element.value == null && element.valueBinding == null) {
|
||||
errors += "Progress ${element.id} needs a value or binding"
|
||||
}
|
||||
if (element.value != null && (!element.value.isFinite() || element.value !in 0.0..1.0)) {
|
||||
errors += "Progress ${element.id} value must be between 0 and 1"
|
||||
}
|
||||
}
|
||||
is PluginElement.Image -> {
|
||||
validateIdentifier("asset id", element.asset.id, errors)
|
||||
validateText(element.contentDescription, errors)
|
||||
if (!element.aspectRatio.isFinite() || element.aspectRatio !in 0.25f..4f) {
|
||||
errors += "Image ${element.id} has an invalid aspect ratio"
|
||||
}
|
||||
}
|
||||
is PluginElement.Divider, is PluginElement.Spacer -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateCondition(condition: PluginCondition?, errors: MutableList<String>) {
|
||||
when (condition) {
|
||||
null -> Unit
|
||||
is PluginCondition.Truthy -> validateIdentifier("condition key", condition.key, errors)
|
||||
is PluginCondition.Equals -> validateIdentifier("condition key", condition.key, errors)
|
||||
is PluginCondition.Not -> validateCondition(condition.condition, errors)
|
||||
is PluginCondition.All -> condition.conditions.forEach { validateCondition(it, errors) }
|
||||
is PluginCondition.Any -> condition.conditions.forEach { validateCondition(it, errors) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateText(text: PluginText, errors: MutableList<String>) {
|
||||
when (text) {
|
||||
is PluginText.Literal -> if (text.value.length > MAX_TEXT_LENGTH) {
|
||||
errors += "Text exceeds $MAX_TEXT_LENGTH characters"
|
||||
}
|
||||
is PluginText.Binding -> {
|
||||
validateIdentifier("text binding", text.key, errors)
|
||||
if (text.fallback.length > MAX_TEXT_LENGTH) {
|
||||
errors += "Text fallback exceeds $MAX_TEXT_LENGTH characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateIdentifier(label: String, value: String, errors: MutableList<String>) {
|
||||
if (!safeIdentifier.matches(value)) errors += "Invalid $label: $value"
|
||||
}
|
||||
|
||||
private fun walk(
|
||||
element: PluginElement,
|
||||
depth: Int,
|
||||
visit: (PluginElement, Int) -> Unit,
|
||||
) {
|
||||
visit(element, depth)
|
||||
when (element) {
|
||||
is PluginElement.Group -> element.children.forEach { walk(it, depth + 1, visit) }
|
||||
is PluginElement.Card -> walk(element.child, depth + 1, visit)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.plugins.runtime
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* Discovers opt-in Android plugin surfaces through the vanilla upstream dashboard.
|
||||
*
|
||||
* The dashboard catalog is authoritative for plugin identity and active state. Only
|
||||
* entries advertising a backend API are probed; a missing or malformed mobile manifest
|
||||
* simply means that dashboard plugin has no Android surface.
|
||||
*/
|
||||
class PluginDiscoveryClient(
|
||||
private val dashboard: DashboardApiClient,
|
||||
) {
|
||||
suspend fun discover(): Result<List<DiscoveredAndroidPlugin>> = runCatching {
|
||||
val catalog = dashboard.getJsonElement(CATALOG_PATH).getOrThrow() as? JsonArray
|
||||
?: error("Dashboard plugin catalog must be a JSON array")
|
||||
|
||||
catalog.mapNotNull(::parseCatalogEntry)
|
||||
.mapNotNull { entry ->
|
||||
val manifest = dashboard
|
||||
.getJsonObject("/api/plugins/${entry.id}/mobile/manifest")
|
||||
.getOrNull()
|
||||
?: return@mapNotNull null
|
||||
// The authenticated catalog supplies provenance. A backend document cannot
|
||||
// claim another plugin's identity and inherit this plugin's namespace/grants.
|
||||
if (manifest.string("id") != entry.id) return@mapNotNull null
|
||||
DiscoveredAndroidPlugin(catalog = entry, manifest = manifest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCatalogEntry(element: kotlinx.serialization.json.JsonElement): AndroidPluginCatalogEntry? {
|
||||
val root = element as? JsonObject ?: return null
|
||||
val id = root.string("name") ?: return null
|
||||
val exposesApi = root.boolean("has_api") == true || root.string("api") != null
|
||||
if (!PluginIdentifiers.isValid(id) || !exposesApi) return null
|
||||
return AndroidPluginCatalogEntry(
|
||||
id = id,
|
||||
label = root.string("label") ?: id,
|
||||
description = root.string("description").orEmpty(),
|
||||
version = root.string("version").orEmpty(),
|
||||
icon = root.string("icon") ?: "Puzzle",
|
||||
source = root.string("source").orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.string(key: String): String? =
|
||||
(this[key] as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotEmpty)
|
||||
|
||||
private fun JsonObject.boolean(key: String): Boolean? =
|
||||
(this[key] as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private companion object {
|
||||
const val CATALOG_PATH = "/api/dashboard/plugins"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.plugins.runtime
|
||||
|
||||
/** Host-stamped context for plugin UI. It is never accepted from plugin JSON. */
|
||||
data class PluginHostContext(
|
||||
val connectionId: String,
|
||||
val profileName: String?,
|
||||
val sessionId: String?,
|
||||
) {
|
||||
init {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
require(sessionId == null || sessionId.isNotBlank()) { "sessionId must be null or non-blank" }
|
||||
}
|
||||
}
|
||||
|
||||
enum class PluginLifecycleChange {
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
CONNECTION_CHANGED,
|
||||
PROFILE_CHANGED,
|
||||
SESSION_CHANGED,
|
||||
UNCHANGED,
|
||||
}
|
||||
|
||||
data class PluginLifecycleSnapshot(
|
||||
val context: PluginHostContext?,
|
||||
val generation: Long,
|
||||
val changedAtEpochMillis: Long,
|
||||
val lastChange: PluginLifecycleChange,
|
||||
)
|
||||
|
||||
/**
|
||||
* Tracks provenance changes independently from UI navigation.
|
||||
*
|
||||
* A connection or profile change invalidates catalog/page ownership. A session change is
|
||||
* metadata-only: it updates the context available to the active surface without replacing the
|
||||
* authenticated Dashboard client or discarding the catalog.
|
||||
*/
|
||||
class PluginLifecycleTracker(
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
var snapshot: PluginLifecycleSnapshot = PluginLifecycleSnapshot(
|
||||
context = null,
|
||||
generation = 0,
|
||||
changedAtEpochMillis = clock(),
|
||||
lastChange = PluginLifecycleChange.UNCHANGED,
|
||||
)
|
||||
private set
|
||||
|
||||
fun update(next: PluginHostContext?): PluginLifecycleSnapshot {
|
||||
val previous = snapshot.context
|
||||
val change = when {
|
||||
previous == next -> PluginLifecycleChange.UNCHANGED
|
||||
previous == null && next != null -> PluginLifecycleChange.CONNECTED
|
||||
previous != null && next == null -> PluginLifecycleChange.DISCONNECTED
|
||||
previous?.connectionId != next?.connectionId -> PluginLifecycleChange.CONNECTION_CHANGED
|
||||
previous?.profileName != next?.profileName -> PluginLifecycleChange.PROFILE_CHANGED
|
||||
else -> PluginLifecycleChange.SESSION_CHANGED
|
||||
}
|
||||
if (change != PluginLifecycleChange.UNCHANGED) {
|
||||
snapshot = PluginLifecycleSnapshot(
|
||||
context = next,
|
||||
generation = snapshot.generation + 1,
|
||||
changedAtEpochMillis = clock(),
|
||||
lastChange = change,
|
||||
)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
data class PluginCatalogPreview(
|
||||
val context: PluginHostContext,
|
||||
val pluginCount: Int,
|
||||
val enabledPluginCount: Int,
|
||||
val pageCount: Int,
|
||||
val refreshedAtEpochMillis: Long,
|
||||
val liveRefreshEnabled: Boolean,
|
||||
) {
|
||||
init {
|
||||
require(pluginCount >= 0 && enabledPluginCount in 0..pluginCount)
|
||||
require(pageCount >= 0)
|
||||
}
|
||||
}
|
||||
|
||||
object PluginCatalogRefreshPolicy {
|
||||
const val VISIBLE_REFRESH_INTERVAL_MILLIS: Long = 5_000L
|
||||
const val MIN_PAGE_REFRESH_SECONDS: Int = 5
|
||||
const val MAX_PAGE_REFRESH_SECONDS: Int = 300
|
||||
|
||||
fun pageRefreshIntervalMillis(requestedSeconds: Int?): Long? = requestedSeconds
|
||||
?.coerceIn(MIN_PAGE_REFRESH_SECONDS, MAX_PAGE_REFRESH_SECONDS)
|
||||
?.times(1_000L)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.hermesandroid.relay.plugins.runtime
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AndroidPluginManifest(
|
||||
@SerialName("schema_version") val schemaVersion: Int = 1,
|
||||
val id: String,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
val description: String? = null,
|
||||
val version: String? = null,
|
||||
@SerialName("min_host_api") val minHostApi: Int = 1,
|
||||
@SerialName("default_enabled") val defaultEnabled: Boolean = false,
|
||||
val contributions: List<AndroidPluginContribution> = emptyList(),
|
||||
@SerialName("requested_capabilities")
|
||||
val requestedCapabilities: List<AndroidPluginCapabilityRequest> = emptyList(),
|
||||
val updates: AndroidPluginUpdateSource? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AndroidPluginContribution(
|
||||
val id: String,
|
||||
val surface: String = "page",
|
||||
val title: String,
|
||||
val document: AndroidPluginDocumentEndpoint,
|
||||
/** Optional host-rendered metadata used by Relay-generated declarative previews. */
|
||||
val status: String? = null,
|
||||
val lifecycle: String? = null,
|
||||
val description: String? = null,
|
||||
val revision: Int? = null,
|
||||
val digest: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AndroidPluginDocumentEndpoint(
|
||||
val method: String = "GET",
|
||||
val path: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AndroidPluginCapabilityRequest(
|
||||
val id: String,
|
||||
val reason: String,
|
||||
val required: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AndroidPluginUpdateSource(
|
||||
@SerialName("poll_seconds") val pollSeconds: Int? = null,
|
||||
)
|
||||
|
||||
const val ANDROID_PLUGIN_HOST_API_VERSION: Int = 1
|
||||
const val PLUGIN_API_WRITE_CAPABILITY: String = "plugin.api.write"
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hermesandroid.relay.plugins.runtime
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/** Upstream dashboard metadata for a plugin whose backend can expose a mobile surface. */
|
||||
data class AndroidPluginCatalogEntry(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val description: String,
|
||||
val version: String,
|
||||
val icon: String,
|
||||
val source: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* A mobile manifest with identity anchored to the authenticated dashboard catalog.
|
||||
*
|
||||
* [manifest] stays as JSON at this transport boundary. The renderer owns the versioned
|
||||
* declarative document contract; the discovery layer must not let a manifest assert a
|
||||
* different plugin identity or widen its API namespace.
|
||||
*/
|
||||
data class DiscoveredAndroidPlugin(
|
||||
val catalog: AndroidPluginCatalogEntry,
|
||||
val manifest: JsonObject,
|
||||
)
|
||||
|
||||
/** Local preference scope. A null profile is the server-default profile context. */
|
||||
data class PluginScope(
|
||||
val connectionId: String,
|
||||
val profileName: String?,
|
||||
val pluginId: String,
|
||||
) {
|
||||
init {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
require(PluginIdentifiers.isValid(pluginId)) { "Invalid plugin id: $pluginId" }
|
||||
}
|
||||
}
|
||||
|
||||
data class PluginPreferenceState(
|
||||
val enabled: Boolean = false,
|
||||
val grants: Set<String> = emptySet(),
|
||||
/** Distinguishes an explicit opt-out from a manifest's default enablement. */
|
||||
val configured: Boolean = false,
|
||||
)
|
||||
|
||||
/** Conservative identifier grammar shared by discovery, API routing, and persistence. */
|
||||
object PluginIdentifiers {
|
||||
private val pattern = Regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
||||
|
||||
fun isValid(value: String): Boolean = pattern.matches(value)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hermesandroid.relay.plugins.runtime
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Durable, local-only plugin enablement and permission grants. */
|
||||
class PluginPreferenceStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.pluginPreferencesDataStore)
|
||||
|
||||
fun state(scope: PluginScope): Flow<PluginPreferenceState> {
|
||||
val suffix = scope.keySuffix()
|
||||
val enabledKey = booleanPreferencesKey("enabled_$suffix")
|
||||
val grantsKey = stringSetPreferencesKey("grants_$suffix")
|
||||
return dataStore.data.map { preferences ->
|
||||
PluginPreferenceState(
|
||||
enabled = preferences[enabledKey] ?: false,
|
||||
grants = preferences[grantsKey].orEmpty(),
|
||||
configured = enabledKey in preferences,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEnabled(scope: PluginScope, enabled: Boolean) {
|
||||
val key = booleanPreferencesKey("enabled_${scope.keySuffix()}")
|
||||
dataStore.edit { preferences -> preferences[key] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setGrants(scope: PluginScope, grants: Set<String>) {
|
||||
require(grants.none(String::isBlank)) { "Plugin grants must not be blank" }
|
||||
val key = stringSetPreferencesKey("grants_${scope.keySuffix()}")
|
||||
dataStore.edit { preferences -> preferences[key] = grants.toSortedSet() }
|
||||
}
|
||||
|
||||
suspend fun clear(scope: PluginScope) {
|
||||
val suffix = scope.keySuffix()
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(booleanPreferencesKey("enabled_$suffix"))
|
||||
preferences.remove(stringSetPreferencesKey("grants_$suffix"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun PluginScope.keySuffix(): String {
|
||||
val profileKey = AgentDisplay.profileSessionKey(profileName)
|
||||
val material = "$connectionId\u0000$profileKey\u0000$pluginId"
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(material.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { byte -> "%02x".format(byte) }
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.pluginPreferencesDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "plugin_preferences")
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hermesandroid.relay.plugins.runtime
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Namespace-confined access to one authenticated plugin backend.
|
||||
*
|
||||
* Callers provide path segments, never a URL. This rejects traversal and encoded path
|
||||
* tricks before composing with [DashboardApiClient], while query values are encoded.
|
||||
*/
|
||||
class ScopedPluginApiClient(
|
||||
private val pluginId: String,
|
||||
private val dashboard: DashboardApiClient,
|
||||
) {
|
||||
init {
|
||||
require(PluginIdentifiers.isValid(pluginId)) { "Invalid plugin id: $pluginId" }
|
||||
}
|
||||
|
||||
suspend fun get(
|
||||
relativePath: String,
|
||||
query: Map<String, String> = emptyMap(),
|
||||
): Result<JsonElement> = validateAndBuild(relativePath, query).fold(
|
||||
onSuccess = { dashboard.getJsonElement(it) },
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
|
||||
suspend fun post(relativePath: String, payload: JsonObject): Result<JsonObject> =
|
||||
objectPath(relativePath).fold(
|
||||
onSuccess = { dashboard.postJsonObject(it, payload) },
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
|
||||
suspend fun put(relativePath: String, payload: JsonObject): Result<JsonObject> =
|
||||
objectPath(relativePath).fold(
|
||||
onSuccess = { dashboard.putJsonObject(it, payload) },
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
|
||||
suspend fun patch(relativePath: String, payload: JsonObject): Result<JsonObject> =
|
||||
objectPath(relativePath).fold(
|
||||
onSuccess = { dashboard.patchJsonObject(it, payload) },
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
|
||||
suspend fun delete(relativePath: String): Result<JsonObject> = objectPath(relativePath).fold(
|
||||
onSuccess = { dashboard.deleteJsonObject(it) },
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
|
||||
internal fun objectPath(relativePath: String): Result<String> =
|
||||
validateAndBuild(relativePath, emptyMap())
|
||||
|
||||
private fun validateAndBuild(
|
||||
relativePath: String,
|
||||
query: Map<String, String>,
|
||||
): Result<String> = runCatching {
|
||||
val trimmed = relativePath.trim()
|
||||
require(trimmed.isNotEmpty()) { "Plugin API path must not be empty" }
|
||||
require(!trimmed.startsWith('/') && !trimmed.endsWith('/')) {
|
||||
"Plugin API path must be relative and have no empty segments"
|
||||
}
|
||||
require('?' !in trimmed && '#' !in trimmed && '\\' !in trimmed && '%' !in trimmed) {
|
||||
"Plugin API path must contain only plain path segments"
|
||||
}
|
||||
val segments = trimmed.split('/')
|
||||
require(segments.all(::isSafeSegment)) { "Invalid plugin API path: $relativePath" }
|
||||
|
||||
buildString {
|
||||
append("/api/plugins/")
|
||||
append(pluginId)
|
||||
append('/')
|
||||
append(segments.joinToString("/"))
|
||||
if (query.isNotEmpty()) {
|
||||
append('?')
|
||||
append(
|
||||
query.entries.sortedBy { it.key }.joinToString("&") { (key, value) ->
|
||||
"${encode(key)}=${encode(value)}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSafeSegment(segment: String): Boolean =
|
||||
segment.isNotEmpty() && segment != "." && segment != ".." && SEGMENT.matches(segment)
|
||||
|
||||
private fun encode(value: String): String =
|
||||
URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20")
|
||||
|
||||
private companion object {
|
||||
val SEGMENT = Regex("^[a-zA-Z0-9._~-]+$")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package com.hermesandroid.relay.plugins.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.aspectRatio
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.plugins.document.PluginAction
|
||||
import com.hermesandroid.relay.plugins.document.PluginAnimation
|
||||
import com.hermesandroid.relay.plugins.document.PluginAnimationKind
|
||||
import com.hermesandroid.relay.plugins.document.PluginAnimationSpeed
|
||||
import com.hermesandroid.relay.plugins.document.PluginAssetReference
|
||||
import com.hermesandroid.relay.plugins.document.PluginButtonStyle
|
||||
import com.hermesandroid.relay.plugins.document.PluginDirection
|
||||
import com.hermesandroid.relay.plugins.document.PluginDocument
|
||||
import com.hermesandroid.relay.plugins.document.PluginDocumentState
|
||||
import com.hermesandroid.relay.plugins.document.PluginElement
|
||||
import com.hermesandroid.relay.plugins.document.PluginPage
|
||||
import com.hermesandroid.relay.plugins.document.PluginSpacing
|
||||
import com.hermesandroid.relay.plugins.document.PluginTextStyle
|
||||
import com.hermesandroid.relay.plugins.document.PluginTone
|
||||
import com.hermesandroid.relay.plugins.document.PluginValue
|
||||
import com.hermesandroid.relay.ui.components.rememberAccessibleMotionState
|
||||
|
||||
sealed interface PluginInteraction {
|
||||
data class ActionInvoked(
|
||||
val elementId: String,
|
||||
val action: PluginAction,
|
||||
) : PluginInteraction
|
||||
|
||||
data class ValueChanged(
|
||||
val elementId: String,
|
||||
val key: String,
|
||||
val value: PluginValue,
|
||||
) : PluginInteraction
|
||||
}
|
||||
|
||||
typealias PluginAssetContent = @Composable (
|
||||
reference: PluginAssetReference,
|
||||
contentDescription: String,
|
||||
modifier: Modifier,
|
||||
) -> Unit
|
||||
|
||||
/**
|
||||
* Renders one complete plugin page using host-owned Compose components.
|
||||
*
|
||||
* [state] is controlled by the caller. Every local edit is emitted as a [PluginInteraction],
|
||||
* making server reconciliation and permission checks explicit rather than hidden in UI code.
|
||||
*/
|
||||
@Composable
|
||||
fun PluginPageRenderer(
|
||||
document: PluginDocument,
|
||||
pageId: String,
|
||||
state: PluginDocumentState,
|
||||
onInteraction: (PluginInteraction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
assetContent: PluginAssetContent = { _, description, assetModifier ->
|
||||
DefaultPluginAsset(description, assetModifier)
|
||||
},
|
||||
) {
|
||||
val page = document.pages.firstOrNull { it.id == pageId }
|
||||
if (page == null) {
|
||||
return
|
||||
}
|
||||
PluginPageRenderer(
|
||||
page = page,
|
||||
state = state,
|
||||
onInteraction = onInteraction,
|
||||
modifier = modifier,
|
||||
assetContent = assetContent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PluginPageRenderer(
|
||||
page: PluginPage,
|
||||
state: PluginDocumentState,
|
||||
onInteraction: (PluginInteraction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
assetContent: PluginAssetContent = { _, description, assetModifier ->
|
||||
DefaultPluginAsset(description, assetModifier)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = state.resolve(page.title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
RenderElement(
|
||||
element = page.content,
|
||||
state = state,
|
||||
onInteraction = onInteraction,
|
||||
assetContent = assetContent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderElement(
|
||||
element: PluginElement,
|
||||
state: PluginDocumentState,
|
||||
onInteraction: (PluginInteraction) -> Unit,
|
||||
assetContent: PluginAssetContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val visible = state.matches(element.visibleWhen)
|
||||
val duration = element.animation.speed.milliseconds
|
||||
val accessibleMotion = rememberAccessibleMotionState()
|
||||
val animation = if (accessibleMotion.osAnimations && !accessibleMotion.touchExploration) {
|
||||
element.animation
|
||||
} else {
|
||||
PluginAnimation()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
modifier = modifier,
|
||||
enter = animation.enter.enterTransition(duration),
|
||||
exit = animation.enter.exitTransition(duration),
|
||||
) {
|
||||
val animatedModifier = Modifier.pluginChangeAnimation(
|
||||
animation = animation,
|
||||
stateFingerprint = state.values.hashCode(),
|
||||
)
|
||||
when (element) {
|
||||
is PluginElement.Group -> PluginGroup(
|
||||
element = element,
|
||||
state = state,
|
||||
onInteraction = onInteraction,
|
||||
assetContent = assetContent,
|
||||
modifier = animatedModifier,
|
||||
)
|
||||
is PluginElement.Card -> Card(modifier = animatedModifier.fillMaxWidth()) {
|
||||
RenderElement(
|
||||
element = element.child,
|
||||
state = state,
|
||||
onInteraction = onInteraction,
|
||||
assetContent = assetContent,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
}
|
||||
is PluginElement.Text -> Text(
|
||||
text = state.resolve(element.text),
|
||||
style = when (element.style) {
|
||||
PluginTextStyle.BODY -> MaterialTheme.typography.bodyLarge
|
||||
PluginTextStyle.LABEL -> MaterialTheme.typography.labelLarge
|
||||
PluginTextStyle.TITLE -> MaterialTheme.typography.titleMedium
|
||||
PluginTextStyle.HEADLINE -> MaterialTheme.typography.headlineSmall
|
||||
},
|
||||
modifier = animatedModifier,
|
||||
)
|
||||
is PluginElement.Badge -> PluginBadge(element, state, animatedModifier)
|
||||
is PluginElement.Button -> PluginButton(element, state, onInteraction, animatedModifier)
|
||||
is PluginElement.TextInput -> OutlinedTextField(
|
||||
value = (state[element.binding] as? PluginValue.StringValue)?.value.orEmpty(),
|
||||
onValueChange = { value ->
|
||||
onInteraction(
|
||||
PluginInteraction.ValueChanged(
|
||||
elementId = element.id,
|
||||
key = element.binding,
|
||||
value = PluginValue.StringValue(value.take(element.maxLength)),
|
||||
),
|
||||
)
|
||||
},
|
||||
label = { Text(state.resolve(element.label)) },
|
||||
placeholder = element.placeholder?.let { placeholder ->
|
||||
{ Text(state.resolve(placeholder)) }
|
||||
},
|
||||
singleLine = false,
|
||||
modifier = animatedModifier.fillMaxWidth(),
|
||||
)
|
||||
is PluginElement.Toggle -> Row(
|
||||
modifier = animatedModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(state.resolve(element.label), modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = (state[element.binding] as? PluginValue.BooleanValue)?.value == true,
|
||||
enabled = state.matches(element.enabledWhen),
|
||||
onCheckedChange = { checked ->
|
||||
onInteraction(
|
||||
PluginInteraction.ValueChanged(
|
||||
elementId = element.id,
|
||||
key = element.binding,
|
||||
value = PluginValue.BooleanValue(checked),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
is PluginElement.Progress -> PluginProgress(element, state, animatedModifier)
|
||||
is PluginElement.Image -> assetContent(
|
||||
element.asset,
|
||||
state.resolve(element.contentDescription),
|
||||
animatedModifier.fillMaxWidth().aspectRatio(element.aspectRatio.coerceIn(0.25f, 4f)),
|
||||
)
|
||||
is PluginElement.Divider -> HorizontalDivider(modifier = animatedModifier.fillMaxWidth())
|
||||
is PluginElement.Spacer -> Spacer(modifier = animatedModifier.height(element.size.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginGroup(
|
||||
element: PluginElement.Group,
|
||||
state: PluginDocumentState,
|
||||
onInteraction: (PluginInteraction) -> Unit,
|
||||
assetContent: PluginAssetContent,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
if (element.direction == PluginDirection.COLUMN) {
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(element.spacing.dp)) {
|
||||
element.children.forEach { child ->
|
||||
RenderElement(child, state, onInteraction, assetContent, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(element.spacing.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
element.children.forEach { child -> RenderElement(child, state, onInteraction, assetContent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginBadge(
|
||||
element: PluginElement.Badge,
|
||||
state: PluginDocumentState,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val color = when (element.tone) {
|
||||
PluginTone.NEUTRAL -> MaterialTheme.colorScheme.surfaceVariant
|
||||
PluginTone.INFO -> MaterialTheme.colorScheme.primaryContainer
|
||||
PluginTone.SUCCESS -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
PluginTone.WARNING -> MaterialTheme.colorScheme.secondaryContainer
|
||||
PluginTone.DANGER -> MaterialTheme.colorScheme.errorContainer
|
||||
}
|
||||
Surface(modifier = modifier, color = color, shape = RoundedCornerShape(999.dp)) {
|
||||
Text(
|
||||
text = state.resolve(element.text),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginButton(
|
||||
element: PluginElement.Button,
|
||||
state: PluginDocumentState,
|
||||
onInteraction: (PluginInteraction) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val onClick = { onInteraction(PluginInteraction.ActionInvoked(element.id, element.action)) }
|
||||
val enabled = state.matches(element.enabledWhen)
|
||||
when (element.style) {
|
||||
PluginButtonStyle.PRIMARY -> Button(onClick, modifier, enabled) { Text(state.resolve(element.label)) }
|
||||
PluginButtonStyle.SECONDARY -> OutlinedButton(onClick, modifier, enabled) { Text(state.resolve(element.label)) }
|
||||
PluginButtonStyle.TEXT -> TextButton(onClick, modifier, enabled) { Text(state.resolve(element.label)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginProgress(
|
||||
element: PluginElement.Progress,
|
||||
state: PluginDocumentState,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val bound = element.valueBinding
|
||||
?.let(state::get)
|
||||
?.let { it as? PluginValue.NumberValue }
|
||||
?.value
|
||||
val progress = (bound ?: element.value ?: 0.0).toFloat().coerceIn(0f, 1f)
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
element.label?.let { Text(state.resolve(it), style = MaterialTheme.typography.labelMedium) }
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultPluginAsset(contentDescription: String, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Image,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.pluginChangeAnimation(
|
||||
animation: PluginAnimation,
|
||||
stateFingerprint: Int,
|
||||
): Modifier {
|
||||
val progress = remember { Animatable(1f) }
|
||||
var composed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(stateFingerprint, animation.change) {
|
||||
if (animation.change == PluginAnimationKind.NONE) {
|
||||
progress.snapTo(1f)
|
||||
} else if (composed) {
|
||||
progress.snapTo(0f)
|
||||
progress.animateTo(1f, tween(animation.speed.milliseconds))
|
||||
} else {
|
||||
composed = true
|
||||
}
|
||||
}
|
||||
if (animation.change == PluginAnimationKind.NONE) return this
|
||||
val p = progress.value
|
||||
return graphicsLayer {
|
||||
when (animation.change) {
|
||||
PluginAnimationKind.NONE -> Unit
|
||||
PluginAnimationKind.FADE -> alpha = 0.65f + (0.35f * p)
|
||||
PluginAnimationKind.SCALE -> {
|
||||
scaleX = 0.96f + (0.04f * p)
|
||||
scaleY = scaleX
|
||||
}
|
||||
PluginAnimationKind.SLIDE_VERTICAL -> translationY = 8.dp.toPx() * (1f - p)
|
||||
PluginAnimationKind.HIGHLIGHT -> {
|
||||
alpha = 0.82f + (0.18f * p)
|
||||
scaleX = 0.98f + (0.02f * p)
|
||||
scaleY = scaleX
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PluginAnimationKind.enterTransition(duration: Int): EnterTransition = when (this) {
|
||||
PluginAnimationKind.NONE -> EnterTransition.None
|
||||
PluginAnimationKind.FADE -> fadeIn(tween(duration))
|
||||
PluginAnimationKind.SCALE -> fadeIn(tween(duration)) + scaleIn(tween(duration), initialScale = 0.96f)
|
||||
PluginAnimationKind.SLIDE_VERTICAL -> fadeIn(tween(duration)) +
|
||||
slideInVertically(tween(duration)) { height -> height.coerceAtMost(48) / 3 }
|
||||
PluginAnimationKind.HIGHLIGHT -> fadeIn(tween(duration)) + scaleIn(tween(duration), initialScale = 0.98f)
|
||||
}
|
||||
|
||||
private fun PluginAnimationKind.exitTransition(duration: Int): ExitTransition = when (this) {
|
||||
PluginAnimationKind.NONE -> ExitTransition.None
|
||||
PluginAnimationKind.FADE -> fadeOut(tween(duration))
|
||||
PluginAnimationKind.SCALE -> fadeOut(tween(duration)) + scaleOut(tween(duration), targetScale = 0.96f)
|
||||
PluginAnimationKind.SLIDE_VERTICAL -> fadeOut(tween(duration)) +
|
||||
slideOutVertically(tween(duration)) { height -> height.coerceAtMost(48) / 3 }
|
||||
PluginAnimationKind.HIGHLIGHT -> fadeOut(tween(duration)) + scaleOut(tween(duration), targetScale = 0.98f)
|
||||
}
|
||||
|
||||
private val PluginAnimationSpeed.milliseconds: Int
|
||||
get() = when (this) {
|
||||
PluginAnimationSpeed.FAST -> 120
|
||||
PluginAnimationSpeed.NORMAL -> 220
|
||||
PluginAnimationSpeed.SLOW -> 360
|
||||
}
|
||||
|
||||
private val PluginSpacing.dp
|
||||
get() = when (this) {
|
||||
PluginSpacing.NONE -> 0.dp
|
||||
PluginSpacing.SMALL -> 8.dp
|
||||
PluginSpacing.MEDIUM -> 16.dp
|
||||
PluginSpacing.LARGE -> 24.dp
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Android boundary for the local reliability store. Nothing in this object has
|
||||
* a network path; writes stay in app-private storage until a user explicitly
|
||||
* reviews and shares text through the UI.
|
||||
*/
|
||||
object ReliabilityCenter {
|
||||
private val writer = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "hermes-reliability-writer").apply { isDaemon = true }
|
||||
}
|
||||
private val appSessionId = ReliabilityReport.newId("app")
|
||||
|
||||
@Volatile
|
||||
private var store: ReliabilityStore? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (store != null) return
|
||||
synchronized(this) {
|
||||
if (store == null) {
|
||||
store = ReliabilityStore(
|
||||
java.io.File(context.applicationContext.filesDir, "reliability/reports-v1.json"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recordFatal(
|
||||
context: Context,
|
||||
throwable: Throwable,
|
||||
threadName: String,
|
||||
timeIso: String = Instant.now().toString(),
|
||||
): ReliabilityReport {
|
||||
initialize(context)
|
||||
val summary = buildString {
|
||||
append(throwable.javaClass.simpleName.ifBlank { "Unexpected crash" })
|
||||
throwable.message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) }
|
||||
}
|
||||
val report = ReliabilityReport(
|
||||
reportId = ReliabilityReport.newId(),
|
||||
appSessionId = appSessionId,
|
||||
timeIso = timeIso,
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = summary,
|
||||
recovery = "The app restarted. Work already running on Hermes may still be active.",
|
||||
reportRecommended = true,
|
||||
technicalDetail = "Thread: $threadName\n${stackTraceText(throwable)}",
|
||||
environment = environment(),
|
||||
pendingReview = true,
|
||||
)
|
||||
// Fatal capture must complete before the platform terminates the process.
|
||||
store?.append(report)
|
||||
return report
|
||||
}
|
||||
|
||||
fun recordHandled(
|
||||
title: String,
|
||||
detail: String?,
|
||||
throwable: Throwable,
|
||||
context: String?,
|
||||
routeRole: String? = null,
|
||||
) {
|
||||
val target = store ?: return
|
||||
val classification = ReliabilityClassifier.classify(throwable, context)
|
||||
if (!classification.shouldPersist) return
|
||||
val report = ReliabilityReport(
|
||||
reportId = ReliabilityReport.newId(),
|
||||
appSessionId = appSessionId,
|
||||
timeIso = Instant.now().toString(),
|
||||
kind = classification.kind,
|
||||
owner = classification.owner,
|
||||
severity = ReliabilitySeverity.Error,
|
||||
summary = title,
|
||||
recovery = detail ?: "The failure was handled; retry or review Diagnostics if it continues.",
|
||||
reportRecommended = classification.reportRecommended,
|
||||
technicalDetail = stackTraceText(throwable),
|
||||
routeRole = routeRole,
|
||||
environment = environment(),
|
||||
)
|
||||
writer.execute { runCatching { target.append(report) } }
|
||||
}
|
||||
|
||||
fun reports(context: Context): List<ReliabilityReport> {
|
||||
initialize(context)
|
||||
return store?.readAll().orEmpty()
|
||||
}
|
||||
|
||||
fun pendingCrash(context: Context): ReliabilityReport? =
|
||||
reports(context).lastOrNull { it.kind == ReliabilityKind.FatalCrash && it.pendingReview }
|
||||
|
||||
fun markReviewed(context: Context, reportId: String) {
|
||||
initialize(context)
|
||||
store?.markReviewed(reportId)
|
||||
}
|
||||
|
||||
fun import(context: Context, report: ReliabilityReport) {
|
||||
initialize(context)
|
||||
store?.append(report)
|
||||
}
|
||||
|
||||
fun environment(): ReliabilityEnvironment = ReliabilityEnvironment(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = BuildConfig.VERSION_CODE,
|
||||
flavor = BuildConfig.FLAVOR,
|
||||
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
|
||||
model = Build.MODEL.orEmpty().ifBlank { "?" },
|
||||
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
)
|
||||
|
||||
private fun stackTraceText(throwable: Throwable): String =
|
||||
StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
const val RELIABILITY_SCHEMA_VERSION = 1
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilityKind {
|
||||
FatalCrash,
|
||||
AnrSignal,
|
||||
RecoverableProductError,
|
||||
Connectivity,
|
||||
Authentication,
|
||||
RateLimit,
|
||||
ServiceUnavailable,
|
||||
ExpectedCancellation,
|
||||
UserDenial,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilityOwner(val label: String) {
|
||||
Android("Android"),
|
||||
Dashboard("Dashboard"),
|
||||
Api("API"),
|
||||
Relay("Relay"),
|
||||
UpstreamGateway("Upstream Gateway"),
|
||||
Voice("Voice"),
|
||||
Unknown("Unknown"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilitySeverity { Info, Warning, Error, Fatal }
|
||||
|
||||
@Serializable
|
||||
data class ReliabilityEnvironment(
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Allowlisted local reliability record. There are deliberately no fields for
|
||||
* prompts, messages, profile names, product session IDs, URLs, media, or paths.
|
||||
*/
|
||||
@Serializable
|
||||
data class ReliabilityReport(
|
||||
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
|
||||
val reportId: String,
|
||||
val appSessionId: String,
|
||||
val timeIso: String,
|
||||
val kind: ReliabilityKind,
|
||||
val owner: ReliabilityOwner,
|
||||
val severity: ReliabilitySeverity,
|
||||
val summary: String,
|
||||
val recovery: String,
|
||||
val reportRecommended: Boolean,
|
||||
val technicalDetail: String? = null,
|
||||
val routeRole: String? = null,
|
||||
val environment: ReliabilityEnvironment,
|
||||
val pendingReview: Boolean = false,
|
||||
) {
|
||||
fun shortTitle(): String = summary.lineSequence().firstOrNull().orEmpty().ifBlank {
|
||||
kind.name
|
||||
}.take(90)
|
||||
|
||||
fun versionLine(): String =
|
||||
"${environment.versionName} (code ${environment.versionCode}) ${environment.flavor}"
|
||||
|
||||
fun environmentBlock(): String = buildString {
|
||||
appendLine("- Hermes-Relay version/tag: ${environment.versionName} (code ${environment.versionCode})")
|
||||
appendLine(
|
||||
"- Install surface: " +
|
||||
if (environment.flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
|
||||
)
|
||||
appendLine(
|
||||
"- Android device and OS: ${environment.manufacturer} ${environment.model} — " +
|
||||
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
|
||||
)
|
||||
append("- Connection mode: ${routeRole ?: "unknown"}")
|
||||
}
|
||||
|
||||
/** Exact local review/copy/share payload. Redaction is repeated for legacy defense in depth. */
|
||||
fun toPlainText(): String = ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("Hermes-Relay support information")
|
||||
appendLine("Report: $reportId")
|
||||
appendLine("Session: $appSessionId")
|
||||
appendLine("Time: $timeIso")
|
||||
appendLine("Type: ${kind.name}")
|
||||
appendLine("Owner: ${owner.label}")
|
||||
appendLine("App: ${versionLine()}")
|
||||
appendLine(
|
||||
"Device: ${environment.manufacturer} ${environment.model} — " +
|
||||
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
|
||||
)
|
||||
routeRole?.let { appendLine("Route: $it") }
|
||||
appendLine()
|
||||
appendLine("What happened: $summary")
|
||||
appendLine("Recovery: $recovery")
|
||||
technicalDetail?.let {
|
||||
appendLine()
|
||||
appendLine("Technical detail (redacted)")
|
||||
append(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun newId(prefix: String = "rpt"): String =
|
||||
"$prefix-${UUID.randomUUID().toString().replace("-", "").take(16)}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Old `files/crash/last-crash.json` shape, retained only for one-way migration. */
|
||||
@Serializable
|
||||
data class LegacyCrashSnapshot(
|
||||
val timeIso: String,
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
val threadName: String,
|
||||
val exceptionSummary: String,
|
||||
val stackTrace: String,
|
||||
)
|
||||
|
||||
fun migrateLegacyCrash(
|
||||
old: LegacyCrashSnapshot,
|
||||
reportId: String = ReliabilityReport.newId(),
|
||||
appSessionId: String = ReliabilityReport.newId("legacy"),
|
||||
): ReliabilityReport = ReliabilityReport(
|
||||
reportId = reportId,
|
||||
appSessionId = appSessionId,
|
||||
timeIso = runCatching { Instant.parse(old.timeIso).toString() }.getOrDefault(old.timeIso),
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = ReliabilityRedactor.redact(old.exceptionSummary, 240),
|
||||
recovery = "The app restarted. Work already running on Hermes may still be active.",
|
||||
reportRecommended = true,
|
||||
technicalDetail = ReliabilityRedactor.redact("Thread: ${old.threadName}\n${old.stackTrace}"),
|
||||
environment = ReliabilityEnvironment(
|
||||
old.versionName, old.versionCode, old.flavor, old.manufacturer, old.model,
|
||||
old.androidRelease, old.sdkInt,
|
||||
),
|
||||
pendingReview = true,
|
||||
)
|
||||
|
||||
data class ReliabilityClassification(
|
||||
val kind: ReliabilityKind,
|
||||
val owner: ReliabilityOwner,
|
||||
val reportRecommended: Boolean,
|
||||
val shouldPersist: Boolean,
|
||||
)
|
||||
|
||||
object ReliabilityClassifier {
|
||||
fun classify(throwable: Throwable, context: String? = null): ReliabilityClassification {
|
||||
val message = throwable.message.orEmpty().lowercase()
|
||||
val owner = ownerForContext(context)
|
||||
return when {
|
||||
throwable is CancellationException -> ReliabilityClassification(
|
||||
ReliabilityKind.ExpectedCancellation, owner, reportRecommended = false, shouldPersist = false,
|
||||
)
|
||||
throwable is SecurityException && ("denied" in message || "permission" in message) ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.UserDenial, ReliabilityOwner.Android,
|
||||
reportRecommended = false, shouldPersist = false,
|
||||
)
|
||||
"429" in message || "rate limit" in message || "too many requests" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.RateLimit, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
"401" in message || "403" in message || "unauthorized" in message || "forbidden" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.Authentication, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
throwable is java.net.UnknownHostException ||
|
||||
throwable is java.net.ConnectException ||
|
||||
throwable is java.net.SocketTimeoutException ||
|
||||
"timeout" in message -> ReliabilityClassification(
|
||||
ReliabilityKind.Connectivity, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
"503" in message || "service unavailable" in message || "gateway_draining" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.ServiceUnavailable, owner,
|
||||
reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
else -> ReliabilityClassification(
|
||||
ReliabilityKind.RecoverableProductError, owner,
|
||||
reportRecommended = true, shouldPersist = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ownerForContext(context: String?): ReliabilityOwner = when (context?.lowercase()) {
|
||||
"dashboard", "manage", "dashboard_auth" -> ReliabilityOwner.Dashboard
|
||||
"gateway", "gateway_chat", "upstream_gateway" -> ReliabilityOwner.UpstreamGateway
|
||||
"transcribe", "synthesize", "voice_config", "record", "voice" -> ReliabilityOwner.Voice
|
||||
"pair", "save_and_test", "media_fetch", "relay" -> ReliabilityOwner.Relay
|
||||
"send_message", "load_sessions", "create_session", "api" -> ReliabilityOwner.Api
|
||||
"android", "permission", "ui" -> ReliabilityOwner.Android
|
||||
else -> ReliabilityOwner.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/** Local, deterministic redaction. It runs before persistence and again before export. */
|
||||
object ReliabilityRedactor {
|
||||
const val MAX_TECHNICAL_LENGTH = 8_000
|
||||
private const val HIDDEN = "[hidden]"
|
||||
|
||||
private val secretAssignment = Regex(
|
||||
"""(?i)\b(authorization|bearer|cookie|set-cookie|token|api[_-]?key|session[_-]?token|pairing[_-]?code|password|secret|oauth[_-]?code)\s*[:=]\s*((?:Bearer\s+)?[^\s,;]+)""",
|
||||
)
|
||||
private val sensitiveHeader = Regex("""(?im)^\s*(authorization|cookie|set-cookie)\s*:\s*.+$""")
|
||||
private val standaloneBearer = Regex("""(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+""")
|
||||
private val sensitivePayload = Regex(
|
||||
"""(?i)\b(prompt|message|content|transcript|reasoning|tool[_-]?(args|result)|profile[_-]?name)\s*[:=]\s*([^\r\n]+)""",
|
||||
)
|
||||
private val url = Regex("""(?i)\b(?:https?|wss?)://[^\s)\]}>,]+""")
|
||||
private val ipv4 = Regex("""(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?![\w.])""")
|
||||
private val uuid = Regex("""(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b""")
|
||||
private val email = Regex("""(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b""")
|
||||
private val namedHost = Regex("""(?i)\b(host|hostname)\s*[:=]\s*[^\s,;]+""")
|
||||
private val unresolvedHost = Regex("""(?i)(?:resolve|resolved|host)\s+[\"']([^\"']+)[\"']""")
|
||||
private val windowsPath = Regex("""(?i)\b[A-Z]:\\(?:[^\s\\]+\\)+[^\s]+""")
|
||||
private val unixPrivatePath = Regex("""(?i)(?:/home/|/Users/|/data/user/\d+/|/sdcard/)[^\s]+""")
|
||||
|
||||
fun redact(value: String, maxLength: Int = MAX_TECHNICAL_LENGTH): String {
|
||||
var result = value
|
||||
result = sensitiveHeader.replace(result) { "${it.groupValues[1]}: $HIDDEN" }
|
||||
result = secretAssignment.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = standaloneBearer.replace(result, "Bearer $HIDDEN")
|
||||
result = sensitivePayload.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = url.replace(result, "[url hidden]")
|
||||
result = ipv4.replace(result, "[host hidden]")
|
||||
result = uuid.replace(result, "[id hidden]")
|
||||
result = email.replace(result, "[email hidden]")
|
||||
result = namedHost.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = unresolvedHost.replace(result, "host \"$HIDDEN\"")
|
||||
result = windowsPath.replace(result, "[path hidden]")
|
||||
result = unixPrivatePath.replace(result, "[path hidden]")
|
||||
return if (result.length > maxLength) {
|
||||
result.take(maxLength) + "\n… (truncated)"
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ReliabilityEnvelope(
|
||||
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
|
||||
val reports: List<ReliabilityReport> = emptyList(),
|
||||
)
|
||||
|
||||
/** Pure file store so retention, bounds, and migration behavior are JVM-testable. */
|
||||
class ReliabilityStore(
|
||||
private val file: File,
|
||||
private val maxReports: Int = 20,
|
||||
private val retentionDays: Long = 14,
|
||||
) {
|
||||
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
|
||||
private val lock = Any()
|
||||
|
||||
fun readAll(now: Instant = Instant.now()): List<ReliabilityReport> = synchronized(lock) {
|
||||
val decoded = decode()
|
||||
val retained = prune(decoded, now)
|
||||
if (retained != decoded) write(retained)
|
||||
retained
|
||||
}
|
||||
|
||||
fun append(report: ReliabilityReport, now: Instant = Instant.now()) = synchronized(lock) {
|
||||
write(prune(decode() + sanitize(report), now))
|
||||
}
|
||||
|
||||
fun markReviewed(reportId: String, now: Instant = Instant.now()) = synchronized(lock) {
|
||||
write(
|
||||
prune(
|
||||
decode().map { if (it.reportId == reportId) it.copy(pendingReview = false) else it },
|
||||
now,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitize(report: ReliabilityReport): ReliabilityReport = report.copy(
|
||||
summary = ReliabilityRedactor.redact(report.summary, 240),
|
||||
recovery = ReliabilityRedactor.redact(report.recovery, 240),
|
||||
technicalDetail = report.technicalDetail?.let(ReliabilityRedactor::redact),
|
||||
routeRole = report.routeRole?.let { ReliabilityRedactor.redact(it, 40) },
|
||||
)
|
||||
|
||||
private fun prune(reports: List<ReliabilityReport>, now: Instant): List<ReliabilityReport> {
|
||||
val cutoff = now.minusSeconds(retentionDays * 24 * 60 * 60)
|
||||
return reports
|
||||
.distinctBy { it.reportId }
|
||||
.filter { report -> runCatching { Instant.parse(report.timeIso) >= cutoff }.getOrDefault(true) }
|
||||
.sortedBy { it.timeIso }
|
||||
.takeLast(maxReports.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private fun decode(): List<ReliabilityReport> = runCatching {
|
||||
if (!file.isFile) return emptyList()
|
||||
json.decodeFromString<ReliabilityEnvelope>(file.readText()).reports
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun write(reports: List<ReliabilityReport>) {
|
||||
file.parentFile?.mkdirs()
|
||||
val temp = File(file.parentFile, "${file.name}.tmp")
|
||||
temp.writeText(json.encodeToString(ReliabilityEnvelope(reports = reports)))
|
||||
runCatching {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
file.toPath(),
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}.recoverCatching {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
file.toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
object SupportBundleBuilder {
|
||||
const val MAX_REPORTS = 10
|
||||
|
||||
fun build(reports: List<ReliabilityReport>): String {
|
||||
val selected = reports.sortedByDescending { it.timeIso }.take(MAX_REPORTS)
|
||||
return ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("Hermes-Relay support bundle")
|
||||
appendLine("Local-only export · review before sharing")
|
||||
appendLine("Reports: ${selected.size}")
|
||||
selected.forEachIndexed { index, report ->
|
||||
appendLine()
|
||||
appendLine("===== Report ${index + 1} =====")
|
||||
append(report.toPlainText())
|
||||
appendLine()
|
||||
}
|
||||
},
|
||||
maxLength = 64_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.hermesandroid.relay.runtime
|
||||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import com.hermesandroid.relay.HermesRelayApp
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Application-lifetime owner for the runtime state shared by the normal app UI
|
||||
* and service-started assistant turns.
|
||||
*
|
||||
* The runtime deliberately lives in the main application process. The isolated
|
||||
* `:assistant_session` process exchanges snapshots and lifecycle commands over
|
||||
* the assistant protocol and must never construct a second set of voice
|
||||
* ViewModels or microphone collaborators.
|
||||
*/
|
||||
class HermesProcessRuntime internal constructor(
|
||||
private val application: HermesRelayApp,
|
||||
) {
|
||||
private val viewModelStore = ViewModelStore()
|
||||
private val viewModelProvider = ViewModelProvider(
|
||||
viewModelStore,
|
||||
ViewModelProvider.AndroidViewModelFactory.getInstance(application),
|
||||
)
|
||||
private val initializationMutex = Mutex()
|
||||
private val activationLock = Any()
|
||||
private var activationGeneration = 0L
|
||||
private var currentActivationId: String? = null
|
||||
private var activationJob: Job? = null
|
||||
private val runtimeJob = SupervisorJob()
|
||||
private val binder by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
HermesRuntimeBinder(application, this)
|
||||
}
|
||||
private val _initializationState =
|
||||
MutableStateFlow(HermesRuntimeInitializationState.Uninitialized)
|
||||
|
||||
/**
|
||||
* Process-lifetime scope for runtime binders and state bridges. UI work
|
||||
* should continue to use its lifecycle-aware scopes.
|
||||
*/
|
||||
val coroutineScope: CoroutineScope =
|
||||
CoroutineScope(runtimeJob + Dispatchers.Main.immediate)
|
||||
|
||||
/**
|
||||
* Existing app ViewModels, now owned by the process runtime instead of an
|
||||
* Activity. Each property remains lazy so merely starting the Application
|
||||
* does not initialize network, chat, or microphone state.
|
||||
*/
|
||||
val connectionViewModel: ConnectionViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
viewModelProvider[ConnectionViewModel::class.java]
|
||||
}
|
||||
|
||||
val chatViewModel: ChatViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
viewModelProvider[ChatViewModel::class.java]
|
||||
}
|
||||
|
||||
val voiceViewModel: VoiceViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
viewModelProvider[VoiceViewModel::class.java]
|
||||
}
|
||||
|
||||
val initializationState: StateFlow<HermesRuntimeInitializationState> =
|
||||
_initializationState.asStateFlow()
|
||||
|
||||
val voiceActivationReadiness: StateFlow<HermesVoiceActivationReadiness>
|
||||
get() = binder.voiceActivationReadiness
|
||||
|
||||
val assistantSnapshot: StateFlow<com.hermesandroid.relay.assistant.AssistantSessionSnapshot>
|
||||
get() = binder.assistantSnapshot
|
||||
|
||||
/**
|
||||
* Process-owned collaborators still consumed by Compose screens. Callers
|
||||
* must await [ensureInitialized] before reading [relayVoiceClient].
|
||||
*/
|
||||
val relayVoiceClient: RelayVoiceClient
|
||||
get() = binder.requireRelayVoiceClient()
|
||||
|
||||
val voicePreferences: VoicePreferencesRepository
|
||||
get() = binder.voicePreferencesRepository
|
||||
|
||||
val voiceSettings: StateFlow<VoiceSettings>
|
||||
get() = binder.voiceSettings
|
||||
|
||||
/**
|
||||
* Runs the non-UI runtime wiring exactly once. A failed attempt returns the
|
||||
* runtime to [HermesRuntimeInitializationState.Uninitialized] so a later
|
||||
* foreground or assistant activation can retry cleanly.
|
||||
*/
|
||||
suspend fun ensureInitialized() {
|
||||
if (_initializationState.value == HermesRuntimeInitializationState.Ready) return
|
||||
initializationMutex.withLock {
|
||||
if (_initializationState.value == HermesRuntimeInitializationState.Ready) return
|
||||
_initializationState.value = HermesRuntimeInitializationState.Initializing
|
||||
try {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
binder.bind()
|
||||
}
|
||||
_initializationState.value = HermesRuntimeInitializationState.Ready
|
||||
} catch (failure: Throwable) {
|
||||
_initializationState.value = HermesRuntimeInitializationState.Uninitialized
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestVoiceActivation(
|
||||
activationId: String,
|
||||
startNewSession: Boolean = true,
|
||||
timeoutMs: Long = DEFAULT_VOICE_ACTIVATION_TIMEOUT_MS,
|
||||
onFailure: (Throwable) -> Unit = {},
|
||||
) {
|
||||
val nextJob = synchronized(activationLock) {
|
||||
// The assistant session process can replay the same activation while
|
||||
// being recreated. That replay must not re-arm the recorder.
|
||||
if (currentActivationId == activationId) return
|
||||
|
||||
activationJob?.cancel()
|
||||
activationGeneration += 1
|
||||
val generation = activationGeneration
|
||||
currentActivationId = activationId
|
||||
coroutineScope.launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
ensureInitialized()
|
||||
binder.activateVoice(
|
||||
startNewSession = startNewSession,
|
||||
timeoutMs = timeoutMs,
|
||||
isCurrent = {
|
||||
synchronized(activationLock) {
|
||||
activationGeneration == generation &&
|
||||
currentActivationId == activationId
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (failure: Throwable) {
|
||||
onFailure(failure)
|
||||
}
|
||||
}.also { activationJob = it }
|
||||
}
|
||||
nextJob.start()
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
synchronized(activationLock) {
|
||||
activationGeneration += 1
|
||||
currentActivationId = null
|
||||
activationJob?.cancel()
|
||||
activationJob = null
|
||||
}
|
||||
if (_initializationState.value != HermesRuntimeInitializationState.Uninitialized) {
|
||||
binder.cancelVoice()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Android processes are torn down as a unit. This explicit
|
||||
* cleanup seam exists for local/instrumentation hosts that construct more
|
||||
* than one Application instance in a single VM.
|
||||
*/
|
||||
internal fun clear() {
|
||||
synchronized(activationLock) {
|
||||
activationGeneration += 1
|
||||
currentActivationId = null
|
||||
activationJob?.cancel()
|
||||
activationJob = null
|
||||
}
|
||||
if (_initializationState.value != HermesRuntimeInitializationState.Uninitialized) {
|
||||
binder.clear()
|
||||
}
|
||||
coroutineScope.cancel()
|
||||
viewModelStore.clear()
|
||||
_initializationState.value = HermesRuntimeInitializationState.Uninitialized
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_VOICE_ACTIVATION_TIMEOUT_MS = 20_000L
|
||||
}
|
||||
}
|
||||
|
||||
enum class HermesRuntimeInitializationState {
|
||||
Uninitialized,
|
||||
Initializing,
|
||||
Ready,
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package com.hermesandroid.relay.runtime
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.HermesRelayApp
|
||||
import com.hermesandroid.relay.assistant.AssistantAppSessionState
|
||||
import com.hermesandroid.relay.assistant.AssistantSessionPhase
|
||||
import com.hermesandroid.relay.assistant.AssistantSessionProtocol
|
||||
import com.hermesandroid.relay.assistant.AssistantSessionSnapshot
|
||||
import com.hermesandroid.relay.assistant.AssistantVoiceCommandCoordinator
|
||||
import com.hermesandroid.relay.audio.BargeInListener
|
||||
import com.hermesandroid.relay.audio.RealtimePcmPlayer
|
||||
import com.hermesandroid.relay.audio.VadEngine
|
||||
import com.hermesandroid.relay.audio.VoicePlayer
|
||||
import com.hermesandroid.relay.audio.VoiceRecorder
|
||||
import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BargeInPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.StandardHermesVoiceClient
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Process-lifetime composition root for the chat and voice state machines.
|
||||
*
|
||||
* This owns only runtime behavior. Navigation, Activity lifecycle revalidation,
|
||||
* and visual presentation remain UI responsibilities.
|
||||
*/
|
||||
internal class HermesRuntimeBinder(
|
||||
private val application: HermesRelayApp,
|
||||
private val runtime: HermesProcessRuntime,
|
||||
) {
|
||||
private val jobs = mutableListOf<Job>()
|
||||
private var bound = false
|
||||
|
||||
val voicePreferencesRepository = VoicePreferencesRepository(application)
|
||||
private val _voiceSettings = MutableStateFlow(VoiceSettings())
|
||||
val voiceSettings: StateFlow<VoiceSettings> = _voiceSettings.asStateFlow()
|
||||
private lateinit var relayVoiceClient: RelayVoiceClient
|
||||
|
||||
private val _voiceActivationReadiness =
|
||||
MutableStateFlow<HermesVoiceActivationReadiness>(HermesVoiceActivationReadiness.Initializing)
|
||||
val voiceActivationReadiness: StateFlow<HermesVoiceActivationReadiness> =
|
||||
_voiceActivationReadiness.asStateFlow()
|
||||
private val voiceSettingsHydrated = MutableStateFlow(false)
|
||||
private val profileContextReady = MutableStateFlow(false)
|
||||
|
||||
private val _assistantSnapshot = MutableStateFlow(AssistantSessionSnapshot())
|
||||
val assistantSnapshot: StateFlow<AssistantSessionSnapshot> = _assistantSnapshot.asStateFlow()
|
||||
|
||||
fun bind() {
|
||||
if (bound) return
|
||||
|
||||
val connection = runtime.connectionViewModel
|
||||
val chat = runtime.chatViewModel
|
||||
val voice = runtime.voiceViewModel
|
||||
|
||||
relayVoiceClient = RelayVoiceClient(
|
||||
context = application,
|
||||
okHttpClient = OkHttpClient.Builder()
|
||||
.readTimeout(2, TimeUnit.MINUTES)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
relayUrlProvider = { connection.effectiveRelayUrl.value },
|
||||
relayRouteChangesProvider = {
|
||||
connection.activeEndpoint.mapNotNull { it?.relay?.url }
|
||||
},
|
||||
routeProbeRequester = connection::probeNow,
|
||||
profileNameProvider = {
|
||||
AgentDisplay.profileRequestName(connection.selectedProfile.value?.name)
|
||||
},
|
||||
sessionTokenProvider = {
|
||||
(connection.authState.value as? AuthState.Paired)?.token
|
||||
},
|
||||
apiBearerTokenProvider = connection::getApiKey,
|
||||
)
|
||||
val standardVoiceClient = StandardHermesVoiceClient(
|
||||
context = application,
|
||||
dashboardHttpClientProvider = connection::dashboardHttpClientForActive,
|
||||
dashboardUrlProvider = connection::activeDashboardUrl,
|
||||
profileProvider = {
|
||||
AgentDisplay.profileRequestName(connection.selectedProfile.value?.name)
|
||||
},
|
||||
)
|
||||
val voiceAudioClient = AutoVoiceAudioClient(
|
||||
standardClient = standardVoiceClient,
|
||||
relayClient = RelayVoiceAudioClientAdapter(
|
||||
relayVoiceClient,
|
||||
enhancedOverridesProvider = {
|
||||
EnhancedVoiceOverrides.fromSettings(voiceSettings.value)
|
||||
},
|
||||
),
|
||||
routeProvider = {
|
||||
VoiceAudioRoute.fromStorage(voiceSettings.value.audioRoute)
|
||||
},
|
||||
standardReadyProvider = { connection.standardVoiceReady.value },
|
||||
relayReadyProvider = { connection.relayVoiceReady.value },
|
||||
)
|
||||
|
||||
voice.initialize(
|
||||
voiceClient = relayVoiceClient,
|
||||
voiceAudioClient = voiceAudioClient,
|
||||
chatViewModel = chat,
|
||||
recorder = VoiceRecorder(application, voice.viewModelScope),
|
||||
player = VoicePlayer(application),
|
||||
realtimePcmPlayer = RealtimePcmPlayer(application),
|
||||
sfxPlayer = VoiceSfxPlayer(application),
|
||||
bridgeMultiplexer = connection.multiplexer,
|
||||
localBridgeDispatcher = if (BuildFlavor.isSideload) {
|
||||
connection.bridgeCommandHandler::handleLocalCommand
|
||||
} else {
|
||||
null
|
||||
},
|
||||
voicePreferences = voicePreferencesRepository,
|
||||
voiceRelayPreflight = connection::verifyRelayForVoice,
|
||||
voiceHandoffReporter = connection::recordVoiceHandoff,
|
||||
bargeInPreferences = BargeInPreferencesRepository(application),
|
||||
vadEngineFactory = { VadEngine(application) },
|
||||
bargeInListenerFactory = { vad, audioSessionIdProvider ->
|
||||
BargeInListener.create(application, vad, audioSessionIdProvider)
|
||||
},
|
||||
)
|
||||
|
||||
bindChatDependencies()
|
||||
bindProcessCollectors()
|
||||
bound = true
|
||||
}
|
||||
|
||||
private fun bindChatDependencies() {
|
||||
val connection = runtime.connectionViewModel
|
||||
val chat = runtime.chatViewModel
|
||||
val voice = runtime.voiceViewModel
|
||||
|
||||
chat.initialize(connection.chatApiClient.value, connection.chatHandler)
|
||||
chat.initializeMedia(
|
||||
context = application,
|
||||
relayHttpClient = connection.relayHttpClient,
|
||||
mediaSettingsRepo = connection.mediaSettingsRepo,
|
||||
mediaCacheWriter = connection.mediaCacheWriter,
|
||||
)
|
||||
chat.setSelectedProfileProvider { connection.selectedProfile.value }
|
||||
chat.setIsolatedProfileApiProvider { connection.selectedProfileUsesIsolatedApiRoute() }
|
||||
chat.setSessionProfileNameProvider { connection.effectiveSessionProfileName.value }
|
||||
chat.setEffectiveProfileProvider {
|
||||
AgentDisplay.effectiveProfile(
|
||||
selectedProfile = connection.selectedProfile.value,
|
||||
profiles = connection.agentProfiles.value,
|
||||
)
|
||||
}
|
||||
chat.setDisplayProfileProvider { connection.effectiveDisplayProfile.value }
|
||||
chat.setDisplayAliasProvider { connection.profileDisplayAlias.value }
|
||||
chat.setProfileSessionLister { connection.listProfileScopedSessions() }
|
||||
chat.setProfileMessageLoader(connection::loadProfileScopedMessages)
|
||||
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
|
||||
chat.profileSessionDeleter = connection::deleteProfileScopedSession
|
||||
chat.profileSessionRenamer = connection::renameProfileScopedSession
|
||||
chat.onSessionChanged = connection::saveLastSessionId
|
||||
chat.setDemoModeWiring(
|
||||
isDemo = { connection.isDemoMode.value },
|
||||
handler = { connection.chatHandler },
|
||||
)
|
||||
voice.chatNoticeSink = connection.chatHandler::addSystemNotice
|
||||
|
||||
connection.registerStreamCancelCallback(chat::cancelStream)
|
||||
if (BuildFlavor.isSideload) {
|
||||
connection.registerVoiceStopCallback(voice::exitVoiceMode)
|
||||
}
|
||||
chat.observeConnectionSwitches(connection.connectionSwitchEvents)
|
||||
}
|
||||
|
||||
private fun bindProcessCollectors() {
|
||||
val connection = runtime.connectionViewModel
|
||||
val chat = runtime.chatViewModel
|
||||
val voice = runtime.voiceViewModel
|
||||
var boundCatalogConnectionId: String? = null
|
||||
var lastAcquiredDashboardUrl: String? = null
|
||||
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
connection.authState.collect { auth ->
|
||||
if (auth is AuthState.Paired) connection.reconnectIfStale()
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
combine(connection.chatApiClient, connection.activeConnectionId) { client, id ->
|
||||
client to id
|
||||
}.collect { (client, connectionId) ->
|
||||
if (chat.boundHandler === connection.chatHandler) {
|
||||
if (boundCatalogConnectionId != connectionId) {
|
||||
chat.resetConnectionCatalogs()
|
||||
chat.updateApiClient(null)
|
||||
} else {
|
||||
chat.updateApiClient(client)
|
||||
}
|
||||
} else {
|
||||
chat.initialize(client, connection.chatHandler)
|
||||
}
|
||||
boundCatalogConnectionId = connectionId
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
chat.isStreaming.collect(connection::setChatStreaming)
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
combine(
|
||||
connection.activeConnectionId,
|
||||
connection.selectedProfile,
|
||||
) { connectionId, profile ->
|
||||
connectionId to AgentDisplay.profileRequestName(profile?.name)
|
||||
}.distinctUntilChanged().collectLatest { (connectionId, profileName) ->
|
||||
voiceSettingsHydrated.value = false
|
||||
voice.setVoicePrefsConnection(connectionId)
|
||||
voicePreferencesRepository.setActiveScope(connectionId, profileName)
|
||||
voice.onProfileChanged(profileName)
|
||||
// Collect the repository flow directly. Its first value is read
|
||||
// from DataStore for the selected scope; unlike a seeded
|
||||
// StateFlow, it cannot falsely mark default settings hydrated.
|
||||
voicePreferencesRepository.settings.collect { settings ->
|
||||
_voiceSettings.value = settings
|
||||
voiceSettingsHydrated.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
combine(
|
||||
connection.streamingEndpoint,
|
||||
connection.serverCapabilities,
|
||||
connection.gatewayAvailability,
|
||||
connection.effectiveDashboardUrl,
|
||||
) { preference, _, gateway, dashboardUrl ->
|
||||
Triple(preference, gateway, dashboardUrl)
|
||||
}.collectLatest { (preference, _, dashboardUrl) ->
|
||||
if (
|
||||
lastAcquiredDashboardUrl != null &&
|
||||
lastAcquiredDashboardUrl != dashboardUrl
|
||||
) {
|
||||
delay(GATEWAY_ROUTE_SETTLE_MS)
|
||||
}
|
||||
val resolved = connection.resolveStreamingEndpoint(preference)
|
||||
chat.streamingEndpoint = resolved
|
||||
chat.sseFallbackEndpoint = connection.resolveSseStreamingEndpoint()
|
||||
chat.updateGatewayClient(
|
||||
if (resolved == "gateway") connection.activeGatewayChatClient() else null,
|
||||
)
|
||||
lastAcquiredDashboardUrl = dashboardUrl
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
val contextInputs = combine(
|
||||
connection.chatReady,
|
||||
connection.activeConnectionId,
|
||||
connection.effectiveSessionProfileName,
|
||||
connection.lastSessionId,
|
||||
) { ready, connectionId, profileName, sessionId ->
|
||||
ProfileContextInputs(ready, connectionId, profileName, sessionId)
|
||||
}
|
||||
combine(contextInputs, connection.profileSelectionSettled) { inputs, settled ->
|
||||
inputs.copy(profileSelectionSettled = settled)
|
||||
}.collectLatest { inputs ->
|
||||
profileContextReady.value = false
|
||||
if (!inputs.chatReady) return@collectLatest
|
||||
if (!inputs.profileSelectionSettled) delay(PROFILE_SETTLE_BACKSTOP_MS)
|
||||
else delay(PROFILE_CONTEXT_COALESCE_MS)
|
||||
chat.switchProfileContext(
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = inputs.connectionId,
|
||||
profileName = inputs.profileName,
|
||||
),
|
||||
sessionId = inputs.sessionId,
|
||||
)
|
||||
chat.refreshSessions()
|
||||
profileContextReady.value = true
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
connection.parseToolAnnotations.collect { enabled ->
|
||||
connection.chatHandler.parseToolAnnotations = enabled
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
connection.showSystemMessages.collect { enabled ->
|
||||
connection.chatHandler.showSystemMarkers = enabled
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
val routeReadiness = combine(
|
||||
voiceSettings,
|
||||
connection.chatReady,
|
||||
connection.standardVoiceAvailability,
|
||||
connection.relayVoiceReady,
|
||||
connection.profileSelectionSettled,
|
||||
) { settings, chatReady, standard, relayReady, profileSettled ->
|
||||
resolveVoiceActivationReadiness(
|
||||
settings,
|
||||
chatReady,
|
||||
standard,
|
||||
relayReady,
|
||||
profileSettled,
|
||||
)
|
||||
}
|
||||
combine(
|
||||
routeReadiness,
|
||||
voiceSettingsHydrated,
|
||||
profileContextReady,
|
||||
) { readiness, settingsReady, contextReady ->
|
||||
when {
|
||||
!settingsReady ->
|
||||
HermesVoiceActivationReadiness.Waiting("Loading voice settings")
|
||||
!contextReady &&
|
||||
(readiness as? HermesVoiceActivationReadiness.Ready)?.route !=
|
||||
HermesVoiceActivationRoute.Realtime ->
|
||||
HermesVoiceActivationReadiness.Waiting("Restoring the Hermes chat context")
|
||||
else -> readiness
|
||||
}
|
||||
}.collect { readiness ->
|
||||
_voiceActivationReadiness.value = readiness
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
voice.uiState.collect { state ->
|
||||
val snapshot = AssistantSessionProtocol.snapshotFromVoiceState(state)
|
||||
_assistantSnapshot.value = snapshot
|
||||
if (!AssistantAppSessionState.active.value) return@collect
|
||||
if (state.voiceMode) AssistantAppSessionState.markVoiceStarted()
|
||||
if (state.voiceMode || AssistantAppSessionState.hasVoiceStarted()) {
|
||||
AssistantSessionProtocol.publish(application, snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
jobs += runtime.coroutineScope.launch {
|
||||
AssistantVoiceCommandCoordinator.cancelRequest.collect { request ->
|
||||
request ?: return@collect
|
||||
cancelVoice()
|
||||
AssistantVoiceCommandCoordinator.consume(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun activateVoice(
|
||||
startNewSession: Boolean,
|
||||
timeoutMs: Long,
|
||||
isCurrent: () -> Boolean,
|
||||
) {
|
||||
check(bound) { "Hermes runtime is not initialized" }
|
||||
val connection = runtime.connectionViewModel
|
||||
val chat = runtime.chatViewModel
|
||||
val voice = runtime.voiceViewModel
|
||||
|
||||
connection.reconnectIfStale()
|
||||
connection.revalidate()
|
||||
val readiness = withTimeout(timeoutMs) {
|
||||
voiceActivationReadiness.first {
|
||||
it is HermesVoiceActivationReadiness.Ready ||
|
||||
it is HermesVoiceActivationReadiness.Blocked
|
||||
}
|
||||
}
|
||||
if (readiness is HermesVoiceActivationReadiness.Blocked) {
|
||||
error(readiness.reason)
|
||||
}
|
||||
readiness as HermesVoiceActivationReadiness.Ready
|
||||
|
||||
currentCoroutineContext().ensureActive()
|
||||
check(isCurrent()) { "Assistant activation was superseded" }
|
||||
// Re-apply scope before entry. The readiness collector already observed
|
||||
// the scope's resolved settings, so Realtime prewarm cannot use defaults.
|
||||
voice.setVoicePrefsConnection(connection.activeConnectionId.value)
|
||||
voice.onProfileChanged(
|
||||
AgentDisplay.profileRequestName(connection.selectedProfile.value?.name)
|
||||
)
|
||||
if (startNewSession) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
check(isCurrent()) { "Assistant activation was superseded" }
|
||||
chat.createNewChat()
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
check(isCurrent()) { "Assistant activation was superseded" }
|
||||
voice.enterVoiceMode()
|
||||
currentCoroutineContext().ensureActive()
|
||||
check(isCurrent()) { "Assistant activation was superseded" }
|
||||
voice.startListening()
|
||||
check(voice.uiState.value.state == VoiceState.Listening) {
|
||||
voice.uiState.value.error ?: "Voice recorder did not enter Listening"
|
||||
}
|
||||
_voiceActivationReadiness.value = readiness
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
runtime.voiceViewModel.exitVoiceMode()
|
||||
}
|
||||
|
||||
fun requireRelayVoiceClient(): RelayVoiceClient {
|
||||
check(bound && ::relayVoiceClient.isInitialized) {
|
||||
"Call HermesProcessRuntime.ensureInitialized() before using voice clients"
|
||||
}
|
||||
return relayVoiceClient
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
jobs.forEach { it.cancel() }
|
||||
jobs.clear()
|
||||
bound = false
|
||||
_voiceActivationReadiness.value = HermesVoiceActivationReadiness.Initializing
|
||||
_assistantSnapshot.value = AssistantSessionSnapshot(phase = AssistantSessionPhase.Closed)
|
||||
}
|
||||
|
||||
private data class ProfileContextInputs(
|
||||
val chatReady: Boolean,
|
||||
val connectionId: String?,
|
||||
val profileName: String?,
|
||||
val sessionId: String?,
|
||||
val profileSelectionSettled: Boolean = false,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val PROFILE_SETTLE_BACKSTOP_MS = 2_500L
|
||||
const val PROFILE_CONTEXT_COALESCE_MS = 160L
|
||||
const val GATEWAY_ROUTE_SETTLE_MS = 750L
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface HermesVoiceActivationReadiness {
|
||||
data object Initializing : HermesVoiceActivationReadiness
|
||||
data class Waiting(val reason: String) : HermesVoiceActivationReadiness
|
||||
data class Ready(val route: HermesVoiceActivationRoute) : HermesVoiceActivationReadiness
|
||||
data class Blocked(val reason: String) : HermesVoiceActivationReadiness
|
||||
}
|
||||
|
||||
enum class HermesVoiceActivationRoute {
|
||||
Standard,
|
||||
RelayAudio,
|
||||
Realtime,
|
||||
}
|
||||
|
||||
internal fun resolveVoiceActivationReadiness(
|
||||
settings: VoiceSettings,
|
||||
chatReady: Boolean,
|
||||
standardAvailability: StandardVoiceAvailability,
|
||||
relayReady: Boolean,
|
||||
profileSettled: Boolean,
|
||||
): HermesVoiceActivationReadiness {
|
||||
if (!profileSettled) {
|
||||
return HermesVoiceActivationReadiness.Waiting("Loading the selected Hermes profile")
|
||||
}
|
||||
return when (VoiceEngineMode.fromStorage(settings.engineMode)) {
|
||||
VoiceEngineMode.RealtimeAgent -> {
|
||||
if (relayReady) {
|
||||
HermesVoiceActivationReadiness.Ready(HermesVoiceActivationRoute.Realtime)
|
||||
} else {
|
||||
HermesVoiceActivationReadiness.Waiting("Waiting for the Relay realtime route")
|
||||
}
|
||||
}
|
||||
VoiceEngineMode.HermesVoiceOutput -> {
|
||||
if (!chatReady) {
|
||||
return HermesVoiceActivationReadiness.Waiting("Waiting for Hermes chat")
|
||||
}
|
||||
when (VoiceAudioRoute.fromStorage(settings.audioRoute)) {
|
||||
VoiceAudioRoute.Relay -> if (relayReady) {
|
||||
HermesVoiceActivationReadiness.Ready(
|
||||
HermesVoiceActivationRoute.RelayAudio
|
||||
)
|
||||
} else {
|
||||
HermesVoiceActivationReadiness.Waiting("Waiting for Relay voice")
|
||||
}
|
||||
VoiceAudioRoute.Standard -> standardVoiceReadiness(standardAvailability)
|
||||
VoiceAudioRoute.Auto -> when {
|
||||
relayReady -> HermesVoiceActivationReadiness.Ready(
|
||||
HermesVoiceActivationRoute.RelayAudio
|
||||
)
|
||||
standardAvailability == StandardVoiceAvailability.Ready ->
|
||||
HermesVoiceActivationReadiness.Ready(
|
||||
HermesVoiceActivationRoute.Standard
|
||||
)
|
||||
else -> standardVoiceReadiness(standardAvailability)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun standardVoiceReadiness(
|
||||
availability: StandardVoiceAvailability,
|
||||
): HermesVoiceActivationReadiness = when (availability) {
|
||||
StandardVoiceAvailability.Ready ->
|
||||
HermesVoiceActivationReadiness.Ready(HermesVoiceActivationRoute.Standard)
|
||||
StandardVoiceAvailability.SignInRequired ->
|
||||
HermesVoiceActivationReadiness.Blocked(
|
||||
"Vanilla Hermes voice needs dashboard sign-in in Manage"
|
||||
)
|
||||
StandardVoiceAvailability.Unsupported ->
|
||||
HermesVoiceActivationReadiness.Blocked(
|
||||
"This Hermes dashboard does not expose the upstream audio routes"
|
||||
)
|
||||
StandardVoiceAvailability.Unreachable ->
|
||||
HermesVoiceActivationReadiness.Waiting("Waiting for the Hermes dashboard")
|
||||
StandardVoiceAvailability.Unknown ->
|
||||
HermesVoiceActivationReadiness.Waiting("Checking Vanilla Hermes voice")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+74
-44
@@ -96,7 +96,6 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.asBadgeState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -216,17 +215,45 @@ fun ActiveCardRelayStatusSection(
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
|
||||
val connectedLabel = stringResource(R.string.conn_info_connected)
|
||||
val relayStatusText = when (relayRowState.phase) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> stringResource(R.string.relay_state_unavailable)
|
||||
RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
}
|
||||
val relayRoleLabel = relayRowState.activeEndpointRole
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { role ->
|
||||
when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
else -> role
|
||||
}
|
||||
}
|
||||
val relayStatusWithRole = when (relayRowState.phase) {
|
||||
RelayUiState.Connected,
|
||||
RelayUiState.Connecting,
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> relayRoleLabel
|
||||
?.let { "$relayStatusText \u00B7 $it" }
|
||||
?: relayStatusText
|
||||
RelayUiState.NotConfigured,
|
||||
RelayUiState.Expired -> relayStatusText
|
||||
}
|
||||
|
||||
// ADR 24: relayRowState carries both the phase and the active endpoint
|
||||
// role. statusText appends " · <Role>" when the resolver has picked one.
|
||||
// ADR 24: keep the selected route visible without changing the Relay
|
||||
// phase vocabulary shared with the rest of Settings.
|
||||
ConnectionStatusRow(
|
||||
label = stringResource(R.string.active_section_relay),
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = connectedLabel),
|
||||
statusText = relayStatusWithRole,
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
connectionViewModel.reconnectIfStale()
|
||||
Toast.makeText(
|
||||
context,
|
||||
reconnectingRelayToast,
|
||||
@@ -240,20 +267,15 @@ fun ActiveCardRelayStatusSection(
|
||||
)
|
||||
|
||||
// Pre-resolve strings for Session status
|
||||
val pairedLabel = stringResource(R.string.conn_info_paired)
|
||||
val pairingLabel = stringResource(R.string.conn_info_pairing)
|
||||
val unpairedLabel = stringResource(R.string.conn_info_unpaired)
|
||||
val failedReasonLabel = stringResource(R.string.active_section_failed_reason)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = stringResource(R.string.active_section_session),
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> pairedLabel
|
||||
is AuthState.Pairing -> pairingLabel
|
||||
is AuthState.Unpaired -> unpairedLabel
|
||||
is AuthState.Failed -> failedReasonLabel.format((authState as AuthState.Failed).reason)
|
||||
is AuthState.Paired -> stringResource(R.string.relay_state_ready)
|
||||
is AuthState.Pairing -> stringResource(R.string.conn_info_pairing)
|
||||
is AuthState.Unpaired -> stringResource(R.string.relay_state_optional)
|
||||
is AuthState.Failed -> stringResource(R.string.relay_state_needs_repair)
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -281,7 +303,6 @@ fun ActiveCardFeaturesSection(
|
||||
connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
|
||||
@@ -347,29 +368,27 @@ fun ActiveCardFeaturesSection(
|
||||
StandardVoiceAvailability.Unknown -> CapabilityTone.Neutral
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||
relayReady -> stringResource(R.string.active_section_ready)
|
||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||
else -> stringResource(R.string.active_section_configured)
|
||||
val relayValue = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
RelayUiState.Connected -> stringResource(R.string.relay_state_ready)
|
||||
RelayUiState.Connecting -> stringResource(R.string.relay_state_reconnecting)
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected -> stringResource(R.string.relay_state_unavailable)
|
||||
RelayUiState.Expired -> stringResource(R.string.relay_state_needs_repair)
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayConfigured -> CapabilityTone.Neutral
|
||||
relayReady -> CapabilityTone.Good
|
||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Info
|
||||
val relayTone = when (relayUiState) {
|
||||
RelayUiState.NotConfigured -> CapabilityTone.Neutral
|
||||
RelayUiState.Connected -> CapabilityTone.Good
|
||||
RelayUiState.Connecting -> CapabilityTone.Info
|
||||
RelayUiState.Stale,
|
||||
RelayUiState.Disconnected,
|
||||
RelayUiState.Expired -> CapabilityTone.Warning
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
}
|
||||
val terminalTone = when {
|
||||
authState is AuthState.Paired -> CapabilityTone.Good
|
||||
relayConfigured && authState !is AuthState.Paired -> CapabilityTone.Info
|
||||
else -> CapabilityTone.Neutral
|
||||
}
|
||||
// Terminal rides the same Relay session, so it must never contradict the
|
||||
// Relay tools row with a second, independently-derived directive.
|
||||
val terminalValue = relayValue
|
||||
val terminalTone = relayTone
|
||||
|
||||
val proxyValue = if (secureProxyAdvertised) stringResource(R.string.active_section_available) else stringResource(R.string.active_section_not_advertised)
|
||||
val proxyTone = if (secureProxyAdvertised) CapabilityTone.Good else CapabilityTone.Neutral
|
||||
@@ -1439,6 +1458,7 @@ fun ActiveCardSecurityPosture(
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
var showSecurityDetails by remember { mutableStateOf(false) }
|
||||
@@ -1450,11 +1470,13 @@ fun ActiveCardSecurityPosture(
|
||||
dashboardUrl.startsWith("http://", ignoreCase = true) -> "HTTP"
|
||||
else -> stringResource(R.string.active_section_not_configured)
|
||||
}
|
||||
val pairedLabel = if (currentPairedSession != null) {
|
||||
stringResource(R.string.active_section_paired)
|
||||
} else {
|
||||
stringResource(R.string.active_section_not_paired)
|
||||
val pairedLabel = when (authState) {
|
||||
is AuthState.Paired -> stringResource(R.string.relay_state_ready)
|
||||
AuthState.Pairing -> stringResource(R.string.relay_state_reconnecting)
|
||||
is AuthState.Failed -> stringResource(R.string.relay_state_needs_repair)
|
||||
AuthState.Unpaired -> stringResource(R.string.relay_state_optional)
|
||||
}
|
||||
val relaySessionUsable = authState is AuthState.Paired
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Surface(
|
||||
@@ -1540,7 +1562,7 @@ fun ActiveCardSecurityPosture(
|
||||
icon = Icons.Filled.Link,
|
||||
label = stringResource(R.string.active_section_relay_session),
|
||||
value = pairedLabel,
|
||||
positive = currentPairedSession != null,
|
||||
positive = relaySessionUsable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1555,14 +1577,22 @@ fun ActiveCardSecurityPosture(
|
||||
SecurityAccessRow(
|
||||
icon = Icons.Filled.Devices,
|
||||
label = stringResource(R.string.active_section_paired_devices),
|
||||
value = stringResource(R.string.active_section_device_count, pairedDevices.size),
|
||||
value = if (relaySessionUsable) {
|
||||
stringResource(R.string.active_section_device_count, pairedDevices.size)
|
||||
} else {
|
||||
pairedLabel
|
||||
},
|
||||
onClick = onNavigateToPairedDevices,
|
||||
)
|
||||
HorizontalDivider()
|
||||
SecurityAccessRow(
|
||||
icon = Icons.Filled.Schedule,
|
||||
label = stringResource(R.string.active_section_session_activity),
|
||||
value = stringResource(R.string.active_section_last_checked_just_now),
|
||||
value = if (relaySessionUsable) {
|
||||
stringResource(R.string.active_section_last_checked_just_now)
|
||||
} else {
|
||||
pairedLabel
|
||||
},
|
||||
onClick = onNavigateToPairedDevices,
|
||||
)
|
||||
}
|
||||
@@ -1579,7 +1609,7 @@ fun ActiveCardSecurityPosture(
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onRevokeRelay,
|
||||
enabled = currentPairedSession != null,
|
||||
enabled = relaySessionUsable && currentPairedSession != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.LinkOff, contentDescription = null)
|
||||
|
||||
@@ -78,6 +78,7 @@ import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
@@ -501,12 +502,9 @@ private fun CleanModeComposer(
|
||||
* the caller as plain UI-local state; this is a presentation over the same
|
||||
* conversation, not new ViewModel state.
|
||||
*
|
||||
* Honors [animationEnabled], OS reduce-motion, and TalkBack: the sphere
|
||||
* renders a static frame and the text stays readable + announced when motion
|
||||
* is suppressed.
|
||||
*
|
||||
* The avatar is rendered through the [LocalAgentAvatar] seam (WP-C2), so clean
|
||||
* mode gets future "pets" for free alongside chat and the voice overlay.
|
||||
* Honors the ambient-visualization preference independently from motion.
|
||||
* When visible, [animationEnabled] and OS reduce-motion pause the sphere on a
|
||||
* static frame; TalkBack keeps the text readable and announced.
|
||||
*/
|
||||
@Composable
|
||||
fun CleanChatMode(
|
||||
@@ -522,6 +520,7 @@ fun CleanChatMode(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val backgroundVisualizationEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val sphereAnimated = animationEnabled && motion.osAnimations
|
||||
// Faded text is unreadable to touch exploration, so the text path goes
|
||||
// static (readable + announced) whenever TalkBack is exploring.
|
||||
@@ -608,29 +607,32 @@ fun CleanChatMode(
|
||||
// sphere rises toward the top third.
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Bounded, centered sphere — a fixed size so the group grows via the
|
||||
// text, sliding the sphere upward as the conversation lengthens.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(sphereHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// The ambient sphere is optional. Hiding it collapses the visual's
|
||||
// slot so the conversation remains centered instead of leaving a
|
||||
// large blank region above the text.
|
||||
if (backgroundVisualizationEnabled) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.semantics { contentDescription = sphereDescription },
|
||||
.fillMaxWidth()
|
||||
.height(sphereHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = sphereState,
|
||||
intensity = streamingIntensity,
|
||||
toolCallBurst = toolCallBurst,
|
||||
// Pin to a still frame when motion is suppressed.
|
||||
paused = !sphereAnimated,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.semantics { contentDescription = sphereDescription },
|
||||
) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = sphereState,
|
||||
intensity = streamingIntensity,
|
||||
toolCallBurst = toolCallBurst,
|
||||
// Pin to a still frame when motion is suppressed.
|
||||
paused = !sphereAnimated,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -130,6 +132,12 @@ data class ChatInputPickerControl(
|
||||
* (DataStore flag owned by the caller, consumed via [onVoiceHintShown]).
|
||||
* - [purpleGlow] on the trailing button (dark theme only) when it is an
|
||||
* enabled SEND — the bar's one flourish, exactly as before.
|
||||
* - [topContent] lets an active mode share the composer's outer surface.
|
||||
* Conversation voice uses it for the dock and sets [suppressVoiceTrailing]
|
||||
* so the dock remains the only idle/stop voice action; typed send/steer/
|
||||
* queue actions still render normally.
|
||||
* - [topContentVisible] expands or collapses that shared content from the
|
||||
* composer edge instead of abruptly replacing the input layout.
|
||||
*
|
||||
* [onSend] fires for SEND, STEER, and QUEUE — the caller already encoded
|
||||
* the meaning in the state it passed; [onVoice]/[onStop] for theirs.
|
||||
@@ -159,8 +167,12 @@ fun ChatInputBar(
|
||||
onModelOptionSelected: (ChatInputPickerOption) -> Unit = {},
|
||||
onModelPickerClick: (() -> Unit)? = null,
|
||||
effortControl: ChatInputPickerControl? = null,
|
||||
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
|
||||
onEffortPickerClick: (() -> Unit)? = null,
|
||||
topContent: (@Composable () -> Unit)? = null,
|
||||
topContentVisible: Boolean = topContent != null,
|
||||
suppressVoiceTrailing: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
surfaceModifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
// Keep the last caption around so the AnimatedVisibility exit doesn't
|
||||
@@ -244,37 +256,60 @@ fun ChatInputBar(
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.then(surfaceModifier),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 34.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
maxLines = 5,
|
||||
enabled = enabled,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { inner ->
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = RelayRefresh.Dim,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
Column {
|
||||
if (topContent != null) {
|
||||
AnimatedVisibility(
|
||||
visible = topContentVisible,
|
||||
enter = expandVertically(
|
||||
animationSpec = tween(240),
|
||||
expandFrom = Alignment.Bottom,
|
||||
) + fadeIn(tween(180)),
|
||||
exit = shrinkVertically(
|
||||
animationSpec = tween(180),
|
||||
shrinkTowards = Alignment.Bottom,
|
||||
) + fadeOut(tween(120)),
|
||||
) {
|
||||
Column {
|
||||
topContent()
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 34.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
maxLines = 5,
|
||||
enabled = enabled,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { inner ->
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = RelayRefresh.Dim,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -364,8 +399,9 @@ fun ChatInputBar(
|
||||
if (effortControl != null) {
|
||||
ChatInputPickerChip(
|
||||
control = effortControl,
|
||||
onSelect = onEffortOptionSelected,
|
||||
onSelect = {},
|
||||
modifier = Modifier.widthIn(max = 104.dp),
|
||||
onClickOverride = onEffortPickerClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -401,42 +437,50 @@ fun ChatInputBar(
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputTrailing.VOICE -> Box {
|
||||
IconButton(onClick = onVoice) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = if (voiceReady) stringResource(R.string.chat_input_start_voice)
|
||||
else stringResource(R.string.chat_input_voice_setup_needed),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
// "Needs setup" badge — full-alpha button + Amber
|
||||
// dot instead of a half-dimmed broken-looking mic.
|
||||
if (!voiceReady) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(RelayRefresh.Amber),
|
||||
)
|
||||
ChatInputTrailing.VOICE -> {
|
||||
if (!suppressVoiceTrailing) {
|
||||
Box {
|
||||
IconButton(onClick = onVoice) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = if (voiceReady) stringResource(R.string.chat_input_start_voice)
|
||||
else stringResource(R.string.chat_input_voice_setup_needed),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
// "Needs setup" badge — full-alpha button + Amber
|
||||
// dot instead of a half-dimmed broken-looking mic.
|
||||
if (!voiceReady) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(RelayRefresh.Amber),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatInputTrailing.STOP -> IconButton(onClick = onStop) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.border(1.dp, MaterialTheme.colorScheme.error, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = stringResource(R.string.chat_input_stop_streaming),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
ChatInputTrailing.STOP -> {
|
||||
if (!suppressVoiceTrailing) {
|
||||
IconButton(onClick = onStop) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.border(1.dp, MaterialTheme.colorScheme.error, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = stringResource(R.string.chat_input_stop_streaming),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +524,7 @@ fun ChatInputBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputPickerChip(
|
||||
|
||||
+875
-392
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.CrashReport
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -57,16 +57,16 @@ import kotlinx.coroutines.withContext
|
||||
* crashed). Render it inside the app theme so the dialog picks up Material
|
||||
* colors — see RelayApp.
|
||||
*
|
||||
* Peeks the report on first composition (does NOT delete on read) and clears it
|
||||
* only when the user acknowledges it (Dismiss/Report). A report the user merely
|
||||
* Peeks the report on first composition (does not mark it reviewed on read) and
|
||||
* acknowledges it only on Dismiss/Report. A report the user merely
|
||||
* glanced at — or never reached because the app was backgrounded — therefore
|
||||
* survives relaunches instead of being lost after one view; once acknowledged
|
||||
* it's deleted and won't reappear.
|
||||
* survives relaunches instead of being lost after one view. Once acknowledged,
|
||||
* it remains in bounded Diagnostics history but does not interrupt startup again.
|
||||
*/
|
||||
@Composable
|
||||
fun CrashReportGate() {
|
||||
val context = LocalContext.current
|
||||
var report by remember { mutableStateOf<CrashReport?>(null) }
|
||||
var report by remember { mutableStateOf<ReliabilityReport?>(null) }
|
||||
var checked by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -79,19 +79,26 @@ fun CrashReportGate() {
|
||||
CrashReportDialog(
|
||||
report = pending,
|
||||
onDismiss = {
|
||||
// Acknowledged (Dismiss/Report) → delete so it won't reappear.
|
||||
// Acknowledged (Dismiss/Report) → retain as reviewed history without showing again.
|
||||
// Copy does NOT route through here, so the report stays available
|
||||
// across relaunches until the user actually dismisses or reports it.
|
||||
CrashReporter.clearPending(context)
|
||||
CrashReporter.clearPending(context, pending.reportId)
|
||||
report = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val reportText = remember(report) { report.toPlainText() }
|
||||
var showDetails by remember(report.reportId) { mutableStateOf(false) }
|
||||
val copiedMessage = stringResource(R.string.crash_toast_copied)
|
||||
val noShareMessage = stringResource(R.string.crash_toast_no_share)
|
||||
val shareTitle = stringResource(R.string.crash_share_title)
|
||||
val reportSubject = stringResource(R.string.crash_share_subject, report.shortTitle())
|
||||
val openedMessage = stringResource(R.string.crash_toast_opened)
|
||||
val noBrowserMessage = stringResource(R.string.crash_toast_no_browser)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -126,25 +133,34 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.crash_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
if (showDetails) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,45 +173,45 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Crash report copied")
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
if (!showDetails) {
|
||||
Button(onClick = { showDetails = true }) {
|
||||
Text(stringResource(R.string.crash_review))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, copiedMessage)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
// until they choose to send it — same privacy posture as Copy.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
"Hermes-Relay crash report — ${report.shortTitle()}",
|
||||
reportText,
|
||||
chooserTitle = "Share crash report",
|
||||
)
|
||||
if (!shared) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
reportSubject,
|
||||
reportText,
|
||||
chooserTitle = shareTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, noShareMessage)
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Report copied — no app found to share to")
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy the FULL report first; the URL only carries the
|
||||
// head of the trace, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full report copied — paste into the issue if it's truncated"
|
||||
else "Report copied — no browser found to open GitHub",
|
||||
)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(context, if (opened) openedMessage else noBrowserMessage)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-5
@@ -63,6 +63,8 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val plainText = remember(entry) { entry.toPlainText() }
|
||||
val severityName = entry.severity.name
|
||||
val copiedToast = stringResource(R.string.diag_copied)
|
||||
val exportChooserTitle = stringResource(R.string.diag_export)
|
||||
|
||||
// Info-severity pre-flight: routine log lines only become GitHub issues once
|
||||
// the reporter says what they expected instead (that answer replaces the
|
||||
@@ -104,9 +106,27 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
|
||||
MetaRow("Severity", severityName)
|
||||
MetaRow("Category", entry.category.label)
|
||||
entry.operation?.let { MetaRow("Operation", it) }
|
||||
entry.endpointRole?.let { MetaRow("Route", it) }
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
|
||||
entry.requestUrl?.let { MetaRow("Request", it) }
|
||||
if (entry.configuredUrl == null && entry.requestUrl == null) {
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
}
|
||||
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
|
||||
entry.suggestion?.let { suggestion ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Suggested next step",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
val body = entry.stacktrace ?: entry.detail
|
||||
@@ -165,7 +185,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, "Diagnostic copied")
|
||||
toast(context, copiedToast)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
OutlinedButton(
|
||||
@@ -174,7 +194,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
context,
|
||||
subject = "Hermes-Relay diagnostic — ${entry.title}",
|
||||
text = plainText,
|
||||
chooserTitle = "Export diagnostic",
|
||||
chooserTitle = exportChooserTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
@@ -246,7 +266,7 @@ internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(50), color = bg) {
|
||||
Text(
|
||||
text = severity.name.uppercase(),
|
||||
text = stringResource(severityLabelRes(severity)).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = fg,
|
||||
@@ -267,9 +287,15 @@ private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
appendLine("Severity: ${severity.name}")
|
||||
appendLine("Time: ${DateFormat.format("yyyy-MM-dd HH:mm:ss", timestampMs)}")
|
||||
appendLine("App: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR}")
|
||||
operation?.let { appendLine("Operation: $it") }
|
||||
endpointRole?.let { appendLine("Route: $it") }
|
||||
url?.let { appendLine("URL: $it") }
|
||||
configuredUrl?.let { appendLine("Configured URL: $it") }
|
||||
requestUrl?.let { appendLine("Request: $it") }
|
||||
if (configuredUrl == null && requestUrl == null) {
|
||||
url?.let { appendLine("URL: $it") }
|
||||
}
|
||||
elapsedMs?.let { appendLine("Elapsed: ${it}ms") }
|
||||
suggestion?.let { appendLine("Suggested next step: $it") }
|
||||
detail?.let {
|
||||
appendLine()
|
||||
appendLine("Detail:")
|
||||
|
||||
@@ -99,7 +99,7 @@ fun DiagnosticsLogPanel(
|
||||
FilterChip(
|
||||
selected = severityFilter == sev,
|
||||
onClick = { severityFilter = if (severityFilter == sev) null else sev },
|
||||
label = { Text(sev.name) },
|
||||
label = { Text(stringResource(severityLabelRes(sev))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -218,9 +218,18 @@ private fun severityColor(severity: DiagnosticSeverity): Color = when (severity)
|
||||
private fun DiagnosticLogEntry.detailLine(): String? {
|
||||
val pieces = listOfNotNull(
|
||||
detail,
|
||||
suggestion,
|
||||
operation,
|
||||
endpointRole?.let { "route=$it" },
|
||||
url,
|
||||
primaryUrl,
|
||||
elapsedMs?.let { "${it}ms" },
|
||||
)
|
||||
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/** String resource for a [DiagnosticSeverity] label (shared with the detail dialog). */
|
||||
internal fun severityLabelRes(severity: DiagnosticSeverity): Int = when (severity) {
|
||||
DiagnosticSeverity.Info -> R.string.diag_severity_info
|
||||
DiagnosticSeverity.Warning -> R.string.diag_severity_warning
|
||||
DiagnosticSeverity.Error -> R.string.diag_severity_error
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -78,9 +78,9 @@ fun GatewayBackgroundProcessStrip(
|
||||
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
|
||||
val displayedCount = if (running > 0) running else processes.size
|
||||
val status = when {
|
||||
running > 0 -> "$running running"
|
||||
failed > 0 -> "$failed failed"
|
||||
else -> "Complete"
|
||||
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
|
||||
failed > 0 -> "$failed ${stringResource(R.string.task_status_failed)}"
|
||||
else -> stringResource(R.string.task_status_complete)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -94,7 +94,7 @@ fun GatewayBackgroundProcessStrip(
|
||||
stateDescription = status
|
||||
}
|
||||
.clickable(
|
||||
onClickLabel = "Open background processes",
|
||||
onClickLabel = stringResource(R.string.bg_processes_open),
|
||||
onClick = onClick,
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -208,7 +208,7 @@ fun GatewayBackgroundProcessSheet(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"No background processes in this chat",
|
||||
stringResource(R.string.bg_processes_empty),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -220,7 +220,7 @@ fun GatewayBackgroundProcessSheet(
|
||||
.heightIn(max = 560.dp),
|
||||
) {
|
||||
if (running.isNotEmpty()) {
|
||||
item { ProcessSectionLabel("Running", running.size) }
|
||||
item { ProcessSectionLabel(stringResource(R.string.bg_processes_running), running.size) }
|
||||
items(running, key = { it.id }) { process ->
|
||||
GatewayProcessRow(
|
||||
process = process,
|
||||
@@ -234,7 +234,7 @@ fun GatewayBackgroundProcessSheet(
|
||||
item { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) }
|
||||
}
|
||||
if (recent.isNotEmpty()) {
|
||||
item { ProcessSectionLabel("Recent", recent.size) }
|
||||
item { ProcessSectionLabel(stringResource(R.string.bg_processes_recent), recent.size) }
|
||||
items(recent, key = { it.id }) { process ->
|
||||
GatewayProcessRow(
|
||||
process = process,
|
||||
@@ -278,7 +278,7 @@ private fun GatewayProcessRow(
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifBlank {
|
||||
"Background process"
|
||||
stringResource(R.string.bg_processes_title)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -287,7 +287,7 @@ private fun GatewayProcessRow(
|
||||
.animateContentSize()
|
||||
.clickable(
|
||||
enabled = output.isNotBlank(),
|
||||
onClickLabel = if (expanded) "Collapse process output" else "Expand process output",
|
||||
onClickLabel = if (expanded) stringResource(R.string.bg_collapse_output) else stringResource(R.string.bg_expand_output),
|
||||
) { expanded = !expanded }
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
) {
|
||||
@@ -332,7 +332,7 @@ private fun GatewayProcessRow(
|
||||
if (output.isNotBlank()) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse output" else "Expand output",
|
||||
contentDescription = if (expanded) stringResource(R.string.tool_progress_cd_collapse) else stringResource(R.string.tool_progress_cd_expand),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -396,11 +396,14 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
|
||||
val state = when {
|
||||
process.isRunning -> "Running"
|
||||
failed -> "Failed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
|
||||
else -> "Completed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
|
||||
process.isRunning -> stringResource(R.string.bg_processes_running)
|
||||
failed -> stringResource(R.string.task_status_failed) +
|
||||
process.exitCode?.let { " · exit $it" }.orEmpty()
|
||||
else -> stringResource(R.string.task_status_complete) +
|
||||
process.exitCode?.let { " · exit $it" }.orEmpty()
|
||||
}
|
||||
return "$state · ${formatElapsed(process.uptimeSeconds)}" +
|
||||
if (process.detached) " · recovered" else ""
|
||||
|
||||
@@ -502,7 +502,7 @@ private fun CardInputSlot(
|
||||
input.holdToConfirm -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HoldToConfirmButton(
|
||||
label = "Hold to confirm",
|
||||
label = stringResource(R.string.hermes_card_hold_confirm),
|
||||
enabled = !input.masked || answerText.isNotEmpty(),
|
||||
onConfirmed = {
|
||||
onSubmit(
|
||||
@@ -511,6 +511,13 @@ private fun CardInputSlot(
|
||||
)
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.hermes_card_hold_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
input.masked -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
@@ -155,7 +155,7 @@ private fun LoadingCard(
|
||||
val sizeHint = attachment.fileSize?.takeIf { it > 0 }
|
||||
?.let { " · ${formatBytes(it)}" } ?: ""
|
||||
Text(
|
||||
text = if (isManualCta) stringResource(R.string.inbound_attach_tap_download) else stringResource(R.string.inbound_attach_downloading, sizeHint),
|
||||
text = if (isManualCta) stringResource(R.string.attach_tap_download) else stringResource(R.string.inbound_attach_downloading, sizeHint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Returns the string resource for a tool name (toolCall.name), or null when the
|
||||
* name does not map to a known tool. Pure mapping so it can be unit-tested.
|
||||
*/
|
||||
fun localizeToolNameKey(name: String): Int? = when {
|
||||
name.contains("terminal") -> R.string.tool_name_terminal
|
||||
name.contains("execute") -> R.string.tool_name_execute_code
|
||||
name.contains("read_file") -> R.string.tool_name_read_file
|
||||
name.contains("write_file") || name.contains("patch") -> R.string.tool_name_write_file
|
||||
name.contains("session_search") -> R.string.tool_name_session
|
||||
name.contains("android") || name.contains("phone") -> R.string.tool_name_android
|
||||
name.contains("web_search") || name.contains("search") -> R.string.tool_name_web_search
|
||||
name.contains("web_extract") -> R.string.tool_name_web_extract
|
||||
name.contains("memory") || name.contains("mnemosyne") -> R.string.tool_name_memory
|
||||
name.contains("skill") -> R.string.tool_name_skill
|
||||
name.contains("delegate") -> R.string.tool_name_delegate
|
||||
name.contains("cron") -> R.string.tool_name_cron
|
||||
name.contains("todo") -> R.string.tool_name_todo
|
||||
name.contains("process") -> R.string.tool_name_process
|
||||
name.contains("vision") || name.contains("image") -> R.string.tool_name_vision
|
||||
name.contains("computer_use") -> R.string.tool_name_computer
|
||||
name.contains("speech") || name.contains("tts") -> R.string.tool_name_tts
|
||||
name.contains("file") -> R.string.tool_name_file
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string resource for a badge label (message.badges entries), or
|
||||
* null when the badge does not map to a known resource. Pure mapping so it can
|
||||
* be unit-tested.
|
||||
*/
|
||||
fun localizeBadgeKey(badge: String): Int? = when (badge) {
|
||||
"Tool failed" -> R.string.badge_tool_failed
|
||||
"Memory" -> R.string.badge_memory
|
||||
"Skill" -> R.string.badge_skill
|
||||
"Artifact" -> R.string.badge_artifact
|
||||
"Response interrupted" -> R.string.badge_response_interrupted
|
||||
"Unknown error" -> R.string.badge_unknown_error
|
||||
"Error" -> R.string.badge_error
|
||||
"Stopped" -> R.string.badge_stopped
|
||||
"Model changed" -> R.string.badge_model_changed
|
||||
"Background work completed" -> R.string.badge_bg_work_completed
|
||||
"Continued after an interrupted turn" -> R.string.badge_continued
|
||||
"Realtime Agent" -> R.string.bubble_realtime_agent
|
||||
"Voice" -> R.string.bubble_voice
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string resource for an agent name (message.agentName), or null
|
||||
* when the name does not map to a known resource. Pure mapping so it can be
|
||||
* unit-tested.
|
||||
*/
|
||||
fun localizeAgentNameKey(name: String): Int? = when (name) {
|
||||
"Send SMS" -> R.string.agent_send_sms
|
||||
"Call" -> R.string.agent_call
|
||||
"Search Contacts" -> R.string.agent_search_contacts
|
||||
"Open App" -> R.string.agent_open_app
|
||||
"Return to Hermes" -> R.string.agent_return_hermes
|
||||
"Screenshot" -> R.string.agent_screenshot
|
||||
"Key Press" -> R.string.agent_key_press
|
||||
"Bridge Setup" -> R.string.agent_bridge_setup
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Localizes a tool name (toolCall.name) for display. Unknown names are returned as-is. */
|
||||
@Composable
|
||||
fun localizeToolName(name: String): String = localizeToolNameKey(name)?.let {
|
||||
stringResource(it)
|
||||
} ?: name
|
||||
|
||||
/** Localizes a badge (message.badges entries) for display. Unknown badges are returned as-is. */
|
||||
@Composable
|
||||
fun localizeBadge(badge: String): String = localizeBadgeKey(badge)?.let {
|
||||
stringResource(it)
|
||||
} ?: badge
|
||||
|
||||
/** Localizes an agent name (message.agentName) for display. Unknown names are returned as-is. */
|
||||
@Composable
|
||||
fun localizeAgentName(name: String): String = localizeAgentNameKey(name)?.let {
|
||||
stringResource(it)
|
||||
} ?: name
|
||||
|
||||
/**
|
||||
* Splits a timeline event title of the form "toolName · status" and returns the
|
||||
* resource key for the tool-name segment when it is known. Returns null when
|
||||
* the title has no separator or the name is not mapped, so the render site
|
||||
* falls back to the original title.
|
||||
*/
|
||||
fun localizeTimelineTitleName(title: String): Pair<String, Int?>? {
|
||||
val sep = " · "
|
||||
val idx = title.indexOf(sep)
|
||||
if (idx <= 0) return null
|
||||
val name = title.substring(0, idx)
|
||||
return name to localizeToolNameKey(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Localizes a timeline event title of the form "toolName · status" at the
|
||||
* render site (TimelineRow). The data layer (buildTimelineEvents) keeps the
|
||||
* English keys.
|
||||
*/
|
||||
@Composable
|
||||
fun localizeTimelineTitle(title: String): String {
|
||||
val parts = localizeTimelineTitleName(title) ?: return title
|
||||
val (name, key) = parts
|
||||
if (key == null) return title
|
||||
val sep = " · "
|
||||
val idx = title.indexOf(sep)
|
||||
return stringResource(key) + title.substring(idx)
|
||||
}
|
||||
@@ -29,8 +29,9 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -42,6 +43,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -50,19 +52,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.BlurMode
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
@@ -70,9 +71,10 @@ import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MediaSettingsRepository
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.pet.petPerchSurface
|
||||
import com.hermesandroid.relay.ui.components.pet.petVisitTargetSurface
|
||||
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -97,6 +99,12 @@ fun MessageBubble(
|
||||
* the long-press menu, so legacy call sites keep the copy-only behavior.
|
||||
*/
|
||||
onQuoteMessage: ((String) -> Unit)? = null,
|
||||
/**
|
||||
* Reads a completed assistant response through the active voice renderer.
|
||||
* Null hides the entry; the owning screen uses that to limit the action to
|
||||
* idle Conversation voice sessions.
|
||||
*/
|
||||
onSpeakMessage: ((String) -> Unit)? = null,
|
||||
/**
|
||||
* Invoked when the user taps a FAILED inbound attachment card.
|
||||
* `attachmentIndex` is the position in [ChatMessage.attachments] so the
|
||||
@@ -140,6 +148,8 @@ fun MessageBubble(
|
||||
recoveringAnswer: Boolean = false,
|
||||
imageGenerationStylePreference: String = "rotate",
|
||||
imageGenerationRotationIndex: Int = 0,
|
||||
petVisitTargetKey: String? = null,
|
||||
petPerchKey: String? = null,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -240,38 +250,54 @@ fun MessageBubble(
|
||||
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
|
||||
|
||||
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
|
||||
// Identity (the active profile avatar) is shown once in the top bar, so
|
||||
// message bubbles no longer reserve a per-group avatar gutter — that width
|
||||
// is reclaimed for wider bubbles. Outer alignment keeps user bubbles right.
|
||||
// The active profile image is sender identity, distinct from both the
|
||||
// floating pet companion and the ambient Sphere. Reserve one stable gutter
|
||||
// for an assistant run and render the identity only on its first message.
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
if (!isUser && !isSystem) {
|
||||
Box(
|
||||
modifier = Modifier.width(40.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
if (shouldShowMessageGroupAvatar(
|
||||
isUser = isUser,
|
||||
isSystem = isSystem,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
agentName = message.agentName,
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
// Decorative: the adjacent visible agent name already owns
|
||||
// the identity announcement, avoiding duplicate TalkBack copy.
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
AgentAvatarFace(
|
||||
name = message.agentName.orEmpty(),
|
||||
letterStyle = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = alignment
|
||||
) {
|
||||
// Agent name label (above assistant bubbles, only first in group), with
|
||||
// the active profile's local icon (if set) — a small avatar by the name.
|
||||
// Agent name label sits beside the first group identity avatar. Pet
|
||||
// companions never enter this row.
|
||||
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
val agentIconPath = LocalAgentIconPath.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(bottom = 2.dp, start = 4.dp),
|
||||
) {
|
||||
if (!agentIconPath.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(agentIconPath),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message.agentName,
|
||||
text = localizeAgentName(message.agentName),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -288,13 +314,13 @@ fun MessageBubble(
|
||||
) {
|
||||
message.badges.take(4).forEach { badge ->
|
||||
MessagePathBadge(
|
||||
text = badge,
|
||||
text = localizeBadge(badge),
|
||||
// Speaker glyph = the shared "spoken" modality marker.
|
||||
// Both the standard voice-mode chip ("Voice") and the
|
||||
// realtime engine chip ("Realtime Agent") are spoken
|
||||
// turns, so they share it; only the text differs.
|
||||
leadingIcon = if (badge == "Voice" || badge == "Realtime Agent") {
|
||||
Icons.Filled.VolumeUp
|
||||
Icons.AutoMirrored.Filled.VolumeUp
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -309,6 +335,7 @@ fun MessageBubble(
|
||||
thinkingContent = message.thinkingContent,
|
||||
isStreaming = message.isThinkingStreaming,
|
||||
timestamp = message.timestamp,
|
||||
petObstacleKey = "chat-thinking:${message.uiKey}",
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp)
|
||||
@@ -321,17 +348,18 @@ fun MessageBubble(
|
||||
thinkingContent = if (reference.available) {
|
||||
reference.text
|
||||
} else {
|
||||
"Advisor unavailable."
|
||||
stringResource(R.string.bubble_advisor_unavailable)
|
||||
},
|
||||
isStreaming = false,
|
||||
petObstacleKey = "chat-thinking:${message.uiKey}:advisor:${reference.index}",
|
||||
headerText = buildString {
|
||||
append("Advisor ")
|
||||
append(stringResource(R.string.bubble_advisor_prefix))
|
||||
append(reference.index)
|
||||
reference.count?.let { append("/").append(it) }
|
||||
append(" · ")
|
||||
append(reference.label)
|
||||
},
|
||||
accessibilityLabel = "Mixture of Agents advisor response",
|
||||
accessibilityLabel = stringResource(R.string.bubble_moa_advisor),
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp),
|
||||
@@ -382,7 +410,8 @@ fun MessageBubble(
|
||||
var showMessageActions by remember { mutableStateOf(false) }
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val showEditAction = onEditMessage != null && isUser
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
val showSpeakAction = shouldShowSpeakResponseAction(message, onSpeakMessage != null)
|
||||
if (onQuoteMessage != null || showEditAction || showSpeakAction) {
|
||||
DropdownMenu(
|
||||
expanded = showMessageActions,
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
@@ -403,6 +432,21 @@ fun MessageBubble(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showSpeakAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.msg_bubble_speak_response)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onSpeakMessage?.invoke(message.content)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showEditAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.msg_bubble_edit)) },
|
||||
@@ -454,7 +498,7 @@ fun MessageBubble(
|
||||
// action menu is the discoverability moment, so it gets the
|
||||
// same tactile confirm every chat app fires.
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
if (onQuoteMessage != null || showEditAction || showSpeakAction) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(message.content)
|
||||
@@ -462,9 +506,29 @@ fun MessageBubble(
|
||||
}
|
||||
)
|
||||
.semantics { contentDescription = a11yDescription }
|
||||
.then(
|
||||
if (petVisitTargetKey != null) {
|
||||
Modifier.petVisitTargetSurface(
|
||||
key = petVisitTargetKey,
|
||||
routes = setOf("chat"),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (petPerchKey != null) {
|
||||
Modifier.petPerchSurface(
|
||||
key = petPerchKey,
|
||||
routes = setOf("chat"),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)) {
|
||||
SelectionContainer {
|
||||
val messageTextContent: @Composable () -> Unit = {
|
||||
if (isUser || isSystem) {
|
||||
// Plain text for user and system messages
|
||||
Text(
|
||||
@@ -490,6 +554,25 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compose's SelectionManager assumes that selectable IDs
|
||||
// captured by a drag remain registered. Reset its owner when
|
||||
// a live Text node becomes a Markdown tree, or settled
|
||||
// Markdown content changes its node topology, so a handle
|
||||
// cannot keep pointing at a removed selectable.
|
||||
if (showSpeakAction) {
|
||||
DisableSelection { messageTextContent() }
|
||||
} else {
|
||||
key(
|
||||
messageSelectionTopologyKey(
|
||||
isPlainText = isUser || isSystem,
|
||||
isStreaming = message.isStreaming,
|
||||
retainStreamingLayout = retainStreamingLayout,
|
||||
markdownBody = markdownBody,
|
||||
),
|
||||
) {
|
||||
SelectionContainer { messageTextContent() }
|
||||
}
|
||||
}
|
||||
|
||||
// Inline generated images (assistant only) — rendered OUTSIDE
|
||||
// the SelectionContainer (they're not selectable text). Remote
|
||||
@@ -666,16 +749,21 @@ fun MessageBubble(
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
|
||||
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
|
||||
// Keep the live bubble's footer structurally quiet. Showing a
|
||||
// timestamp while text is still growing makes it chase every
|
||||
// token and exaggerates any single-frame layout lag. Reveal it
|
||||
// once the message settles into its final layout.
|
||||
if (isLastInGroup && !message.isStreaming) {
|
||||
// Reserve the footer from the first streaming frame so
|
||||
// completion is a color-only transition and cannot resize the
|
||||
// row. Hide the reserved timestamp from accessibility until it
|
||||
// becomes visible.
|
||||
if (isLastInGroup) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
modifier = if (message.isStreaming) {
|
||||
Modifier.clearAndSetSemantics { }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -720,6 +808,86 @@ fun MessageBubble(
|
||||
} // end CompositionLocalProvider(LocalMediaBlurMode)
|
||||
}
|
||||
|
||||
internal data class MessageSelectionTopologyKey(
|
||||
val renderer: String,
|
||||
val markdownBody: String?,
|
||||
)
|
||||
|
||||
internal fun messageSelectionTopologyKey(
|
||||
isPlainText: Boolean,
|
||||
isStreaming: Boolean,
|
||||
retainStreamingLayout: Boolean,
|
||||
markdownBody: String,
|
||||
): MessageSelectionTopologyKey = when {
|
||||
isPlainText -> MessageSelectionTopologyKey(renderer = "plain", markdownBody = null)
|
||||
isStreaming || retainStreamingLayout ->
|
||||
MessageSelectionTopologyKey(renderer = "live", markdownBody = null)
|
||||
else -> MessageSelectionTopologyKey(renderer = "markdown", markdownBody = markdownBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a settled, plain assistant response can become a transient pet visit
|
||||
* target. Rich cards, attachments, and tool/action rows remain interaction
|
||||
* surfaces for the user rather than pet destinations.
|
||||
*/
|
||||
internal fun isPetVisitTargetCandidate(message: ChatMessage): Boolean =
|
||||
message.role == MessageRole.ASSISTANT &&
|
||||
!message.isStreaming &&
|
||||
!message.isThinkingStreaming &&
|
||||
message.content.isNotBlank() &&
|
||||
message.toolCalls.isEmpty() &&
|
||||
message.cards.isEmpty() &&
|
||||
message.attachments.isEmpty() &&
|
||||
message.backgroundTask == null &&
|
||||
message.agentName != "Voice action" &&
|
||||
message.agentName != "Phone action" &&
|
||||
!message.id.startsWith("voice-intent-")
|
||||
|
||||
/** The newest settled user or assistant bubble can provide text-safe habitat geometry. */
|
||||
internal fun isPetPerchCandidate(message: ChatMessage): Boolean =
|
||||
(message.role == MessageRole.ASSISTANT || message.role == MessageRole.USER) &&
|
||||
!message.isStreaming &&
|
||||
!message.isThinkingStreaming &&
|
||||
message.content.isNotBlank() &&
|
||||
message.backgroundTask == null &&
|
||||
message.agentName != "Voice action" &&
|
||||
message.agentName != "Phone action" &&
|
||||
!message.id.startsWith("voice-intent-")
|
||||
|
||||
internal fun newestPetVisitTargetUiKey(messages: List<ChatMessage>): String? =
|
||||
messages.lastOrNull { message ->
|
||||
message.role == MessageRole.ASSISTANT
|
||||
}?.takeIf(::isPetVisitTargetCandidate)?.uiKey
|
||||
|
||||
internal fun newestPetPerchUiKey(messages: List<ChatMessage>): String? =
|
||||
messages.lastOrNull()?.takeIf(::isPetPerchCandidate)?.uiKey
|
||||
|
||||
/** All settled visible-message candidates that may become journey stepping stones. */
|
||||
internal fun petPerchUiKeys(messages: List<ChatMessage>): Set<String> =
|
||||
messages.asSequence().filter(::isPetPerchCandidate).map { it.uiKey }.toSet()
|
||||
|
||||
/** The completion edge is usable only after the newest assistant row itself settles. */
|
||||
internal fun newestPetAssistantIsSettled(messages: List<ChatMessage>): Boolean =
|
||||
messages.lastOrNull { it.role == MessageRole.ASSISTANT }?.let { message ->
|
||||
!message.isStreaming && !message.isThinkingStreaming
|
||||
} == true
|
||||
|
||||
internal fun shouldShowSpeakResponseAction(
|
||||
message: ChatMessage,
|
||||
handlerAvailable: Boolean,
|
||||
): Boolean =
|
||||
handlerAvailable &&
|
||||
message.role == MessageRole.ASSISTANT &&
|
||||
!message.isStreaming &&
|
||||
message.content.isNotBlank()
|
||||
|
||||
internal fun shouldShowMessageGroupAvatar(
|
||||
isUser: Boolean,
|
||||
isSystem: Boolean,
|
||||
isFirstInGroup: Boolean,
|
||||
agentName: String?,
|
||||
): Boolean = !isUser && !isSystem && isFirstInGroup && !agentName.isNullOrBlank()
|
||||
|
||||
@Composable
|
||||
private fun MessagePathBadge(text: String, leadingIcon: ImageVector? = null) {
|
||||
Surface(
|
||||
|
||||
@@ -83,8 +83,9 @@ fun ModelPickerSheet(
|
||||
}
|
||||
// groupBy preserves key insertion order, so providers stay current-first
|
||||
// (the caller sorts is_current to the front).
|
||||
val grouped = remember(filtered) {
|
||||
filtered.groupBy { it.group?.takeIf { g -> g.isNotBlank() } ?: "Other" }
|
||||
val otherGroupLabel = stringResource(R.string.model_picker_other)
|
||||
val grouped = remember(filtered, otherGroupLabel) {
|
||||
filtered.groupBy { it.group?.takeIf { g -> g.isNotBlank() } ?: otherGroupLabel }
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
@@ -106,7 +107,7 @@ fun ModelPickerSheet(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = "Model", style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = stringResource(R.string.model_picker_model), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "${modelOptions.size} models",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@@ -131,7 +132,7 @@ fun ModelPickerSheet(
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(if (refreshing) "Refreshing" else "Refresh")
|
||||
Text(if (refreshing) stringResource(R.string.model_picker_refreshing) else stringResource(R.string.model_picker_refresh))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +217,63 @@ fun ModelPickerSheet(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sibling of [ModelPickerSheet] for short, ungrouped control lists.
|
||||
* Passport selectors use this so profile, personality, and reasoning choices
|
||||
* share the model picker's modal hierarchy, row rhythm, and selected-state
|
||||
* treatment instead of expanding dense radio lists inside the detail card.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun OptionPickerSheet(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
options: List<ChatInputPickerOption>,
|
||||
onSelect: (ChatInputPickerOption) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 520.dp),
|
||||
) {
|
||||
items(
|
||||
items = options,
|
||||
key = { option -> "${option.group}:${option.value}:${option.label}" },
|
||||
) { option ->
|
||||
ModelPickerRow(option = option, onClick = { onSelect(option) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelPickerRow(
|
||||
option: ChatInputPickerOption,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ enum class PowerFeatureGateStatus(
|
||||
explanationRes = R.string.power_feature_requires_pairing_explain,
|
||||
),
|
||||
PairingExpired(
|
||||
labelRes = R.string.power_feature_pairing_expired_label,
|
||||
labelRes = R.string.relay_state_needs_repair,
|
||||
actionLabelRes = R.string.power_feature_pairing_expired_action,
|
||||
explanationRes = R.string.power_feature_pairing_expired_explain,
|
||||
),
|
||||
@@ -202,8 +202,8 @@ fun PowerFeatureGateCard(
|
||||
private fun PowerFeatureGatePreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
title = stringResource(R.string.power_terminal),
|
||||
summary = stringResource(R.string.power_terminal_desc),
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
@@ -215,8 +215,8 @@ private fun PowerFeatureGatePreview() {
|
||||
private fun PowerFeatureGateExpiredPreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
title = stringResource(R.string.power_bridge),
|
||||
summary = stringResource(R.string.power_bridge_desc),
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
|
||||
|
||||
/** One localized label source shared by the composer and Agent Passport. */
|
||||
@Composable
|
||||
fun reasoningEffortLabel(value: String?): String = when (ReasoningEfforts.normalize(value)) {
|
||||
"none" -> stringResource(R.string.chat_reasoning_none)
|
||||
"minimal" -> stringResource(R.string.chat_reasoning_minimal)
|
||||
"low" -> stringResource(R.string.chat_reasoning_low)
|
||||
"medium" -> stringResource(R.string.chat_reasoning_medium)
|
||||
"high" -> stringResource(R.string.chat_reasoning_high)
|
||||
"xhigh" -> stringResource(R.string.chat_reasoning_xhigh)
|
||||
"max" -> stringResource(R.string.chat_reasoning_max)
|
||||
"ultra" -> stringResource(R.string.chat_reasoning_ultra)
|
||||
else -> error("ReasoningEfforts.normalize returned a non-canonical value")
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.SweepGradient
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -25,6 +33,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
@@ -56,13 +65,23 @@ 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.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -76,6 +95,8 @@ private enum class SessionDrawerFilter {
|
||||
Archive,
|
||||
}
|
||||
|
||||
internal const val SESSION_DRAWER_LIST_TAG = "session-drawer-list"
|
||||
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: List<ChatSession>,
|
||||
@@ -84,12 +105,15 @@ fun SessionDrawerContent(
|
||||
scopeSubtitle: String? = null,
|
||||
isLoading: Boolean = false,
|
||||
isOpen: Boolean = true,
|
||||
activityStates: Map<String, SessionActivityState> = emptyMap(),
|
||||
animationEnabled: Boolean = true,
|
||||
autoTitlesSupported: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
onNewChat: () -> Unit,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onRenameSession: (String, String) -> Unit,
|
||||
onCopySessionId: ((String) -> Unit)? = null,
|
||||
/**
|
||||
* When true, the Threads affordance (header spool + filter chip) shows even with no
|
||||
* Thread sessions present yet — i.e. the relay Threads capability is paired + opted in
|
||||
@@ -113,11 +137,11 @@ fun SessionDrawerContent(
|
||||
var sourceFilterOpen by remember { mutableStateOf(false) }
|
||||
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var searchExpanded by remember { mutableStateOf(false) }
|
||||
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
|
||||
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val listState = rememberLazyListState()
|
||||
var scrollToTopPending by remember { mutableStateOf(false) }
|
||||
val trimmedQuery = query.trim()
|
||||
// Threads affordance shows when the capability is active OR there's already at least one
|
||||
// agent Thread (source=phone) in the list. If the filter is on Threads but they've
|
||||
@@ -172,27 +196,12 @@ fun SessionDrawerContent(
|
||||
.toList()
|
||||
val topVisibleSessionId = visibleSessions.firstOrNull()?.sessionId
|
||||
|
||||
LaunchedEffect(isOpen) {
|
||||
scrollToTopPending = isOpen
|
||||
if (isOpen && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filter, trimmedQuery) {
|
||||
if (isOpen && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
} else if (isOpen) {
|
||||
scrollToTopPending = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isOpen, topVisibleSessionId, visibleSessions.size) {
|
||||
if (isOpen && scrollToTopPending && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
LaunchedEffect(isOpen, activeFilter, trimmedQuery, topVisibleSessionId) {
|
||||
if (isOpen && topVisibleSessionId != null) {
|
||||
// A drawer-open refresh can reorder rows after the initial scroll.
|
||||
// Override LazyColumn's normal key anchoring so the new leading
|
||||
// session is visible instead of being retained above the viewport.
|
||||
listState.requestScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +305,21 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { searchExpanded = !searchExpanded },
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.drawer_search_sessions),
|
||||
tint = if (searchExpanded || query.isNotBlank()) {
|
||||
RelayRefresh.Relay
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
// Manual re-pull: the server titles a session asynchronously after
|
||||
// the first turn (and never pushes a rename), so a refresh is the
|
||||
// way to pick up a title the auto-reconcile window missed.
|
||||
@@ -333,17 +357,19 @@ fun SessionDrawerContent(
|
||||
Text(stringResource(R.string.drawer_new_chat))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
|
||||
)
|
||||
if (searchExpanded || query.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
@@ -460,11 +486,16 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(state = listState) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.testTag(SESSION_DRAWER_LIST_TAG),
|
||||
) {
|
||||
items(visibleSessions, key = { it.sessionId }) { session ->
|
||||
SessionItem(
|
||||
session = session,
|
||||
isActive = session.sessionId == currentSessionId,
|
||||
activityState = activityStates[session.sessionId],
|
||||
animationEnabled = animationEnabled,
|
||||
pinned = session.sessionId in pinnedSessionIds,
|
||||
archived = session.sessionId in archivedSessionIds,
|
||||
onClick = { onSelectSession(session.sessionId) },
|
||||
@@ -483,6 +514,7 @@ fun SessionDrawerContent(
|
||||
}
|
||||
},
|
||||
onRename = { renameDialogSession = session },
|
||||
onCopySessionId = { onCopySessionId?.invoke(session.sessionId) },
|
||||
onDelete = { deleteDialogSession = session }
|
||||
)
|
||||
}
|
||||
@@ -587,18 +619,27 @@ fun SessionDrawerContent(
|
||||
private fun SessionItem(
|
||||
session: ChatSession,
|
||||
isActive: Boolean,
|
||||
activityState: SessionActivityState?,
|
||||
animationEnabled: Boolean,
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onCopySessionId: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val context = LocalContext.current
|
||||
val untitledLabel = stringResource(R.string.drawer_untitled)
|
||||
val activityLabel = when (activityState) {
|
||||
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
|
||||
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
|
||||
null -> null
|
||||
}
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
@@ -609,6 +650,13 @@ private fun SessionItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.sessionActivityBorder(
|
||||
state = activityState,
|
||||
animated = animationEnabled && motion.osAnimations && !motion.touchExploration,
|
||||
)
|
||||
.semantics {
|
||||
activityLabel?.let { stateDescription = it }
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -633,6 +681,17 @@ private fun SessionItem(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (activityState != null && activityLabel != null) {
|
||||
SessionActivityIndicator(activityState, activityLabel)
|
||||
}
|
||||
if (pinned) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Amber,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
// Agent Thread tag — the clean spool + "Thread", so a source=phone
|
||||
// conversation reads as its own lane in the unified session list (ADR 12).
|
||||
if (isThreadSource(session.source)) {
|
||||
@@ -683,18 +742,6 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onTogglePinned,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = if (pinned) stringResource(R.string.drawer_unpin_session) else stringResource(R.string.drawer_pin_session),
|
||||
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { menuOpen = true },
|
||||
@@ -711,6 +758,42 @@ private fun SessionItem(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (pinned) {
|
||||
stringResource(R.string.drawer_unpin_session)
|
||||
} else {
|
||||
stringResource(R.string.drawer_pin_session)
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = null,
|
||||
tint = if (pinned) {
|
||||
RelayRefresh.Amber
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onTogglePinned()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_copy_session_id)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onCopySessionId()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.drawer_rename)) },
|
||||
leadingIcon = {
|
||||
@@ -763,6 +846,147 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
|
||||
val color = when (state) {
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(7.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(color),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.sessionActivityBorder(
|
||||
state: SessionActivityState?,
|
||||
animated: Boolean,
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
}
|
||||
val shouldRotate = animated && state == SessionActivityState.Working
|
||||
val phase = if (shouldRotate) {
|
||||
val transition = rememberInfiniteTransition(label = "session-activity")
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 2_230, easing = LinearEasing),
|
||||
),
|
||||
label = "session-activity-glow",
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return drawWithCache {
|
||||
val coreWidth = 1.25.dp.toPx()
|
||||
val coreInset = coreWidth / 2f
|
||||
val haloWidth = 5.dp.toPx()
|
||||
val haloInset = haloWidth / 2f
|
||||
val radius = 12.dp.toPx()
|
||||
// Matching transparent endpoints make phase 0 and 1 identical, so the
|
||||
// full shader rotation loops without the dash-pattern reset of the old ring.
|
||||
val animatedShader = SweepGradient(
|
||||
size.width / 2f,
|
||||
size.height / 2f,
|
||||
intArrayOf(
|
||||
color.copy(alpha = 0f).toArgb(),
|
||||
color.copy(alpha = 0f).toArgb(),
|
||||
color.copy(alpha = 0.16f).toArgb(),
|
||||
color.copy(alpha = 0.72f).toArgb(),
|
||||
color.toArgb(),
|
||||
color.copy(alpha = 0.34f).toArgb(),
|
||||
color.copy(alpha = 0f).toArgb(),
|
||||
),
|
||||
floatArrayOf(0f, 0.52f, 0.66f, 0.78f, 0.84f, 0.92f, 1f),
|
||||
)
|
||||
val shaderMatrix = Matrix()
|
||||
val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = haloWidth
|
||||
alpha = 88
|
||||
shader = animatedShader
|
||||
}
|
||||
val middlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2.75.dp.toPx()
|
||||
alpha = 150
|
||||
shader = animatedShader
|
||||
}
|
||||
val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = coreWidth
|
||||
shader = animatedShader
|
||||
}
|
||||
val haloRect = RectF(
|
||||
haloInset,
|
||||
haloInset,
|
||||
size.width - haloInset,
|
||||
size.height - haloInset,
|
||||
)
|
||||
val middleInset = middlePaint.strokeWidth / 2f
|
||||
val middleRect = RectF(
|
||||
middleInset,
|
||||
middleInset,
|
||||
size.width - middleInset,
|
||||
size.height - middleInset,
|
||||
)
|
||||
val coreRect = RectF(
|
||||
coreInset,
|
||||
coreInset,
|
||||
size.width - coreInset,
|
||||
size.height - coreInset,
|
||||
)
|
||||
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
if (phase != null) {
|
||||
shaderMatrix.setRotate(
|
||||
phase.value * 360f - 90f,
|
||||
size.width / 2f,
|
||||
size.height / 2f,
|
||||
)
|
||||
animatedShader.setLocalMatrix(shaderMatrix)
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
drawRoundRect(haloRect, radius, radius, haloPaint)
|
||||
drawRoundRect(middleRect, radius, radius, middlePaint)
|
||||
drawRoundRect(coreRect, radius, radius, corePaint)
|
||||
}
|
||||
} else {
|
||||
drawRoundRect(
|
||||
brush = Brush.linearGradient(
|
||||
listOf(color.copy(alpha = 0.72f), color.copy(alpha = 0.28f))
|
||||
),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(coreInset, coreInset),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
width = size.width - coreWidth,
|
||||
height = size.height - coreWidth,
|
||||
),
|
||||
cornerRadius = CornerRadius(radius),
|
||||
style = Stroke(width = coreWidth),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
|
||||
val timestamp = session.activityTimestamp
|
||||
if (timestamp <= 0L) return null
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
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.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.reliability.SupportBundleBuilder
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
|
||||
data class SupportReviewState(
|
||||
val reportCount: Int,
|
||||
val text: String,
|
||||
val shareEnabled: Boolean,
|
||||
)
|
||||
|
||||
internal fun buildSupportReviewState(reports: List<ReliabilityReport>): SupportReviewState =
|
||||
SupportReviewState(
|
||||
reportCount = reports.takeLast(SupportBundleBuilder.MAX_REPORTS).size,
|
||||
text = SupportBundleBuilder.build(reports),
|
||||
shareEnabled = reports.isNotEmpty(),
|
||||
)
|
||||
|
||||
/** Exact review surface for the local text handed to clipboard/share. */
|
||||
@Composable
|
||||
fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val copied = stringResource(R.string.support_bundle_copied)
|
||||
val noShare = stringResource(R.string.support_bundle_no_share)
|
||||
val chooser = stringResource(R.string.support_bundle_share_title)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.support_bundle_privacy, state.reportCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 160.dp, max = 420.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
RoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = state.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
Button(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
if (!IssueReport.share(
|
||||
context,
|
||||
subject = chooser,
|
||||
text = state.text,
|
||||
chooserTitle = chooser,
|
||||
)
|
||||
) {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.pet.petObstacleSurface
|
||||
|
||||
private val THINKING_BLOCK_PET_ROUTES = setOf("chat")
|
||||
|
||||
@Composable
|
||||
fun ThinkingBlock(
|
||||
@@ -40,7 +45,9 @@ fun ThinkingBlock(
|
||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||
timestamp: Long? = null,
|
||||
headerText: String? = null,
|
||||
accessibilityLabel: String = "Thinking",
|
||||
accessibilityLabel: String = stringResource(R.string.thinking_thinking_short),
|
||||
/** Stable key when this interactive block participates in Chat pet terrain. */
|
||||
petObstacleKey: String? = null,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
@@ -52,7 +59,18 @@ fun ThinkingBlock(
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (petObstacleKey != null) {
|
||||
Modifier.petObstacleSurface(
|
||||
key = petObstacleKey,
|
||||
routes = THINKING_BLOCK_PET_ROUTES,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
@@ -73,7 +91,7 @@ fun ThinkingBlock(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = headerText ?: if (isStreaming) "Thinking..." else "Thought process",
|
||||
text = headerText ?: if (isStreaming) stringResource(R.string.thinking_thinking) else stringResource(R.string.thinking_title),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
@@ -88,7 +106,7 @@ fun ThinkingBlock(
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
contentDescription = if (expanded) stringResource(R.string.tool_progress_cd_collapse) else stringResource(R.string.tool_progress_cd_expand),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -102,7 +103,7 @@ fun TimelineView(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "${events.size} events",
|
||||
text = pluralStringResource(R.plurals.timeline_events_count, events.size, events.size),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -161,11 +162,11 @@ private fun TimelineLegend() {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
LegendEntry("Chat", TimelineEventKind.ChatMessage.color())
|
||||
LegendEntry("Tool", TimelineEventKind.ToolCall.color())
|
||||
LegendEntry("Voice", TimelineEventKind.VoiceTurn.color())
|
||||
LegendEntry("Profile", TimelineEventKind.ProfileSwitch.color())
|
||||
LegendEntry("Conn", TimelineEventKind.ConnectionEvent.color())
|
||||
LegendEntry(stringResource(R.string.timeline_legend_chat), TimelineEventKind.ChatMessage.color())
|
||||
LegendEntry(stringResource(R.string.timeline_legend_tool), TimelineEventKind.ToolCall.color())
|
||||
LegendEntry(stringResource(R.string.timeline_legend_voice), TimelineEventKind.VoiceTurn.color())
|
||||
LegendEntry(stringResource(R.string.timeline_legend_profile), TimelineEventKind.ProfileSwitch.color())
|
||||
LegendEntry(stringResource(R.string.timeline_legend_conn), TimelineEventKind.ConnectionEvent.color())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,10 +377,10 @@ private fun StatusCheckRow(
|
||||
private fun StatusPill(status: CheckStatus) {
|
||||
val color = status.statusColor()
|
||||
val label = when (status) {
|
||||
CheckStatus.Pass -> "PASS"
|
||||
CheckStatus.Warn -> "WARN"
|
||||
CheckStatus.Fail -> "FAIL"
|
||||
CheckStatus.Unknown -> "UNKNOWN"
|
||||
CheckStatus.Pass -> stringResource(R.string.timeline_pass)
|
||||
CheckStatus.Warn -> stringResource(R.string.timeline_warn)
|
||||
CheckStatus.Fail -> stringResource(R.string.timeline_fail)
|
||||
CheckStatus.Unknown -> stringResource(R.string.timeline_status_unknown)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
@@ -396,15 +397,16 @@ private fun StatusPill(status: CheckStatus) {
|
||||
}
|
||||
|
||||
/** One-line "N failing · N warning · N passing" summary for the header. */
|
||||
@Composable
|
||||
private fun statusSummary(checks: List<StatusCheck>): String {
|
||||
if (checks.isEmpty()) return "no checks"
|
||||
if (checks.isEmpty()) return stringResource(R.string.timeline_no_checks)
|
||||
val fail = checks.count { it.status == CheckStatus.Fail }
|
||||
val warn = checks.count { it.status == CheckStatus.Warn }
|
||||
val pass = checks.count { it.status == CheckStatus.Pass }
|
||||
return buildList {
|
||||
if (fail > 0) add("$fail failing")
|
||||
if (warn > 0) add("$warn warning")
|
||||
add("$pass passing")
|
||||
if (fail > 0) add(pluralStringResource(R.plurals.timeline_summary_fail, fail, fail))
|
||||
if (warn > 0) add(pluralStringResource(R.plurals.timeline_summary_warn, warn, warn))
|
||||
add(pluralStringResource(R.plurals.timeline_summary_pass, pass, pass))
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
@@ -459,7 +461,7 @@ private fun TimelineRow(
|
||||
modifier = Modifier.width(56.dp),
|
||||
)
|
||||
Text(
|
||||
text = primary.title,
|
||||
text = localizeTimelineTitle(primary.title),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -509,7 +511,7 @@ private fun TimelineRow(
|
||||
.background(sibling.kind.color()),
|
||||
)
|
||||
Text(
|
||||
text = sibling.title,
|
||||
text = localizeTimelineTitle(sibling.title),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ fun ToolProgressCard(
|
||||
val riskDescription = toolCall.outputRisk?.let {
|
||||
stringResource(R.string.tool_output_risk_a11y, it)
|
||||
}.orEmpty()
|
||||
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription) +
|
||||
val toolDescription = stringResource(R.string.tool_a11y, localizeToolName(toolCall.name), statusText, durationDescription) +
|
||||
riskDescription
|
||||
|
||||
Card(
|
||||
@@ -177,7 +177,7 @@ fun ToolProgressCard(
|
||||
|
||||
// Tool name — tool.generating may arrive nameless
|
||||
Text(
|
||||
text = if (isPreparing) toolCall.name.ifBlank { "Preparing tool…" } else toolCall.name,
|
||||
text = if (isPreparing) localizeToolName(toolCall.name).ifBlank { stringResource(R.string.tool_preparing) } else localizeToolName(toolCall.name),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isPreparing) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else androidx.compose.ui.graphics.Color.Unspecified,
|
||||
@@ -211,7 +211,7 @@ fun ToolProgressCard(
|
||||
// Expand/collapse
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
contentDescription = if (expanded) stringResource(R.string.tool_progress_cd_collapse) else stringResource(R.string.tool_progress_cd_expand),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -20,8 +20,6 @@ import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -37,7 +35,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CenterFocusStrong
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
@@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -73,6 +74,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -80,6 +82,7 @@ import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
@@ -145,7 +148,6 @@ fun VoiceModeOverlay(
|
||||
voiceOutputModel: String? = null,
|
||||
voiceOutputVoice: String? = null,
|
||||
voiceProfileName: String? = null,
|
||||
voiceConfigScope: String? = null,
|
||||
voiceOutputEnabled: Boolean? = null,
|
||||
voiceOutputFallbackEnabled: Boolean? = null,
|
||||
onOverlayRequest: () -> Unit = {},
|
||||
@@ -169,6 +171,7 @@ fun VoiceModeOverlay(
|
||||
{ _, _, _ -> },
|
||||
// === END v0.4.1 ===
|
||||
) {
|
||||
val backgroundVisualizationEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
@@ -191,55 +194,59 @@ fun VoiceModeOverlay(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focusMode) surface.copy(alpha = 0.96f) else Color.Transparent)
|
||||
// Click-through fix: in focus mode the overlay is a true modal.
|
||||
// Consume any pointer event a child (mic button, transcript,
|
||||
// chips, pill) didn't handle so stray taps/swipes don't fall
|
||||
// through to the chat + session drawer behind it. Children run on
|
||||
// the same Main pass leaf-first, so this only catches the gaps.
|
||||
// In Conversation the overlay is intentionally transparent and the
|
||||
// chat stays interactive, so no scrim is installed.
|
||||
.then(
|
||||
if (focusMode) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
) {
|
||||
if (focusMode) {
|
||||
// Focus is modal, but its click-through guard must be a sibling
|
||||
// behind the controls. A consuming pointerInput on the root is an
|
||||
// ancestor of every button and cancels their gesture detectors on
|
||||
// later pointer passes, leaving ripples without firing callbacks.
|
||||
// This scrim wins hit-testing only where no foreground control did,
|
||||
// so blank-space gestures stay off the chat underneath while mic,
|
||||
// close, pill, and expanded-panel controls remain interactive.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("voiceFocusInputScrim")
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent().changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
VoiceSessionPill(
|
||||
uiState = uiState,
|
||||
expanded = controlsExpanded,
|
||||
onExpandedChange = { controlsExpanded = it },
|
||||
focusMode = focusMode,
|
||||
onFocusModeChange = setFocusMode,
|
||||
engineMode = voiceEngineMode,
|
||||
provider = voiceOutputProvider,
|
||||
model = voiceOutputModel,
|
||||
voice = voiceOutputVoice,
|
||||
profileName = voiceProfileName,
|
||||
configScope = voiceConfigScope,
|
||||
outputEnabled = voiceOutputEnabled,
|
||||
fallbackEnabled = voiceOutputFallbackEnabled,
|
||||
onModeChange = onModeChange,
|
||||
onMicTap = onMicTap,
|
||||
onMicRelease = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
onOverlayRequest = onOverlayRequest,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onExit = onDismiss,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
|
||||
if (focusMode) {
|
||||
VoiceSessionPill(
|
||||
uiState = uiState,
|
||||
expanded = controlsExpanded,
|
||||
onExpandedChange = { controlsExpanded = it },
|
||||
focusMode = true,
|
||||
onFocusModeChange = setFocusMode,
|
||||
engineMode = voiceEngineMode,
|
||||
provider = voiceOutputProvider,
|
||||
model = voiceOutputModel,
|
||||
voice = voiceOutputVoice,
|
||||
profileName = voiceProfileName,
|
||||
outputEnabled = voiceOutputEnabled,
|
||||
fallbackEnabled = voiceOutputFallbackEnabled,
|
||||
onModeChange = onModeChange,
|
||||
onMicTap = onMicTap,
|
||||
onMicRelease = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
onOverlayRequest = onOverlayRequest,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onExit = onDismiss,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.zIndex(2f)
|
||||
.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
|
||||
// Center column: sphere -> waveform -> scrolling transcript.
|
||||
//
|
||||
@@ -320,14 +327,16 @@ fun VoiceModeOverlay(
|
||||
.weight(1.0f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = voiceStateToSphereState(uiState.state),
|
||||
voiceAmplitude = uiState.amplitude,
|
||||
voiceMode = true,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (backgroundVisualizationEnabled) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = voiceStateToSphereState(uiState.state),
|
||||
voiceAmplitude = uiState.amplitude,
|
||||
voiceMode = true,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VoiceWaveform(
|
||||
@@ -402,7 +411,12 @@ fun VoiceModeOverlay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(visibleTranscriptMessages, key = { it.id }) { msg ->
|
||||
// Match ChatScreen's identity contract. A history
|
||||
// reconcile can adopt the same authoritative server
|
||||
// id into a live row while Compose still holds the
|
||||
// pre-reconcile row for a frame. uiKey remains stable
|
||||
// and unique across that transition.
|
||||
items(visibleTranscriptMessages, key = ::voiceTranscriptItemKey) { msg ->
|
||||
CompactTranscriptRow(
|
||||
message = msg,
|
||||
showThinking = showThinking,
|
||||
@@ -415,7 +429,7 @@ fun VoiceModeOverlay(
|
||||
)
|
||||
}
|
||||
if (pendingTranscriptText != null) {
|
||||
item(key = "pending-voice-transcript") {
|
||||
item(key = "aux:pending-voice-transcript") {
|
||||
CompactTranscriptRow(
|
||||
message = ChatMessage(
|
||||
id = "pending-voice-transcript",
|
||||
@@ -471,7 +485,7 @@ fun VoiceModeOverlay(
|
||||
exit = fadeOut(tween(180)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 120.dp, start = 24.dp, end = 24.dp),
|
||||
.padding(bottom = 176.dp, start = 24.dp, end = 24.dp),
|
||||
) {
|
||||
BackgroundRunChip(
|
||||
run = uiState.backgroundRun,
|
||||
@@ -510,7 +524,7 @@ fun VoiceModeOverlay(
|
||||
// retrying, so a failed/timed-out turn never traps the user
|
||||
// on a retry-only banner.
|
||||
TextButton(onClick = { onClearError() }) {
|
||||
Text("Dismiss")
|
||||
Text(stringResource(R.string.common_dismiss))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
@@ -729,14 +743,248 @@ internal fun pendingVoiceTranscriptText(
|
||||
return if (alreadyRendered) null else transcribed
|
||||
}
|
||||
|
||||
/** Message rows and Voice-only auxiliary rows occupy disjoint key namespaces. */
|
||||
internal fun voiceTranscriptItemKey(message: ChatMessage): String = "message:${message.uiKey}"
|
||||
|
||||
/**
|
||||
* Conversation-mode voice controls that live inside the chat composer.
|
||||
*
|
||||
* The full voice surface keeps its top session pill. Conversation mode instead
|
||||
* shares one bottom input system with text chat: a stateful mic is the primary
|
||||
* action, while focus mode and detailed route/mode controls remain secondary.
|
||||
* This keeps chat readable and removes the duplicate top-level mic affordance.
|
||||
*/
|
||||
@Composable
|
||||
private fun InteractionMode.label(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk)
|
||||
InteractionMode.HoldToTalk -> stringResource(R.string.voice_overlay_hold_to_talk)
|
||||
InteractionMode.Continuous -> stringResource(R.string.voice_overlay_continuous)
|
||||
fun ConversationVoiceDock(
|
||||
uiState: VoiceUiState,
|
||||
engineMode: String?,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
voice: String?,
|
||||
profileName: String?,
|
||||
outputEnabled: Boolean?,
|
||||
onMicTap: () -> Unit,
|
||||
onMicRelease: () -> Unit,
|
||||
onInterrupt: () -> Unit,
|
||||
onPauseAutoMode: () -> Unit,
|
||||
onModeChange: (InteractionMode) -> Unit,
|
||||
onFocusRequest: () -> Unit,
|
||||
onOverlayRequest: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val engineText = voiceEngineLabel(engineMode)
|
||||
val providerText = voiceProviderLabel(provider, model, voice, outputEnabled)
|
||||
val profileText = profileName?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.voice_overlay_default_profile)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("conversationVoiceDock"),
|
||||
) {
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, top = 8.dp, end = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
InteractionMode.values().forEach { mode ->
|
||||
VoiceControlChip(
|
||||
text = mode.shortLabel(),
|
||||
selected = uiState.interactionMode == mode,
|
||||
onClick = { onModeChange(mode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
VoiceRouteSummary(
|
||||
engine = engineText,
|
||||
profile = profileText,
|
||||
provider = providerText,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onOverlayRequest,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_overlay),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onExit,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_exit),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
onExit()
|
||||
onOpenSettings()
|
||||
},
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = stringResource(R.string.voice_overlay_settings_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = uiState.handoffStatus != null) {
|
||||
VoiceHandoffStrip(
|
||||
status = uiState.handoffStatus,
|
||||
compact = true,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(58.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val agentIconPath = LocalAgentIconPath.current
|
||||
Surface(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
|
||||
) {
|
||||
if (!agentIconPath.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(agentIconPath),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceWaveform(
|
||||
amplitude = uiState.amplitude,
|
||||
state = uiState.state,
|
||||
outputAudioActive = uiState.outputAudioActive,
|
||||
height = 26.dp,
|
||||
compactBars = true,
|
||||
modifier = Modifier.width(40.dp),
|
||||
)
|
||||
Text(
|
||||
text = conversationDockStateLabel(uiState.state),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
ConversationVoiceMicButton(
|
||||
uiState = uiState,
|
||||
onMicTap = onMicTap,
|
||||
onMicRelease = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
baseSize = 52,
|
||||
iconSize = 24,
|
||||
modifier = Modifier.testTag("conversationVoiceDockMic"),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onFocusRequest,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.testTag("conversationVoiceDockFocus"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CenterFocusStrong,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.voice_dock_focus),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.testTag("conversationVoiceDockExpand"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandMore else Icons.Filled.ExpandLess,
|
||||
contentDescription = if (expanded) {
|
||||
stringResource(R.string.voice_overlay_collapse_cd)
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_expand_cd)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun conversationDockStateLabel(state: VoiceState): String = when (state) {
|
||||
VoiceState.Idle -> stringResource(R.string.voice_overlay_state_ready)
|
||||
VoiceState.Listening -> stringResource(R.string.voice_overlay_state_listening)
|
||||
VoiceState.Transcribing -> stringResource(R.string.voice_overlay_state_transcribing)
|
||||
VoiceState.Thinking -> stringResource(R.string.voice_overlay_state_thinking)
|
||||
VoiceState.Speaking -> stringResource(R.string.voice_overlay_state_speaking)
|
||||
VoiceState.Error -> stringResource(R.string.voice_overlay_state_error)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun VoiceSessionPill(
|
||||
uiState: VoiceUiState,
|
||||
@@ -749,7 +997,6 @@ private fun VoiceSessionPill(
|
||||
model: String?,
|
||||
voice: String?,
|
||||
profileName: String?,
|
||||
configScope: String?,
|
||||
outputEnabled: Boolean?,
|
||||
fallbackEnabled: Boolean?,
|
||||
onModeChange: (InteractionMode) -> Unit,
|
||||
@@ -765,17 +1012,6 @@ private fun VoiceSessionPill(
|
||||
val engineText = voiceEngineLabel(engineMode)
|
||||
val providerText = voiceProviderLabel(provider, model, voice, outputEnabled)
|
||||
val profileText = profileName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_default_profile)
|
||||
val scopeText = when (configScope) {
|
||||
"profile" -> stringResource(R.string.voice_overlay_profile_voice)
|
||||
"relay" -> stringResource(R.string.voice_overlay_relay_voice)
|
||||
"global" -> stringResource(R.string.voice_overlay_global_voice)
|
||||
else -> null
|
||||
}
|
||||
val headlineText = if (focusMode) {
|
||||
"$engineText / $profileText / $providerText"
|
||||
} else {
|
||||
"${stateHint(uiState.state).ifBlank { stringResource(R.string.voice_overlay_voice_ready) }} / $engineText / $providerText"
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
@@ -817,23 +1053,21 @@ private fun VoiceSessionPill(
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_voice),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
// Collapsed header trimmed to icon + "Voice" + a single
|
||||
// weighted title + chevron + close so the top bar can never
|
||||
// wrap on narrow screens. The status pill and inline mic
|
||||
// control moved into the expanded body below.
|
||||
Text(
|
||||
text = headlineText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_voice),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = "$engineText · $profileText",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (!focusMode) {
|
||||
ConversationVoiceMicButton(
|
||||
uiState = uiState,
|
||||
@@ -877,18 +1111,6 @@ private fun VoiceSessionPill(
|
||||
.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// The expanded body keeps the current interaction mode
|
||||
// visible; Conversation's persistent mic stays in the
|
||||
// collapsed header so it never disappears.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusPill(uiState.interactionMode.label())
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -903,28 +1125,26 @@ private fun VoiceSessionPill(
|
||||
}
|
||||
}
|
||||
|
||||
// Status pills use a FlowRow so they wrap to a second line
|
||||
// on narrow screens instead of being squeezed into equal
|
||||
// weighted columns that truncate every label (4a).
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
StatusPill(engineText, emphasized = true)
|
||||
StatusPill(profileText)
|
||||
scopeText?.let { StatusPill(it) }
|
||||
StatusPill(providerText)
|
||||
}
|
||||
VoiceRouteSummary(
|
||||
engine = engineText,
|
||||
profile = profileText,
|
||||
provider = providerText,
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onFocusModeChange(!focusMode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1.35f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(
|
||||
if (focusMode) {
|
||||
@@ -932,6 +1152,7 @@ private fun VoiceSessionPill(
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_focus)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -941,15 +1162,29 @@ private fun VoiceSessionPill(
|
||||
onFocusModeChange(false)
|
||||
onOverlayRequest()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(0.95f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.voice_overlay_overlay), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringResource(R.string.voice_overlay_overlay),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onExit,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(0.75f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.voice_overlay_exit), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringResource(R.string.voice_overlay_exit),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Settings link (4c): exit voice mode before navigating
|
||||
// so the overlay isn't left floating over the Voice
|
||||
@@ -959,6 +1194,7 @@ private fun VoiceSessionPill(
|
||||
onExit()
|
||||
onOpenSettings()
|
||||
},
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
@@ -980,6 +1216,9 @@ private fun ConversationVoiceMicButton(
|
||||
onMicRelease: () -> Unit,
|
||||
onInterrupt: () -> Unit,
|
||||
onPauseAutoMode: () -> Unit,
|
||||
baseSize: Int = 40,
|
||||
iconSize: Int = 20,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
VoiceMicButton(
|
||||
uiState = uiState,
|
||||
@@ -1003,8 +1242,9 @@ private fun ConversationVoiceMicButton(
|
||||
)
|
||||
},
|
||||
onHoldRelease = onMicRelease,
|
||||
baseSize = 40,
|
||||
iconSize = 20,
|
||||
baseSize = baseSize,
|
||||
iconSize = iconSize,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1090,37 +1330,49 @@ private fun VoiceControlChip(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusPill(
|
||||
text: String,
|
||||
private fun VoiceRouteSummary(
|
||||
engine: String,
|
||||
profile: String,
|
||||
provider: String,
|
||||
modifier: Modifier = Modifier,
|
||||
emphasized: Boolean = false,
|
||||
) {
|
||||
val providerLine = provider.replace(" / ", " · ")
|
||||
Surface(
|
||||
modifier = modifier.height(24.dp),
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (emphasized) {
|
||||
// Opaque — translucent status bubbles over the floating overlay were
|
||||
// hard to read against the sphere/chat behind them.
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (emphasized) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$engine · $profile",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = providerLine,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1199,13 +1451,37 @@ private fun voiceProviderLabel(
|
||||
val providerPart = provider?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_provider_placeholder)
|
||||
val modelPart = model?.takeIf { it.isNotBlank() }
|
||||
val voicePart = voice?.takeIf { it.isNotBlank() }
|
||||
return listOfNotNull(providerPart, modelPart, voicePart).joinToString(" / ")
|
||||
return listOfNotNull(providerPart, modelPart, voicePart)
|
||||
.let(::distinctVoiceRouteParts)
|
||||
.map(::voiceRouteDisplayLabel)
|
||||
.joinToString(" / ")
|
||||
}
|
||||
|
||||
internal fun distinctVoiceRouteParts(parts: List<String>): List<String> =
|
||||
parts.distinctBy { value ->
|
||||
value.lowercase().filter(Char::isLetterOrDigit)
|
||||
}
|
||||
|
||||
internal fun voiceRouteDisplayLabel(value: String): String =
|
||||
value
|
||||
.split('_', '-')
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(" ") { token ->
|
||||
when (token.lowercase()) {
|
||||
"xai" -> "xAI"
|
||||
"openai" -> "OpenAI"
|
||||
"tts" -> "TTS"
|
||||
"api" -> "API"
|
||||
else -> token.replaceFirstChar { first ->
|
||||
if (first.isLowerCase()) first.titlecase() else first.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
|
||||
"realtime_agent" -> stringResource(R.string.voice_overlay_engine_realtime)
|
||||
"hermes_voice_output" -> stringResource(R.string.voice_overlay_engine_hermes)
|
||||
"hermes_voice_output" -> stringResource(R.string.voice_overlay_engine_standard)
|
||||
null, "" -> stringResource(R.string.voice_overlay_engine_placeholder)
|
||||
else -> engineMode
|
||||
.replace('_', ' ')
|
||||
@@ -1378,8 +1654,10 @@ private fun VoiceRichResultAffordance(
|
||||
R.string.voice_overlay_rich_results_count,
|
||||
message.attachments.size + inlineImages.size,
|
||||
)
|
||||
message.attachments.isNotEmpty() || inlineImages.isNotEmpty() ->
|
||||
voiceRichResultUsesImageLabel(message, inlineImages.size) ->
|
||||
stringResource(R.string.voice_overlay_image_ready)
|
||||
message.attachments.isNotEmpty() ->
|
||||
stringResource(R.string.voice_overlay_attachment_ready)
|
||||
else -> stringResource(R.string.voice_overlay_rich_result_ready)
|
||||
}
|
||||
|
||||
@@ -1406,7 +1684,11 @@ private fun VoiceRichResultAffordance(
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Image,
|
||||
imageVector = if (voiceRichResultUsesImageLabel(message, inlineImages.size)) {
|
||||
Icons.Filled.Image
|
||||
} else {
|
||||
Icons.Filled.Description
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
@@ -1427,6 +1709,19 @@ private fun VoiceRichResultAffordance(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-specific copy is valid only when the single surfaced result is an
|
||||
* actual image attachment or an inline image. Documents and unknown MIME types
|
||||
* intentionally fall back to neutral attachment copy.
|
||||
*/
|
||||
internal fun voiceRichResultUsesImageLabel(
|
||||
message: ChatMessage,
|
||||
inlineImageCount: Int,
|
||||
): Boolean {
|
||||
if (message.attachments.size + inlineImageCount != 1) return false
|
||||
return inlineImageCount == 1 || message.attachments.singleOrNull()?.isImage == true
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceToolStatusRow(toolCall: ToolCall) {
|
||||
// Internal enum-style status drives both color and the localized label
|
||||
@@ -1752,7 +2047,7 @@ private fun BackgroundRunChip(
|
||||
imageVector = Icons.Filled.Close,
|
||||
// The VM treats ✕ on a DONE chip as a local dismiss,
|
||||
// never a cancel — label it accordingly for TalkBack.
|
||||
contentDescription = if (done) "Dismiss" else "Cancel background task",
|
||||
contentDescription = if (done) stringResource(R.string.common_dismiss) else stringResource(R.string.voice_overlay_cancel_task_cd),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
|
||||
@@ -216,8 +216,11 @@ fun VoiceWaveform(
|
||||
|
||||
if (compactBars) {
|
||||
val barCount = 10
|
||||
val gap = 3.dp.toPx()
|
||||
val barWidth = 2.5.dp.toPx()
|
||||
// Keep the compact waveform inside its assigned composer lane.
|
||||
// The previous 52 dp drawing width overflowed the 32 dp canvas and
|
||||
// visually collided with the adjacent state label.
|
||||
val gap = 2.dp.toPx()
|
||||
val barWidth = 2.dp.toPx()
|
||||
val totalWidth = barWidth * barCount + gap * (barCount - 1)
|
||||
val startX = ((width - totalWidth) / 2f).coerceAtLeast(0f)
|
||||
repeat(barCount) { index ->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user