Compare commits
331
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d04264a224 | ||
|
|
ee30ec4bf7 | ||
|
|
111c5409bf | ||
|
|
03ce9aa8d5 | ||
|
|
41c2d10700 | ||
|
|
c508392261 | ||
|
|
e9c0620d24 | ||
|
|
217746a243 | ||
|
|
a40e6a5b01 | ||
|
|
118bf07638 | ||
|
|
95f6721b06 | ||
|
|
ac023c0e24 | ||
|
|
cd7792918e | ||
|
|
e617809930 | ||
|
|
79532c6c9b | ||
|
|
0af1cda38e | ||
|
|
454bd44e7a | ||
|
|
b92a40174e | ||
|
|
c6ab26ef13 | ||
|
|
414ade5fa0 | ||
|
|
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 | ||
|
|
77e34c2c02 | ||
|
|
f4ee409106 | ||
|
|
aa26f7c9b6 | ||
|
|
bfb608bea6 | ||
|
|
95a95fe7d2 | ||
|
|
1bdf2ae71b | ||
|
|
f9e7a2f320 | ||
|
|
988fac8522 | ||
|
|
d4832a6a38 | ||
|
|
f9c8736e5b | ||
|
|
a815dd33fa | ||
|
|
cc9c75a636 | ||
|
|
43179e03c0 | ||
|
|
325b8e5670 | ||
|
|
693ac4ed64 | ||
|
|
f94d663ac4 | ||
|
|
d1a21bd42e | ||
|
|
7ee2d73010 | ||
|
|
682bde84fe | ||
|
|
16bdbe5f44 | ||
|
|
f43fba9fed | ||
|
|
d1745413fd | ||
|
|
1b7a8025c3 | ||
|
|
d92a87483e | ||
|
|
e9da59cf40 | ||
|
|
0bea626ed8 | ||
|
|
f453dd27f3 | ||
|
|
c9a03c9ed7 | ||
|
|
36546c2712 | ||
|
|
fbe3fc3e05 | ||
|
|
a77cebec43 | ||
|
|
8167f93705 | ||
|
|
52dc46072e | ||
|
|
c18ab4cce8 | ||
|
|
52b28e5b48 | ||
|
|
575fd82c59 | ||
|
|
ead3f5fd4f | ||
|
|
36a922be64 | ||
|
|
530a1c9591 | ||
|
|
353faa9da5 | ||
|
|
76fdbcfd70 | ||
|
|
5365e22fff | ||
|
|
1e3974fd88 | ||
|
|
72854d58b1 | ||
|
|
b63e0e726d | ||
|
|
fff3ba8d91 | ||
|
|
7c155e3693 | ||
|
|
d15d3b594c | ||
|
|
915b2ebe54 | ||
|
|
cf3634ee2b | ||
|
|
88b11856d3 | ||
|
|
aede6c8cb3 | ||
|
|
c1187f0f2f | ||
|
|
6ff473820c | ||
|
|
847a21cc0c | ||
|
|
8ebd97643d | ||
|
|
8aded16da9 | ||
|
|
a35c0b34f8 | ||
|
|
877cfad88a | ||
|
|
fb0b8deef7 | ||
|
|
1a710f071c | ||
|
|
89b1461431 | ||
|
|
ce72f7790e | ||
|
|
51f4d29ebb | ||
|
|
97a2dac96d | ||
|
|
a569361d43 | ||
|
|
35dfc8c051 | ||
|
|
4a1ece7ad0 | ||
|
|
41b6fb4f84 | ||
|
|
a15b247d1a | ||
|
|
2d486dd395 | ||
|
|
0fcb833c83 | ||
|
|
4507b2270a | ||
|
|
1cd3da5ac6 | ||
|
|
afe2be9304 | ||
|
|
60c14b5db1 | ||
|
|
e3d6dd9509 | ||
|
|
91d05c982e | ||
|
|
85bbbd004d | ||
|
|
a8f61f2aeb | ||
|
|
d554c1819a | ||
|
|
920e7d58f0 | ||
|
|
0ce7c6c6b3 | ||
|
|
46e8f90c39 | ||
|
|
2d28f171e0 | ||
|
|
fdd8301796 | ||
|
|
c9ddc2ef8d | ||
|
|
b0d662b802 | ||
|
|
63ca0a1428 | ||
|
|
8196856d76 | ||
|
|
605ff00cc0 | ||
|
|
c9b1e1b04e | ||
|
|
a4466e4ca0 | ||
|
|
e13e381e3a | ||
|
|
19d33910d0 | ||
|
|
41480a3254 | ||
|
|
50f151edba | ||
|
|
0f108fe971 | ||
|
|
c8d6119e1a | ||
|
|
b2b7a2572b | ||
|
|
1ccf87401a | ||
|
|
50b1a17895 | ||
|
|
d536923c55 | ||
|
|
14a2e01ac5 | ||
|
|
a0d03f6947 | ||
|
|
478eeac3f7 | ||
|
|
799159457a | ||
|
|
f90650bdc9 | ||
|
|
6a41ec154c | ||
|
|
53f4c8187b | ||
|
|
675252090c | ||
|
|
e94db467e8 | ||
|
|
65dae79c8c | ||
|
|
e5d0334c8b | ||
|
|
9cc7b25fd1 | ||
|
|
2ce352a320 | ||
|
|
a6957268b9 | ||
|
|
8f42b96be1 | ||
|
|
8a9c058ddb | ||
|
|
5ef2c40f81 | ||
|
|
6b32fe7ddd | ||
|
|
02f38322fa | ||
|
|
f40b7abaf0 | ||
|
|
5fcbe5ff63 | ||
|
|
6ce504b1c9 | ||
|
|
31ebef825f | ||
|
|
68abbf156d | ||
|
|
73a8af7c8f | ||
|
|
2db00d4c59 | ||
|
|
847444d115 | ||
|
|
798e8eef55 | ||
|
|
35f791be50 | ||
|
|
e727979897 | ||
|
|
91025b733f | ||
|
|
34da86a96a | ||
|
|
30f9f51e2a | ||
|
|
713529f79f | ||
|
|
16d1728306 | ||
|
|
7a813995ba | ||
|
|
c86b2224ce | ||
|
|
11a782bc44 | ||
|
|
884a17ded7 | ||
|
|
745904d468 | ||
|
|
4258322acc | ||
|
|
2c544b8ab0 | ||
|
|
5122ee69f6 | ||
|
|
512199448e | ||
|
|
7a7b10f08a | ||
|
|
f7845291e7 | ||
|
|
689219405b | ||
|
|
f4e9de425a | ||
|
|
2bfe21eaff | ||
|
|
d4cdb96bab | ||
|
|
c5f35145b4 | ||
|
|
3283c9b601 | ||
|
|
44f030f93b | ||
|
|
abce00f49e | ||
|
|
123f1d1263 | ||
|
|
61c4337807 | ||
|
|
6e1338004c | ||
|
|
348152a7cb | ||
|
|
3ec34502ec | ||
|
|
d30de8656d | ||
|
|
9b9b8c7c06 | ||
|
|
93b82aa538 | ||
|
|
7f2049fa0a | ||
|
|
92444a7039 | ||
|
|
a02d7e10df | ||
|
|
8a3935ffa1 | ||
|
|
e48935929a | ||
|
|
1e82347e7d | ||
|
|
75ed3c4226 | ||
|
|
bade61ac34 | ||
|
|
f88559f856 |
@@ -89,7 +89,7 @@ jobs:
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-android.yml \
|
||||
--ref="android-v${VERSION}" \
|
||||
--ref=main \
|
||||
-f version="$VERSION"
|
||||
|
||||
- name: Approval summary
|
||||
@@ -97,4 +97,4 @@ jobs:
|
||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -38,10 +38,11 @@ on:
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
|
||||
# commit must finish its independent validation.
|
||||
concurrency:
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -62,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' }}
|
||||
|
||||
@@ -94,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' }}
|
||||
|
||||
@@ -138,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' }}
|
||||
|
||||
@@ -202,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' }}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
|
||||
#
|
||||
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
|
||||
# intentionally small: check out a clean upstream hermes-agent beside Relay and
|
||||
# run the desktop typed-stream/renderer tests that protect the gateway event
|
||||
# contract. A later expansion can boot the upstream gateway with a mock provider
|
||||
# once that harness is stable enough for CI.
|
||||
|
||||
name: CI - Desktop Upstream Baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_ref:
|
||||
description: "NousResearch/hermes-agent ref to check"
|
||||
required: false
|
||||
default: "main"
|
||||
schedule:
|
||||
- cron: "30 6 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-upstream-baseline-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop-baseline:
|
||||
name: Desktop typed gateway baseline
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}"
|
||||
else
|
||||
REF="main"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout vanilla upstream
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Assert upstream checkout is vanilla
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
|
||||
fi
|
||||
git -C _upstream status --short --untracked-files=no
|
||||
|
||||
- name: Run desktop gateway baseline contract
|
||||
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Run desktop gateway baseline tests
|
||||
working-directory: desktop
|
||||
env:
|
||||
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
|
||||
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ on:
|
||||
tags:
|
||||
- "android-v*"
|
||||
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
||||
# does not recursively start workflows. It explicitly dispatches this file
|
||||
# at that tag instead. Manual tag pushes continue to use the push trigger.
|
||||
# does not recursively start workflows. It dispatches the current workflow
|
||||
# definition from main, while every job checks out the immutable tag. Manual
|
||||
# tag pushes continue to use the push trigger.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -37,19 +38,22 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
env:
|
||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
|
||||
if [ -n "$DISPATCHED_VERSION" ]; then
|
||||
REF_VERSION="$DISPATCHED_VERSION"
|
||||
fi
|
||||
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then
|
||||
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION"
|
||||
exit 1
|
||||
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
|
||||
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
fi
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
||||
@@ -67,8 +71,8 @@ jobs:
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -111,6 +115,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -119,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
|
||||
|
||||
@@ -147,6 +153,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -155,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
|
||||
|
||||
|
||||
+115
@@ -8,9 +8,124 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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.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.
|
||||
|
||||
## [Server 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
|
||||
|
||||
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
|
||||
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
|
||||
|
||||
## [Android 1.5.1] - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice supports focused and conversational layouts.** Focus keeps spoken turns, Markdown, tools, media, and actions in a compact voice surface, while Conversation opens the full Chat renderer without leaving the active voice session.
|
||||
- **Voice can speak only settled answers.** A global Voice setting keeps tool progress, service updates, and intermediate commentary visual while supported voice paths wait to speak the final Hermes answer.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat answers are easier to read in every theme.** Primary assistant text now uses the theme's full-contrast foreground, and chat prose uses a 15sp size with 21sp line height.
|
||||
- **Google Play builds target Android 16.** The app now targets API level 36 while retaining its existing minimum-device support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Completed streamed answers render their formatting without losing the reading position.** Markdown headings, lists, emphasis, and code blocks replace the live text renderer only after completion, then the measured trailing edge remains anchored at the bottom.
|
||||
- **Standard Voice speaks completed assistant replies again.** Session and message fences no longer suppress a valid final answer during the handoff from generation to narration.
|
||||
- **Realtime background work no longer blocks the active voice controls.** A promoted task releases the foreground spinner and microphone while its progress, tools, cancellation, and final result remain available in the owning chat.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
|
||||
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
|
||||
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
|
||||
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -168,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
|
||||
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
|
||||
checks, Google Play debug lint, and the same focused unit-test shard used by
|
||||
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)
|
||||
- **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.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -1,5 +1,351 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 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
|
||||
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
|
||||
the advertised native PKCE system-browser flow, while compatible self-hosted
|
||||
providers retain cookie-backed full-page Dashboard authentication. Callback
|
||||
origin discovery remains server-driven, private-network HTTP compatibility is
|
||||
preserved, and arbitrary public HTTP redirects remain rejected.
|
||||
|
||||
The private Play preflight validated the exact application tree before release
|
||||
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
|
||||
`main` tip, the production workflow promoted versionCode 35 to the completed
|
||||
Google Play production track, and the public GitHub release contains the
|
||||
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
|
||||
checksum was independently verified; replacing the debug-signed phone build
|
||||
with the release-signed artifact requires an uninstall because Android
|
||||
correctly rejects cross-signature in-place updates.
|
||||
|
||||
## 2026-07-27 — Android replayed-message identity reconciliation
|
||||
|
||||
Android history reconciliation now collapses reconnect/rejoin replays of the
|
||||
same persisted message ID before publishing the transcript to Compose. The
|
||||
latest repeated snapshot replaces the value at the message's first transcript
|
||||
position, preserving stable ordering, distinct messages, and the LazyColumn
|
||||
identity contract without index- or random-key fallbacks.
|
||||
|
||||
Focused coverage reproduces the duplicate UUID condition and verifies that the
|
||||
authoritative final content wins while every rendered message keeps a unique
|
||||
stable UI key.
|
||||
|
||||
## 2026-07-26 — Android 1.5.1 patch reconciliation
|
||||
|
||||
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
|
||||
34. Voice now offers compact Focus and full Conversation presentation,
|
||||
Standard narration preserves valid completed replies, and promoted Realtime
|
||||
tasks release foreground voice controls while retaining progress and results.
|
||||
|
||||
Completed streamed answers promote from the stable live text node to full
|
||||
Markdown only after completion. The measured Markdown row is then positioned
|
||||
by its trailing edge until deferred code and attachment measurement settles,
|
||||
preventing the LazyColumn from restoring the start of a tall response.
|
||||
|
||||
The release also targets Android API level 36. Release notes, in-app What's
|
||||
New assets, localized Play notes, and the Play listing reference were updated
|
||||
for Android 1.5.1.
|
||||
|
||||
## 2026-07-25 — Immutable Android release dispatch repair
|
||||
|
||||
Android approval now dispatches the current release workflow definition from
|
||||
`main`, while every release job explicitly checks out the immutable
|
||||
`android-v*` tag. Validation confirms the dispatched version resolves to that
|
||||
checked-out commit and accepts the repository's surface-qualified
|
||||
`[Android x.y.z]` changelog heading. Existing tags remain unchanged, and a
|
||||
workflow-only correction can resume a failed publication without rebuilding
|
||||
from a different application tree.
|
||||
|
||||
Audited `.github/workflows/approve-release-android.yml`,
|
||||
`.github/workflows/release-android.yml`, `RELEASE.md`, and `DEVLOG.md`.
|
||||
|
||||
## 2026-07-25 — Android 1.5.0 final release reconciliation
|
||||
|
||||
The final Android 1.5.0 release tree reconciles the accumulated Dashboard-first
|
||||
connection, Gateway recovery, background delivery, Agent Passport, onboarding,
|
||||
voice, attachment, image-generation, security, localization, and Developer
|
||||
Options work into one public release narrative. Android remains version 1.5.0
|
||||
with monotonic versionCode 33 because that prepared version was not previously
|
||||
tagged or uploaded to production.
|
||||
|
||||
Release notes, the in-app What's New assets, localized Play release notes, and
|
||||
the Play listing reference now describe the final tree rather than the earlier
|
||||
voice-focused candidate. The release train is gated by the exact-tree private
|
||||
Play preflight before the `dev` to `main` release merge and public tag.
|
||||
|
||||
## 2026-07-25 — Active-turn retention and actionable interaction alerts
|
||||
|
||||
Android now promotes user-started chat work to foreground execution until every
|
||||
connection/profile/session-scoped turn settles. Independent leases preserve
|
||||
concurrent detached Gateway sessions, track sessions paused for input, and
|
||||
prevent one completion from stopping protection for siblings. The existing
|
||||
Persistent connection switch now extends retention only to idle periods.
|
||||
|
||||
The foreground notification reports active and waiting session counts.
|
||||
Interaction alerts add privacy-safe expanded profile/session context and an
|
||||
explicit review, answer, or secure-response action that deep-links to the exact
|
||||
conversation; commands, questions, passwords, secrets, and environment-variable
|
||||
names remain confined to the authenticated chat surface.
|
||||
|
||||
Audited `ChatViewModel`, `ConnectionViewModel`, `GatewayChatClient`,
|
||||
`GatewayKeepAliveService`, `InteractionRequestNotifier`, the shared Android
|
||||
manifest, Android settings copy, chat user documentation, and Play foreground
|
||||
service declaration guidance.
|
||||
|
||||
## 2026-07-24 — Post-connect permission setup
|
||||
|
||||
Android onboarding now finishes with a layered permission step after a
|
||||
successful Hermes connection. Standard Chat and Manage are explicitly ready
|
||||
without a phone grant; Android notifications are recommended through one
|
||||
user-triggered runtime prompt; camera, microphone, notification companion, and
|
||||
flavor-supported device tools remain individually optional on the centralized
|
||||
Permissions screen. Existing just-in-time permission prompts remain available
|
||||
when users skip setup.
|
||||
|
||||
Coverage separates the Android-version notification policy from the Compose
|
||||
presentation and checks the recommended, granted, optional-review, and skip
|
||||
states. English and all shipped Android locale catalogs were updated together.
|
||||
|
||||
## 2026-07-24 — Realtime background-task delivery continuity
|
||||
|
||||
Chat stream completion now preserves an otherwise empty assistant row when it
|
||||
owns a promoted background task. This keeps the task identity available after
|
||||
the provider's initial spoken handoff, so later progress, completion, and
|
||||
forced-summary events update and settle the initiating row even when a newer
|
||||
persistent Voice command has started. Existing empty-response cleanup remains
|
||||
unchanged for assistant rows without background work.
|
||||
|
||||
## 2026-07-23 — Background interaction notifications
|
||||
|
||||
Android Gateway chat now treats approval, clarification, elevated-permission,
|
||||
and secret requests as actionable background events. Privacy-safe notifications
|
||||
use stable per-session identities, reopen the exact conversation, replace
|
||||
replayed requests, and clear on answer, expiry, or resumed turn activity.
|
||||
Detached active turns retain and replay their pending interaction when the
|
||||
conversation is reopened.
|
||||
|
||||
The audit classified `terminal.read.request` as renderer plumbing rather than a
|
||||
user decision. Android now answers it with the upstream no-terminal empty
|
||||
response instead of showing an interaction or waiting for the server timeout.
|
||||
The shared main manifest continues to provide notification and persistent
|
||||
connection support to both Google Play and sideload builds.
|
||||
|
||||
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
|
||||
|
||||
The shared Android network security configuration now accepts CA certificates
|
||||
that the device owner deliberately installed in Android's user credential store,
|
||||
in addition to system roots. Because the policy is attached to the common
|
||||
application manifest, it covers both product flavors and every platform-backed
|
||||
Hermes transport: endpoint probes, Dashboard requests and authentication
|
||||
WebView, redirects, Gateway WebSockets, API streaming, Standard Voice, and
|
||||
Relay HTTPS/WSS. Default OkHttp and WebView certificate-chain and hostname
|
||||
checks remain active, and Relay's independent certificate pinner is not
|
||||
overridden.
|
||||
|
||||
An app-wide policy is required for arbitrary operator-supplied server names and
|
||||
for consistent WebView behavior. A runtime opt-in would require parallel custom
|
||||
trust implementations for each client and could not safely reconfigure WebView;
|
||||
per-connection CA import would add private trust-material lifecycle without
|
||||
covering all transports. The tradeoff is that Android disables public
|
||||
Certificate Transparency verification when user trust anchors are enabled.
|
||||
User documentation records the deliberate-installation boundary and a device or
|
||||
emulator validation procedure with positive chain and negative hostname checks.
|
||||
JVM coverage locks the accepted anchor sources and rejects pin overrides or
|
||||
debug-only trust additions.
|
||||
|
||||
## 2026-07-23 — Bounded Android code-highlighting ranges
|
||||
|
||||
Android Markdown code rendering now validates every syntax-highlighting span
|
||||
before applying it to Compose text. The bundled highlighting dependency can
|
||||
emit a reversed multiline-comment range when malformed or incomplete code
|
||||
contains a closing delimiter before its opening delimiter; the Markdown
|
||||
renderer previously passed that range directly to `AnnotatedString` and
|
||||
crashed. Both fenced and indented code use the guarded renderer, valid spans
|
||||
remain highlighted, and malformed ranges are clipped or ignored. Focused JVM
|
||||
coverage reproduces the dependency output and verifies the safe conversion.
|
||||
|
||||
## 2026-07-20 — Image generation placeholder during turns
|
||||
|
||||
Android chat now specializes the generic tool lifecycle for active
|
||||
`image_generate` calls. While the tool is pending, the message shows a
|
||||
theme-aware procedural diffusion canvas with a polite live-region announcement
|
||||
instead of a generic tool card. The completed tool result still replaces the
|
||||
placeholder through the existing tool completion path. Coverage includes pure
|
||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
||||
|
||||
## 2026-07-20 — Faster Android validation feedback
|
||||
|
||||
Android contributors now have one cross-platform pre-push command for locale,
|
||||
documentation, collection-API, and version checks plus primary Play-variant
|
||||
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
|
||||
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
|
||||
Android SDK without writing worktree-local configuration. Hosted CI retains
|
||||
the exhaustive all-variant lint gate.
|
||||
|
||||
Android CI now cancels a superseded run on `dev` or a pull-request ref while
|
||||
preserving every `main` run. A newer integration commit therefore stops paying
|
||||
for an older release smoke that can no longer become the tested release tip.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
@@ -6558,3 +6904,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.
|
||||
|
||||
+7
-10
@@ -1,20 +1,17 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
# Hermes-Relay-Server v__VERSION__
|
||||
|
||||
**Release Date:** July 15, 2026
|
||||
**Release Date:** August 3, 2026
|
||||
|
||||
This patch aligns Server default with Hermes' sticky active profile and lets paired clients import conventional profile avatar files without exposing host paths.
|
||||
This patch makes intentional re-pairing repair the existing device record instead of accumulating duplicate Relay sessions.
|
||||
|
||||
Pairs with Hermes-Relay-Android v1.4.6 for profile image import. 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
|
||||
|
||||
- **Paired clients can import profile avatars.** Relay discovers conventional direct-child images such as `avatar.png` and `profile.jpg`, validates their media type, size, and profile boundary, and serves the bytes through an authenticated route.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server default follows Hermes' active profile.** Advertised identity, model, SOUL, profile metadata, and avatar resolve through the sticky `active_profile` marker instead of always using the root profile.
|
||||
- **Re-pairing replaces stale credentials for the same device.** After the host approves a new pair, Relay revokes older sessions and refresh credentials that belong to that device before issuing the replacement.
|
||||
- **Existing and unrelated sessions remain operator-controlled.** The Dashboard and `/relay revoke <token-prefix>` continue to provide explicit cleanup without treating optional Relay pairing as a requirement.
|
||||
|
||||
## Install / update
|
||||
|
||||
@@ -22,7 +19,7 @@ Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat a
|
||||
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.
|
||||
|
||||
|
||||
+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);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
## 中文界面
|
||||
|
||||
+6
-2
@@ -610,8 +610,12 @@ git push origin dev
|
||||
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
|
||||
`main`, and enter the version. Starting the workflow is the release approval. It
|
||||
verifies that `main` has the exact preflighted tree and creates the
|
||||
`android-v<version>` tag. Manual stable tags are still guarded by the same
|
||||
preflight proof in the tag workflow.
|
||||
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
|
||||
another workflow, approval dispatches the current release workflow definition
|
||||
from `main`; every release job explicitly checks out and verifies the immutable
|
||||
`android-v<version>` tag. This lets release-workflow fixes apply without moving
|
||||
an existing tag or changing its artifact tree. Manual stable tags are still
|
||||
guarded by the same preflight proof in the tag workflow.
|
||||
|
||||
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
||||
artifacts, changes the existing Play Production draft to `completed` (submitting
|
||||
|
||||
+12
-12
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.4.9
|
||||
# Hermes-Relay-Android v1.6.1
|
||||
|
||||
**Release Date:** July 19, 2026
|
||||
**Release Date:** August 3, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.4.9-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.6.1-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,19 +12,19 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes the Hermes dashboard the standard connection path and refreshes setup, connection management, and profile identity throughout Android.
|
||||
|
||||
## Changed
|
||||
|
||||
- Connect through the Hermes dashboard for Chat, sessions, Manage, and voice with one sign-in. The API server remains an automatic fallback or headless compatibility path, and Relay remains optional for power features.
|
||||
- Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, and Relay paths with clearer status, route, startup, Advanced, and Security controls.
|
||||
This patch clarifies optional Relay recovery, restores the full session drawer, and fixes several chat and Voice regressions.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Server default now shows Hermes' pinned active profile consistently across Chat, sessions, agent details, settings, voice, diagnostics, and profile inspection.
|
||||
- Successful local discovery adds useful hostname identity without replacing a custom connection label.
|
||||
- Optional Relay failures stay within Relay-only surfaces, use consistent status labels, and only request re-pairing when the stored Relay credential actually needs it.
|
||||
- Foreground recovery reconnects immediately after ordinary backoff, while retained credentials are described as stored details rather than an active in-memory session.
|
||||
- Session history loads its 200-row drawer window through upstream-compatible 100-row pages instead of failing with HTTP 422.
|
||||
- Selecting text remains stable when a streamed response changes from live text to rendered Markdown.
|
||||
- Manual Voice recording waits for barge-in microphone teardown and gives a useful recovery message when the microphone is unavailable.
|
||||
- New-chat coaching yields while Voice owns the composer, so it no longer covers the expanding Voice drawer.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.4.9** (versionCode **32**).
|
||||
- App version: **1.6.1** (versionCode **38**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Same-device Relay re-pair replacement requires the optional Server 1.5.1 plugin; Dashboard and `/relay revoke <token-prefix>` remain available for explicit cleanup.
|
||||
|
||||
@@ -6,6 +6,42 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Verify Android native dashboard sign-in on device
|
||||
|
||||
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
|
||||
`native_pkce`. The lifecycle-owned callback binds only `127.0.0.1` on an
|
||||
OS-assigned port, keeps verifier/state inside the sign-in coroutine, rejects
|
||||
untrusted callback noise, and closes on completion, cancellation, navigation,
|
||||
or timeout. Encrypted bearer/refresh tokens authenticate Gateway chat, Manage,
|
||||
prewarm, and standard voice; sign-out clears both cookie and native sessions.
|
||||
Older gateways retain the identified WebView cookie fallback.
|
||||
|
||||
Before release, device-test the real Custom Tab → provider → loopback return,
|
||||
configuration/background transitions, Manage reload, Gateway chat ticket,
|
||||
standard voice, sign-out, and process relaunch. Native bearer exchange remains
|
||||
disabled for non-loopback HTTP dashboard addresses; configure HTTPS before
|
||||
using the native flow.
|
||||
|
||||
---
|
||||
|
||||
## Active — Remove temporary GitHub Pages docs redirects
|
||||
|
||||
PR #210 moved current source and production documentation to
|
||||
@@ -36,6 +72,68 @@ removal.
|
||||
|
||||
---
|
||||
|
||||
## Upstream impact certification follow-ups (2026-07-19)
|
||||
|
||||
The client/plugin implementation batch for queued recovery,
|
||||
multiplex-profile fallback routing, gateway diagnostics, Windows system-CA
|
||||
trust, and retained bootstrap async safety is implemented. The following gates
|
||||
intentionally remain outside that code batch:
|
||||
|
||||
- **Image-generation lifecycle while tool progress is hidden.** The upstream
|
||||
TUI gateway suppresses every `tool.start` / `tool.complete` event when
|
||||
`display.tool_progress` is off, so a client cannot distinguish an active
|
||||
`image_generate` turn from generic model work. Propose a narrow upstream
|
||||
exception that always emits the lifecycle for `image_generate` while leaving
|
||||
unrelated tool diagnostics hidden. Android already treats that lifecycle as
|
||||
presentation state rather than a generic tool card and keeps the diffusion
|
||||
canvas visible when its local tool display is off.
|
||||
- Run `docs/upstream-compatibility-certification.md` against an approved test
|
||||
gateway with real provider calls and an Android device. Include concurrent
|
||||
model/image routing, turn isolation off/on, queued reconnect, same-profile
|
||||
background-completion ownership, compression lineage, and the explicitly
|
||||
approved restart case. Static upstream fixtures are necessary but do not
|
||||
prove device or restart behavior.
|
||||
- Upstream the atomic one-turn model arm/submit contract proposed in
|
||||
`docs/upstream-contributions.md`. Until then, document the narrow race where a
|
||||
disconnect or Stop after `/model --once` succeeds but before prompt submission
|
||||
can leave the override armed for a later prompt.
|
||||
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
|
||||
exposes a reset on the active gateway session or an authoritative reset event.
|
||||
`slash.exec` runs the command in a separate worker today, and the mirrored
|
||||
slash side effects do not reset the active TUI session's agent. Relay must not
|
||||
clear local model, reasoning, or Fast pins from a successful command response
|
||||
that did not mutate the agent those controls describe.
|
||||
- Keep profile-scoped cron execution attempts blocked on the public upstream API
|
||||
proposed in `docs/upstream-contributions.md`. The first-class interim
|
||||
assistant event is no longer blocked: Relay Android and desktop consume
|
||||
upstream `message.interim` / `response_previewed`.
|
||||
- Keep Standard voice labeled host-global until upstream exposes a stable
|
||||
profile/per-request audio contract; do not emulate it through Relay on the
|
||||
vanilla path.
|
||||
- Keep provider exclusion/disable filtering out of Android Manage until the
|
||||
public model-options payload identifies excluded and disabled providers.
|
||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||
empty models are not authoritative evidence that a provider should be hidden.
|
||||
- Keep persistent approval-mode writes for multiplexed non-launch profiles
|
||||
read-only until upstream `config.get` / `config.set` bind an explicit
|
||||
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
|
||||
accepts `approvals.mode` but resolves it against the gateway process home;
|
||||
Android may reconcile a selected profile's `session.info.approval_mode`, but
|
||||
must not claim a profile-scoped write that upstream ignores.
|
||||
- Keep gateway `model.options` profile scoping blocked until the supported
|
||||
upstream RPC accepts an explicit `profile` and documents that the returned
|
||||
provider inventory was built inside that profile's runtime scope. Android
|
||||
now keys picker results to its active profile context and rejects late
|
||||
responses after a profile switch, but it deliberately does not send an
|
||||
invented `profile` parameter. API-server fallback can use the separate,
|
||||
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
checkout and the desktop typed gateway renderer/tests.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
|
||||
|
||||
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
||||
@@ -637,9 +735,10 @@ Deferred:
|
||||
|
||||
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
|
||||
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 15sp/21sp, primary assistant prose moved
|
||||
to the theme's full-contrast `onSurface`, inline+fenced code 13sp, `textLink`
|
||||
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
|
||||
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
@@ -651,10 +750,6 @@ gated to pre-first-token. Deferred:
|
||||
parses one full CommonMark document so global link references, indentation, and
|
||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
|
||||
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
|
||||
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
|
||||
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
||||
@@ -965,12 +1060,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.
|
||||
@@ -1005,11 +1102,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
|
||||
|
||||
@@ -1070,7 +1190,7 @@ Things to look into:
|
||||
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
|
||||
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
|
||||
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Done (2026-07-19, HRUI-004/012):** retained session search now uses upstream `AsyncSessionDB` when available and `asyncio.to_thread` on older Hermes, and every compatibility memory mutation resets the upstream consolidation-failure budget when that API exists. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
@@ -1121,6 +1241,7 @@ Follow-ups:
|
||||
|
||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||
|
||||
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
|
||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||
@@ -1140,11 +1261,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.
|
||||
|
||||
|
||||
+26
-3
@@ -37,7 +37,7 @@ android {
|
||||
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
||||
applicationId = "com.axiomlabs.hermesrelay"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
targetSdk = 36
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
@@ -125,6 +125,7 @@ android {
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
@@ -164,6 +165,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
|
||||
@@ -245,6 +261,7 @@ dependencies {
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.browser)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Core
|
||||
@@ -261,6 +278,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
|
||||
@@ -326,8 +349,8 @@ dependencies {
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
/**
|
||||
* Debug-build-only live host for fast image-generation motion tuning.
|
||||
*
|
||||
* Launch directly:
|
||||
* adb shell am start -n <applicationId>/
|
||||
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
|
||||
*/
|
||||
class ImageGenerationDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent {
|
||||
HermesRelayTheme(themePreference = themePreference) {
|
||||
ImageGenerationDesignQaScene(onBack = ::finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
|
||||
var restartKey by remember { mutableIntStateOf(0) }
|
||||
var durationMillis by remember { mutableIntStateOf(4_800) }
|
||||
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
|
||||
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Image generation lab") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Live debug preview · no generation request",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
ImageGenerationVisualStyle.LatentGrid to "Grid",
|
||||
ImageGenerationVisualStyle.ParticleOrb to "Orb",
|
||||
ImageGenerationVisualStyle.Constellation to "Nodes",
|
||||
).forEach { (style, label) ->
|
||||
FilterChip(
|
||||
selected = visualStyle == style,
|
||||
onClick = { visualStyle = style },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
key(restartKey, durationMillis, visualStyle) {
|
||||
val startedAtMillis = remember { System.currentTimeMillis() }
|
||||
ImageGenerationResultTransition(
|
||||
generating = !showResult,
|
||||
startedAtMillis = startedAtMillis,
|
||||
animationDurationMillis = durationMillis,
|
||||
visualStyle = visualStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_generation_transition_preview),
|
||||
contentDescription = "Generated landscape preview",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Generated image",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "12.4s",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Cycle speed",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
7_200 to "Slow",
|
||||
4_800 to "Normal",
|
||||
3_200 to "Fast",
|
||||
).forEach { (duration, label) ->
|
||||
FilterChip(
|
||||
selected = durationMillis == duration,
|
||||
onClick = { durationMillis = duration },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = true
|
||||
},
|
||||
enabled = !showResult,
|
||||
) {
|
||||
Text("Reveal result")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = false
|
||||
restartKey++
|
||||
},
|
||||
) {
|
||||
Text("Restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
|
||||
|
||||
/** Debug-build-only deterministic host for design QA screenshots. */
|
||||
class VoiceSettingsDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VoiceSettingsDesignQaScene() {
|
||||
val provider = remember {
|
||||
RealtimeProviderInfo(
|
||||
id = "xai_tts",
|
||||
name = "xAI Grok TTS",
|
||||
status = "ready",
|
||||
models = listOf("grok-tts", "grok-tts-fast"),
|
||||
voices = listOf("eve", "ara", "sal", "rex", "leo"),
|
||||
model_labels = mapOf("grok-tts" to "Grok TTS"),
|
||||
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
|
||||
recommended_voices = listOf("eve", "ara"),
|
||||
supports_tts = true,
|
||||
)
|
||||
}
|
||||
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
|
||||
var selectedVoice by remember { mutableStateOf("eve") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val allVoices = remember {
|
||||
listOf(
|
||||
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
|
||||
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
|
||||
VoiceChoice("sal", "Sal", "Calm · grounded"),
|
||||
VoiceChoice("rex", "Rex", "Direct · confident"),
|
||||
VoiceChoice("leo", "Leo", "Bright · conversational"),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
VoiceSettingsTabs(selectedSection) { selectedSection = it }
|
||||
VoiceProviderGroupCard(
|
||||
provider = provider,
|
||||
providerValue = provider.id,
|
||||
enabled = true,
|
||||
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
|
||||
onEnabledChange = {},
|
||||
onProviderChange = {},
|
||||
controlsEnabled = true,
|
||||
)
|
||||
ModelAndVoiceGroupCard(
|
||||
modelValue = "grok-tts",
|
||||
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
|
||||
voices = previewVoiceChoices(allVoices, selectedVoice),
|
||||
allVoices = allVoices,
|
||||
selectedVoice = selectedVoice,
|
||||
previewState = VoicePreviewUiState(
|
||||
selectionKey = "voice:eve",
|
||||
isPlaying = true,
|
||||
amplitude = 0.42f,
|
||||
),
|
||||
onModelChange = {},
|
||||
onVoiceChange = { selectedVoice = it },
|
||||
onPreviewVoice = {},
|
||||
enabled = true,
|
||||
)
|
||||
LanguageQualityCard(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
language = "English",
|
||||
languages = listOf(VoiceChoice("en", "English")),
|
||||
onLanguageChange = {},
|
||||
sampleRate = "24000",
|
||||
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
|
||||
onSampleRateChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1 +1 @@
|
||||
Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice. Setup and connection details now explain nearby, remote, Tailscale, custom-port, optional Relay, route, and security choices. Server default also displays Hermes' pinned active profile consistently across the app.
|
||||
Optional Relay recovery now stays in the right surfaces with clear status labels and immediate foreground retry. Session history again loads its full window through upstream-compatible paging. Streamed text selection, Voice microphone handoff, and the expanding Voice drawer are also more reliable.
|
||||
|
||||
@@ -1 +1 @@
|
||||
现在可通过 Hermes 控制面板一次登录使用聊天、会话、管理和语音。设置与连接详情会清楚说明附近设备、远程访问、Tailscale、自定义端口、可选 Relay、路由和安全选项。“服务器默认”也会在整个应用中一致显示 Hermes 当前固定的活跃配置文件。
|
||||
新增可在界面中漫游的 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.
|
||||
@@ -90,12 +98,10 @@
|
||||
android:name=".notifications.ProactiveReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Opt-in "Persistent connection" — holds the user's connection to
|
||||
Hermes open while backgrounded so messages and live features stay
|
||||
responsive (relay-paired setups also keep device control +
|
||||
notification mirroring reachable). In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
<!-- 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. -->
|
||||
<service
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
@@ -103,9 +109,71 @@
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
|
||||
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. -->
|
||||
<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. -->
|
||||
<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,164 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
"date": "2026-07-28",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Provider-compatible sign-in",
|
||||
"bullets": [
|
||||
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
|
||||
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Stable conversation updates",
|
||||
"bullets": [
|
||||
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.1",
|
||||
"title": "Voice and chat stay in place",
|
||||
"date": "2026-07-26",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice at the right depth",
|
||||
"bullets": [
|
||||
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
|
||||
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Reliable narration and background work",
|
||||
"bullets": [
|
||||
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
|
||||
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Formatted answers stay readable",
|
||||
"bullets": [
|
||||
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
|
||||
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Hermes, always in reach",
|
||||
"date": "2026-07-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One secure Hermes connection",
|
||||
"bullets": [
|
||||
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active work stays reachable",
|
||||
"bullets": [
|
||||
"Multiple user-started chats remain active in the background until every session settles.",
|
||||
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Richer chat and voice",
|
||||
"bullets": [
|
||||
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup without surprises",
|
||||
"bullets": [
|
||||
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.9",
|
||||
"title": "Clearer Hermes connections",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
v1.4.9 - Clearer Hermes connections
|
||||
v1.6.1 - Clearer recovery, steadier chat
|
||||
|
||||
* Connect through the Hermes dashboard with one sign-in; API fallback and optional Relay remain available.
|
||||
* Onboarding and connection details now explain nearby, remote, Tailscale, custom-port, route, and security choices.
|
||||
* Server default consistently displays Hermes' pinned active profile, and discovered servers show useful host identity.
|
||||
* Keep optional Relay recovery scoped to Relay surfaces with clear status labels.
|
||||
* Restore the 200-session drawer through upstream-compatible paging.
|
||||
* Stabilize streamed text selection and Voice microphone handoff.
|
||||
* Keep new-chat coaching clear of the expanding Voice drawer.
|
||||
|
||||
@@ -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,28 +5,34 @@ 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
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
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.
|
||||
@@ -66,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 {
|
||||
@@ -87,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 ===
|
||||
@@ -113,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()
|
||||
@@ -121,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
|
||||
@@ -128,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 ===
|
||||
}
|
||||
|
||||
@@ -137,11 +163,49 @@ 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
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// Action-required notifications are durable across process death.
|
||||
// Once the authenticated chat surface is visible it owns presentation;
|
||||
// unresolved asks are re-posted if the app returns to the background.
|
||||
InteractionRequestNotifier.cancelAll(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -142,6 +142,21 @@ data class ChatMessage(
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
/**
|
||||
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||
* Unavailable advisors retain only neutral state, never their raw failure
|
||||
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
)
|
||||
|
||||
data class MoaReference(
|
||||
val index: Int,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
@@ -392,6 +407,8 @@ data class ChatSession(
|
||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||
*/
|
||||
val source: String? = null,
|
||||
/** Server reports a persisted session runtime/model binding. */
|
||||
val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
|
||||
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnMoaReferenceCheckpoint(
|
||||
val index: Int,
|
||||
val count: Int? = null,
|
||||
val label: String,
|
||||
val text: String = "",
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -13,6 +13,8 @@ data class DashboardConnectionStatus(
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val gatewayMode: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -112,6 +114,8 @@ data class Connection(
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
const val DEFAULT_API_PORT: Int = 8642
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
@@ -187,6 +191,29 @@ data class Connection(
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
|
||||
fun deriveDefaultApiUrl(
|
||||
dashboardUrl: String,
|
||||
apiPort: Int = DEFAULT_API_PORT,
|
||||
): String? {
|
||||
val trimmed = dashboardUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$apiPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
@@ -196,7 +223,7 @@ data class Connection(
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
relayPort: Int = DEFAULT_RELAY_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
@@ -220,6 +247,7 @@ data class Connection(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
dashboardUrl: String? = null,
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
@@ -228,6 +256,7 @@ data class Connection(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
@@ -239,6 +268,7 @@ data class Connection(
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
@@ -313,6 +343,7 @@ data class Connection(
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
dashboardUrl: String? = null,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
@@ -336,12 +367,62 @@ data class Connection(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
|
||||
dashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
|
||||
?.let { DashboardEndpoint(url = it) }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl)
|
||||
?.let { DashboardEndpoint(url = it) },
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile stored API-derived routes with the Dashboard origin that
|
||||
* was actually verified during setup. Older app versions synthesized
|
||||
* `:9119` for every API route, even when the same host was reached
|
||||
* through an HTTPS reverse proxy on 443. Replace only that conventional
|
||||
* synthesized value (or a missing value); preserve explicit and
|
||||
* different-host LAN/Tailscale routes.
|
||||
*/
|
||||
fun reconcileDashboardRoutes(
|
||||
dashboardUrl: String?,
|
||||
candidates: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val explicitDashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return candidates
|
||||
return candidates.map { candidate ->
|
||||
val apiUrl = candidate.api?.url ?: return@map candidate
|
||||
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
|
||||
|
||||
val currentDashboard = candidate.dashboard?.url
|
||||
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
|
||||
val canReplace = currentDashboard.isNullOrBlank() ||
|
||||
(
|
||||
derivedDashboard != null &&
|
||||
currentDashboard.trim().trimEnd('/')
|
||||
.equals(derivedDashboard, ignoreCase = true)
|
||||
)
|
||||
if (canReplace) {
|
||||
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
|
||||
} else {
|
||||
candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
|
||||
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
|
||||
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
|
||||
return !leftHost.isNullOrBlank() &&
|
||||
!rightHost.isNullOrBlank() &&
|
||||
leftHost.equals(rightHost, ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||
* legacy API authority when present so an older API-only candidate and
|
||||
|
||||
@@ -543,29 +543,6 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
@@ -585,3 +562,40 @@ class ConnectionStore private constructor(
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore route defaults after loading a serialized connection. This remains
|
||||
* internal so focused persistence tests can exercise the same normalization
|
||||
* path used by [ConnectionStore].
|
||||
*/
|
||||
internal fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
|
||||
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
)
|
||||
}
|
||||
val normalizedRoutes = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
candidates = storedOrDefaultRoutes,
|
||||
)
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
dashboardUrl != effectiveDashboardUrl ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,9 +255,11 @@ class DataManager(
|
||||
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
||||
outputStream.flush()
|
||||
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||
?: return@withContext false
|
||||
outputStream.use {
|
||||
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -288,9 +290,10 @@ class DataManager(
|
||||
* - Clear DataStore preferences
|
||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||
* - Clear any cached data
|
||||
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
|
||||
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
|
||||
* should show onboarding again.
|
||||
*/
|
||||
suspend fun resetAppData() {
|
||||
suspend fun resetAppData(): Boolean =
|
||||
try {
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
@@ -320,10 +323,11 @@ class DataManager(
|
||||
}
|
||||
|
||||
Log.d(TAG, "App data reset complete")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset app data", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -384,16 +388,17 @@ class DataManager(
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
*/
|
||||
suspend fun resetOnboarding() {
|
||||
suspend fun resetOnboarding(): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||
}
|
||||
Log.d(TAG, "Onboarding flag reset")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed.
|
||||
@@ -412,13 +417,14 @@ class DataManager(
|
||||
/**
|
||||
* Mark onboarding as completed.
|
||||
*/
|
||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
||||
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
|
||||
/**
|
||||
* Feature flags with compile-time defaults and runtime overrides.
|
||||
*
|
||||
* In debug builds, all features are unlocked by default.
|
||||
* In debug builds, Developer Options are unlocked by default until the user
|
||||
* explicitly locks them.
|
||||
* In release builds, experimental features are hidden unless the user
|
||||
* enables Developer Options (tap version 7 times in Settings > About).
|
||||
*
|
||||
@@ -21,7 +22,7 @@ object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
|
||||
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
|
||||
@@ -29,15 +30,38 @@ object FeatureFlags {
|
||||
/** Observe whether Developer Options have been unlocked. */
|
||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||
}
|
||||
|
||||
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
|
||||
fun relayEnabled(context: Context): Flow<Boolean> =
|
||||
/**
|
||||
* 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 ->
|
||||
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
|
||||
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 ->
|
||||
@@ -45,18 +69,11 @@ object FeatureFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock Developer Options and disable all experimental features. */
|
||||
/** Lock Developer Options, including in debug builds. */
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||
prefs[KEY_RELAY_ENABLED] = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
|
||||
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
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(
|
||||
|
||||
@@ -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,16 @@ 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
|
||||
* assistant commentary are not narrated.
|
||||
*/
|
||||
val finalAnswerOnly: Boolean = false,
|
||||
/** Presentation only; changing this never restarts or interrupts voice. */
|
||||
val presentationMode: String = VoicePresentationMode.Focus.storageValue,
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
@@ -122,6 +137,16 @@ enum class VoiceAudioRoute(val storageValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoicePresentationMode(val storageValue: String) {
|
||||
Focus("focus"),
|
||||
Conversation("conversation");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoicePresentationMode =
|
||||
values().firstOrNull { it.storageValue == value } ?: Focus
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active scope for per-profile voice prefs.
|
||||
*
|
||||
@@ -182,6 +207,9 @@ 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")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
@@ -191,8 +219,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
||||
const val DEFAULT_FINAL_ANSWER_ONLY = false
|
||||
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].
|
||||
@@ -258,6 +292,11 @@ 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,
|
||||
).storageValue,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
@@ -367,6 +406,23 @@ 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 }
|
||||
}
|
||||
|
||||
suspend fun setPresentationMode(mode: VoicePresentationMode) {
|
||||
dataStore.edit { it[KEY_PRESENTATION_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
@@ -375,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
|
||||
|
||||
@@ -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
|
||||
@@ -16,9 +17,11 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
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 +47,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 +178,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 +190,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 +221,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 +281,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 +344,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 +371,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 +386,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 +431,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) {
|
||||
@@ -398,6 +462,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 ||
|
||||
@@ -429,7 +501,11 @@ class ConnectionManager(
|
||||
|
||||
serverUrl = normalized
|
||||
shouldReconnect = true
|
||||
reconnectAttempt = 0
|
||||
if (preserveReconnectBackoff) {
|
||||
reconnectState.beginAutomaticRouteSwap(normalized)
|
||||
} else {
|
||||
reconnectState.beginExplicitConnect(normalized)
|
||||
}
|
||||
doConnect(normalized, previousSocket, replaceReason)
|
||||
}
|
||||
|
||||
@@ -445,9 +521,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 +565,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 +600,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 +609,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 +647,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 +671,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 +696,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 +743,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 +812,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 +868,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 +890,8 @@ class ConnectionManager(
|
||||
// the ViewModel on the next connection load.
|
||||
_manualRoleOverride.value = null
|
||||
_activeEndpoint.value = null
|
||||
activeRelayEndpoint = null
|
||||
reconnectState.reset()
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
@@ -815,19 +928,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
|
||||
@@ -884,9 +1006,12 @@ 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(
|
||||
@@ -983,14 +1108,14 @@ class ConnectionManager(
|
||||
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 +1154,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 +1170,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 +1208,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 +1226,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,91 @@ class RelayHttpClient(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivitySnapshot(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
val profile: String,
|
||||
val activities: List<ImageActivity> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivity(
|
||||
@SerialName("call_id") val callId: String,
|
||||
@SerialName("tool_name") val toolName: String,
|
||||
val state: String,
|
||||
@SerialName("started_at") val startedAt: Double,
|
||||
@SerialName("completed_at") val completedAt: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Poll the optional Relay image lifecycle bridge. A null success means the
|
||||
* connected Relay predates the endpoint; callers should stop polling and
|
||||
* continue using native Gateway events without surfacing an error.
|
||||
*/
|
||||
suspend fun fetchImageActivity(
|
||||
profile: String,
|
||||
sessionId: String,
|
||||
sinceEpochSeconds: Double,
|
||||
): Result<ImageActivitySnapshot?> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = try {
|
||||
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("profile", profile)
|
||||
.addQueryParameter("session_id", sessionId)
|
||||
.addQueryParameter("since", sinceEpochSeconds.toString())
|
||||
.build()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val activityClient = okHttpClient.newBuilder()
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
try {
|
||||
activityClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Image activity request failed (HTTP ${response.code})")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Empty response body"))
|
||||
}
|
||||
Result.success(
|
||||
sessionsJson.decodeFromString(ImageActivitySnapshot.serializer(), body)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
|
||||
* [Result] — success carries a [FetchedMedia], failure wraps the
|
||||
@@ -615,6 +700,14 @@ class RelayHttpClient(
|
||||
val capabilities: List<String> = emptyList(),
|
||||
val profiles: List<RelayProfileInfo> = emptyList(),
|
||||
val health: String = "unknown",
|
||||
@SerialName("gateway_heartbeat") val gatewayHeartbeat: GatewayHeartbeat? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayHeartbeat(
|
||||
val status: String = "missing",
|
||||
val supported: Boolean = false,
|
||||
@SerialName("age_seconds") val ageSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/** Fetch the installed plugin/protocol/profile capability contract. */
|
||||
|
||||
@@ -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
|
||||
@@ -834,6 +836,11 @@ class RelayVoiceClient(
|
||||
suspend fun runVoiceOutput(
|
||||
text: String,
|
||||
renderMode: String? = "verbatim",
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
onEvent: (RealtimeVoiceEvent) -> Unit,
|
||||
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
|
||||
@@ -844,7 +851,15 @@ class RelayVoiceClient(
|
||||
return@withContext Result.failure(missingAuthError())
|
||||
}
|
||||
|
||||
val sessionResult = createVoiceOutputSession(httpBase, token)
|
||||
val sessionResult = createVoiceOutputSession(
|
||||
httpBase = httpBase,
|
||||
token = token,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
|
||||
}
|
||||
@@ -854,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)
|
||||
@@ -967,6 +983,7 @@ class RelayVoiceClient(
|
||||
}
|
||||
onEvent(event)
|
||||
if (event.isAudioDelta) {
|
||||
firstAudioSeen.set(true)
|
||||
event.audioEventId?.let {
|
||||
lastPlayedAudioEventId.updateAndGet { current -> maxOf(current, it) }
|
||||
}
|
||||
@@ -1110,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()
|
||||
@@ -1119,6 +1145,7 @@ class RelayVoiceClient(
|
||||
socket.close(1001, "timeout")
|
||||
Result.failure(IOException("Voice output timed out", e))
|
||||
} finally {
|
||||
firstAudioWatchdog.cancel()
|
||||
routeWatcher?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1261,8 +1288,11 @@ class RelayVoiceClient(
|
||||
inputSampleRate: Int = 16_000,
|
||||
chatSessionId: String? = null,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
@@ -1290,8 +1320,11 @@ class RelayVoiceClient(
|
||||
token = token,
|
||||
chatSessionId = chatSessionId,
|
||||
conversationContext = conversationContext,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
finalAnswerOnly = finalAnswerOnly,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||
@@ -1805,6 +1838,28 @@ class RelayVoiceClient(
|
||||
if (event.type == "hermes.run.promoted") {
|
||||
longRunningTurn.set(true)
|
||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
||||
if (persistent &&
|
||||
event.spokenHandoff == false &&
|
||||
activeTurn.compareAndSet(true, false)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime agent foreground turn ended at silent background promotion",
|
||||
)
|
||||
onTurnComplete(
|
||||
RealtimeVoiceSummary(
|
||||
provider = event.provider ?: session.provider,
|
||||
model = event.model ?: session.model,
|
||||
voice = event.voice ?: session.voice,
|
||||
sampleRate = session.sampleRate,
|
||||
audioChunks = audioChunks,
|
||||
audioBytes = audioBytes,
|
||||
firstAudioMs = event.firstAudioMs,
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (event.isAudioDelta) {
|
||||
audioChunks += 1
|
||||
@@ -1830,13 +1885,12 @@ class RelayVoiceClient(
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
if (persistent) {
|
||||
if (persistent && activeTurn.compareAndSet(true, false)) {
|
||||
// Turn boundary, not session boundary: keep the socket
|
||||
// open for the next utterance.
|
||||
activeTurn.set(false)
|
||||
longRunningTurn.set(false)
|
||||
onTurnComplete(summary)
|
||||
} else {
|
||||
} else if (!persistent) {
|
||||
if (claimTerminalSocket(
|
||||
webSocket,
|
||||
generation,
|
||||
@@ -2658,17 +2712,29 @@ class RelayVoiceClient(
|
||||
token: String,
|
||||
chatSessionId: String?,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
): Result<RealtimeSessionResponse> {
|
||||
val body = buildJsonObject {
|
||||
putProfile()
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("provider", JsonPrimitive(it))
|
||||
}
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("model", JsonPrimitive(it))
|
||||
}
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("voice", JsonPrimitive(it))
|
||||
}
|
||||
sampleRate?.takeIf { it > 0 }?.let {
|
||||
put("sample_rate", JsonPrimitive(it))
|
||||
}
|
||||
if (finalAnswerOnly) {
|
||||
put("final_answer_only", JsonPrimitive(true))
|
||||
}
|
||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("chat_session_id", JsonPrimitive(it))
|
||||
}
|
||||
@@ -2726,8 +2792,23 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildJsonObject { putProfile() }.toString()
|
||||
private fun createVoiceOutputSession(
|
||||
httpBase: String,
|
||||
token: String,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildVoiceOutputSessionPayload(
|
||||
profile = currentProfileName(),
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("$httpBase/voice/output/session")
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
@@ -2940,6 +3021,9 @@ class RelayVoiceClient(
|
||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
||||
spokenHandoff = (obj["spoken_handoff"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.toBooleanStrictOrNull(),
|
||||
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
||||
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
||||
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
||||
@@ -2970,6 +3054,22 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildVoiceOutputSessionPayload(
|
||||
profile: String?,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): String = buildJsonObject {
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", JsonPrimitive(it)) }
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let { put("provider", JsonPrimitive(it)) }
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let { put("voice", JsonPrimitive(it)) }
|
||||
sampleRate?.let { put("sample_rate", JsonPrimitive(it)) }
|
||||
language?.trim()?.takeIf { it.isNotBlank() }?.let { put("language", JsonPrimitive(it)) }
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* Wire shape of `GET /voice/config`. Providers are returned as nested
|
||||
* objects describing the currently-active STT and TTS backend. Extra
|
||||
@@ -3318,6 +3418,7 @@ data class RealtimeVoiceEvent(
|
||||
// ADR 33: background-run promotion fields.
|
||||
val tier: String? = null,
|
||||
val floor: String? = null,
|
||||
val spokenHandoff: Boolean? = null,
|
||||
// hermes.run.progress extras — drive the live background-run chip.
|
||||
val activeToolName: String? = null,
|
||||
val completedToolCount: Int? = null,
|
||||
|
||||
@@ -47,6 +47,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
|
||||
@@ -59,8 +69,8 @@ data class RouteProbeOutcome(
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** Dashboard-first routes use `GET
|
||||
* ${dashboard.url}/api/status`; legacy API routes use `HEAD
|
||||
* ${api.url}/health`. Relay-only routes use `HEAD ${relay.httpUrl}/health`.
|
||||
* ${dashboard.url}/api/status`; legacy API routes use `GET
|
||||
* ${api.url}/health`. Relay-only routes use `GET ${relay.httpUrl}/health`.
|
||||
* Each request has a 4-second
|
||||
* per-candidate timeout. Positive results are cached longer than negative
|
||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||
@@ -110,7 +120,6 @@ class EndpointResolver(
|
||||
val baseUrl: String,
|
||||
val requestUrl: String,
|
||||
val path: String,
|
||||
val useHead: Boolean,
|
||||
)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
@@ -125,9 +134,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(),
|
||||
@@ -168,21 +182,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.
|
||||
@@ -194,37 +231,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
|
||||
}
|
||||
@@ -238,17 +280,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
|
||||
}
|
||||
@@ -257,7 +302,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 +
|
||||
@@ -277,8 +322,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) {
|
||||
@@ -286,7 +334,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
|
||||
@@ -300,9 +348,12 @@ 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 target = probeTarget(candidate, surface)
|
||||
val url = target?.requestUrl?.toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
@@ -314,7 +365,7 @@ class EndpointResolver(
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.primaryRouteUrl(),
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid route URL")
|
||||
recordOutcome(candidate, surface, reachable = false, detail = "Invalid route URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
@@ -326,7 +377,11 @@ class EndpointResolver(
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "*/*")
|
||||
val request = if (target.useHead) requestBuilder.head().build() else requestBuilder.get().build()
|
||||
// Hermes API's aiohttp health route accepts GET but returns 405 to
|
||||
// HEAD. That response proves connectivity while the old probe marked
|
||||
// the route unreachable. Health payloads are tiny, so follow the
|
||||
// endpoint's actual public contract on every surface.
|
||||
val request = requestBuilder.get().build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
@@ -348,6 +403,7 @@ class EndpointResolver(
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
surface,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
|
||||
)
|
||||
@@ -363,7 +419,7 @@ class EndpointResolver(
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
@@ -376,7 +432,7 @@ class EndpointResolver(
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
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} " +
|
||||
@@ -390,14 +446,20 @@ class EndpointResolver(
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
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('/')
|
||||
@@ -407,7 +469,6 @@ class EndpointResolver(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/api/status",
|
||||
path = "/api/status",
|
||||
useHead = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -416,10 +477,13 @@ class EndpointResolver(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/health",
|
||||
path = "/health",
|
||||
useHead = true,
|
||||
)
|
||||
}
|
||||
|
||||
return relayProbeTarget(candidate)
|
||||
}
|
||||
|
||||
private fun relayProbeTarget(candidate: EndpointCandidate): ProbeTarget? {
|
||||
candidate.relay?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
@@ -436,7 +500,6 @@ class EndpointResolver(
|
||||
baseUrl = relayUrl,
|
||||
requestUrl = "$httpBase/health",
|
||||
path = "/health",
|
||||
useHead = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -467,13 +530,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",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import java.net.URI
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||
@@ -43,6 +44,66 @@ object ProfileApiUrlResolver {
|
||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical API base for a selected Hermes profile.
|
||||
*
|
||||
* A dedicated profile API URL remains authoritative when advertised. When
|
||||
* the dashboard has positively identified a shared multiplex gateway and
|
||||
* the selected non-default profile is in its served-profile list, route
|
||||
* through the upstream `/p/<profile>` mirror on the connection's root API
|
||||
* origin. Older/single-profile servers and incomplete topology snapshots
|
||||
* deliberately keep the root URL.
|
||||
*/
|
||||
fun resolveChatBase(
|
||||
profileApiUrl: String?,
|
||||
baseApiUrl: String?,
|
||||
selectedProfileName: String?,
|
||||
gatewayMode: String?,
|
||||
servedProfiles: Collection<String>,
|
||||
): String? {
|
||||
val base = normalize(baseApiUrl)
|
||||
val dedicated = resolveForConnection(profileApiUrl, base)
|
||||
if (dedicated != null) return dedicated
|
||||
|
||||
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()
|
||||
.addPathSegment("p")
|
||||
.addPathSegment(profile)
|
||||
.build()
|
||||
.toString()
|
||||
.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
|
||||
|
||||
@@ -3,6 +3,31 @@ package com.hermesandroid.relay.network.shared
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
|
||||
enum class VoiceSpeechStreamStatus {
|
||||
Completed,
|
||||
Fallback,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
data class VoiceSpeechStreamOutcome(
|
||||
val status: VoiceSpeechStreamStatus,
|
||||
val audioStarted: Boolean,
|
||||
val error: Throwable? = null,
|
||||
)
|
||||
|
||||
data class VoiceSpeechStreamCallbacks(
|
||||
val onStart: (sampleRate: Int, channels: Int) -> Unit = { _, _ -> },
|
||||
val onPcm: (pcm16Le: ByteArray, sampleRate: Int) -> Unit,
|
||||
)
|
||||
|
||||
interface VoiceSpeechStream {
|
||||
fun append(text: String)
|
||||
fun finish()
|
||||
fun stop()
|
||||
suspend fun awaitOutcome(): VoiceSpeechStreamOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral STT/TTS contract. The routing seam between the Standard
|
||||
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
|
||||
@@ -25,6 +50,16 @@ interface VoiceAudioClient {
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
/**
|
||||
* Open one provider-backed PCM stream for an assistant reply. A null
|
||||
* success means this route has no streaming surface and the caller should
|
||||
* keep using [synthesize]. Concrete implementations must queue [VoiceSpeechStream.append]
|
||||
* calls made before the socket opens and report whether any PCM was emitted
|
||||
* so callers never replay already-heard audio during compatibility fallback.
|
||||
*/
|
||||
suspend fun openSpeechStream(callbacks: VoiceSpeechStreamCallbacks): Result<VoiceSpeechStream?> =
|
||||
Result.success(null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +105,12 @@ class AutoVoiceAudioClient(
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
|
||||
client.openSpeechStream(callbacks)
|
||||
}
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Process-local ownership of background protection for work the user already
|
||||
* started. Keys are connection/profile/session scoped, so detached sibling
|
||||
* sessions retain independent leases and one completion cannot release
|
||||
* another session's protection.
|
||||
*/
|
||||
object ActiveTurnKeepAliveRegistry {
|
||||
data class Snapshot(
|
||||
val activeTurnCount: Int = 0,
|
||||
val waitingSessionCount: Int = 0,
|
||||
) {
|
||||
val required: Boolean get() = activeTurnCount > 0
|
||||
}
|
||||
|
||||
private val lock = Any()
|
||||
private val leases = linkedMapOf<String, Boolean>()
|
||||
private val _snapshot = MutableStateFlow(Snapshot())
|
||||
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()
|
||||
|
||||
fun acquire(key: String) {
|
||||
synchronized(lock) {
|
||||
leases[key] = leases[key] ?: false
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun setWaiting(key: String, waiting: Boolean) {
|
||||
synchronized(lock) {
|
||||
if (key in leases) {
|
||||
leases[key] = waiting
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(oldKey: String, newKey: String) {
|
||||
if (oldKey == newKey) return
|
||||
synchronized(lock) {
|
||||
val waiting = leases.remove(oldKey) ?: return
|
||||
leases[newKey] = waiting
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun release(key: String) {
|
||||
synchronized(lock) {
|
||||
if (leases.remove(key) != null) publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
synchronized(lock) {
|
||||
if (leases.isNotEmpty()) {
|
||||
leases.clear()
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resetForTest() {
|
||||
releaseAll()
|
||||
}
|
||||
|
||||
private fun publishLocked() {
|
||||
_snapshot.value = Snapshot(
|
||||
activeTurnCount = leases.size,
|
||||
waitingSessionCount = leases.count { it.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.MoaReference
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
@@ -43,6 +43,9 @@ class ChatHandler {
|
||||
|
||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||
internal const val MAX_MESSAGES = 500
|
||||
private const val MAX_MOA_REFERENCES = 32
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
|
||||
private fun timestampToMillis(timestamp: Double?): Long {
|
||||
val value = timestamp ?: return 0L
|
||||
@@ -155,6 +158,15 @@ class ChatHandler {
|
||||
*/
|
||||
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
|
||||
|
||||
/**
|
||||
* Fired for a canonical `@image:<path>` directive found on a persisted
|
||||
* USER history row. This is intentionally separate from free-form
|
||||
* assistant `MEDIA:` parsing: only the bounded upstream directive parser
|
||||
* can reach this callback.
|
||||
*/
|
||||
var onPersistedUserImageRequested: (messageId: String, originalPath: String) -> Unit =
|
||||
{ _, _ -> }
|
||||
|
||||
/**
|
||||
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
||||
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
||||
@@ -308,47 +320,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 { message ->
|
||||
message.id == messageId || message.uiKey == 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
|
||||
@@ -360,6 +379,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 }
|
||||
}
|
||||
}
|
||||
@@ -512,6 +532,42 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a provisional post-interim segment back into its sealed
|
||||
* assistant bubble when the terminal text proves they are one response.
|
||||
* Tool/card state accumulated after the interim remains attached.
|
||||
*/
|
||||
fun reconcileInterimMessage(
|
||||
interimMessageId: String,
|
||||
currentMessageId: String,
|
||||
content: String,
|
||||
) {
|
||||
_messages.update { messages ->
|
||||
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||
val merged = interim.copy(
|
||||
content = content,
|
||||
isStreaming = true,
|
||||
toolCalls = mergedTools,
|
||||
thinkingContent = current?.thinkingContent
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: interim.thinkingContent,
|
||||
isThinkingStreaming = current?.isThinkingStreaming
|
||||
?: interim.isThinkingStreaming,
|
||||
badges = (interim.badges + current?.badges.orEmpty()).distinct(),
|
||||
cards = (interim.cards + current?.cards.orEmpty()).distinct(),
|
||||
cardDispatches = (interim.cardDispatches + current?.cardDispatches.orEmpty())
|
||||
.distinctBy { "${it.cardKey}:${it.actionValue}:${it.timestamp}" },
|
||||
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||
)
|
||||
messages
|
||||
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||
.map { if (it.id == 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 } }
|
||||
@@ -894,6 +950,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 }
|
||||
}
|
||||
}
|
||||
@@ -909,6 +966,7 @@ class ChatHandler {
|
||||
fun restoreInFlightTurn(
|
||||
checkpoint: ChatTurnCheckpoint,
|
||||
upstreamAssistantText: String? = null,
|
||||
corrections: List<String> = emptyList(),
|
||||
) {
|
||||
val user = checkpoint.user
|
||||
val assistant = checkpoint.assistant
|
||||
@@ -973,6 +1031,27 @@ class ChatHandler {
|
||||
startedAt = task.startedAt,
|
||||
)
|
||||
}
|
||||
val checkpointMoaReferences = assistant.moaReferences
|
||||
.filter { it.index in 1..MAX_MOA_REFERENCES }
|
||||
.distinctBy { it.index }
|
||||
.sortedBy { it.index }
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
.map { reference ->
|
||||
MoaReference(
|
||||
index = reference.index,
|
||||
count = reference.count,
|
||||
label = reference.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (reference.available) {
|
||||
reference.text.take(MAX_MOA_REFERENCE_CHARS)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
available = reference.available,
|
||||
)
|
||||
}
|
||||
val restoredMoaReferences = currentAssistant?.moaReferences
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: checkpointMoaReferences
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -996,6 +1075,7 @@ class ChatHandler {
|
||||
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
||||
?: assistant.cardDispatches,
|
||||
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
||||
moaReferences = restoredMoaReferences,
|
||||
)
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
@@ -1015,13 +1095,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.id == 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
|
||||
@@ -1153,11 +1273,20 @@ class ChatHandler {
|
||||
// so we can attach results back to the originating assistant message's ToolCall
|
||||
val toolResults = items.filter { it.role == "tool" }
|
||||
.associateBy { it.toolCallId }
|
||||
// A reconnect/rejoin history response can repeat a persisted message row.
|
||||
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
|
||||
// so allowing both copies through would crash Compose before either copy
|
||||
// could be reconciled. A domain id identifies one persisted message: retain
|
||||
// its first transcript position while adopting the latest repeated snapshot.
|
||||
// Rows without ids remain independent, and tool/hidden rows keep their
|
||||
// separate handling above/below.
|
||||
val renderedItems = coalesceRenderedHistoryItems(items)
|
||||
|
||||
// Accumulator for media markers we find in loaded content — fired AFTER
|
||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||
// mutateMessage lookups find the newly-loaded messages.
|
||||
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
|
||||
|
||||
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
||||
// building the carry map, so the id-keyed delta-merge updates rows in
|
||||
@@ -1169,8 +1298,8 @@ class ChatHandler {
|
||||
// silently misses those rows, so a gateway turn's tokens/badges survived
|
||||
// only if a content match happened to cover them. See
|
||||
// [reconcileLiveIdsToServer].
|
||||
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
|
||||
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
|
||||
|
||||
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
||||
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
||||
@@ -1207,11 +1336,16 @@ class ChatHandler {
|
||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val role = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
val loaded = renderedItems.mapNotNull { item ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" ||
|
||||
displayKind == "auto_continue" -> MessageRole.SYSTEM
|
||||
item.role == "user" -> MessageRole.USER
|
||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||
item.role == "system" ->
|
||||
// Upstream injects role:system STEERING markers into the
|
||||
// session history on model/personality change — e.g.
|
||||
// "[System: The active model for this chat has changed to …]"
|
||||
@@ -1227,9 +1361,11 @@ class ChatHandler {
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
item.role == "tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val displayContent = displayEventContent(displayKind, item.displayMetadata)
|
||||
val rawServerContent = displayContent ?: item.contentText ?: ""
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.timestamp ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
@@ -1241,15 +1377,21 @@ class ChatHandler {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = item.contentText ?: ""
|
||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = rawServerContent
|
||||
|
||||
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
|
||||
PersistedImageReferenceParser.parse(rawContent)
|
||||
} else {
|
||||
PersistedImageReferences(rawContent, emptyList())
|
||||
}
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
|
||||
} else {
|
||||
rawContent
|
||||
persistedImages.cleanedText
|
||||
}
|
||||
|
||||
// Cards are synchronous (no async fetch) so we attach them
|
||||
@@ -1287,7 +1429,14 @@ class ChatHandler {
|
||||
// content-keyed queue. Inbound attachments are intentionally
|
||||
// excluded — they come back via the marker re-dispatch.
|
||||
val carriedAttachments = run {
|
||||
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null }
|
||||
val persistedImagePaths = persistedImages.paths.toHashSet()
|
||||
val byId = prior?.attachments.orEmpty().filter { attachment ->
|
||||
attachment.relayToken == null ||
|
||||
(
|
||||
role == MessageRole.USER &&
|
||||
attachment.relayToken in persistedImagePaths
|
||||
)
|
||||
}
|
||||
when {
|
||||
byId.isNotEmpty() -> byId
|
||||
role == MessageRole.USER ->
|
||||
@@ -1295,6 +1444,15 @@ class ChatHandler {
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
if (
|
||||
role == MessageRole.USER &&
|
||||
carriedAttachments.isEmpty() &&
|
||||
persistedImages.paths.isNotEmpty()
|
||||
) {
|
||||
persistedImages.paths.forEach { path ->
|
||||
pendingPersistedUserImages += messageId to path
|
||||
}
|
||||
}
|
||||
// Server reasoning is authoritative when present; absent, keep the
|
||||
// live-streamed thinking rather than blanking it on reload.
|
||||
val serverThinking =
|
||||
@@ -1339,6 +1497,10 @@ class ChatHandler {
|
||||
} else {
|
||||
prior.badges
|
||||
},
|
||||
// Keep sanitized advisor state while reconciling a still-live
|
||||
// row, but clear it once completion made history authoritative.
|
||||
// The server transcript never becomes the source of these blocks.
|
||||
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
|
||||
)
|
||||
} else {
|
||||
// INSERT — a server message with no local row yet. Built from
|
||||
@@ -1431,6 +1593,37 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((messageId, path) in pendingPersistedUserImages) {
|
||||
onPersistedUserImageRequested(messageId, path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse replayed visible history rows by their authoritative message id.
|
||||
*
|
||||
* Replacing the value at its first-seen slot preserves transcript ordering;
|
||||
* the last repeated value wins so a later, more complete snapshot is not lost.
|
||||
* Null ids cannot be proven identical and therefore remain separate rows.
|
||||
*/
|
||||
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
|
||||
val firstSlotById = HashMap<String, Int>()
|
||||
val coalesced = ArrayList<MessageItem>(items.size)
|
||||
for (item in items) {
|
||||
if (renderedRoleOf(item) == null) continue
|
||||
val id = item.id
|
||||
if (id == null) {
|
||||
coalesced += item
|
||||
continue
|
||||
}
|
||||
val existingSlot = firstSlotById[id]
|
||||
if (existingSlot == null) {
|
||||
firstSlotById[id] = coalesced.size
|
||||
coalesced += item
|
||||
} else {
|
||||
coalesced[existingSlot] = item
|
||||
}
|
||||
}
|
||||
return coalesced
|
||||
}
|
||||
|
||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||
@@ -1496,19 +1689,54 @@ class ChatHandler {
|
||||
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
|
||||
* actually render.
|
||||
*/
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||
when (item.displayKind?.trim()?.lowercase()) {
|
||||
"hidden" -> null
|
||||
"model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
|
||||
else -> when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayEventContent(displayKind: String?, metadata: JsonObject?): String? =
|
||||
when (displayKind) {
|
||||
"model_switch" -> {
|
||||
val model = metadata.stringField("model")
|
||||
?: metadata.stringField("to_model")
|
||||
?: metadata.stringField("target_model")
|
||||
if (model.isNullOrBlank()) "Model changed" else "Model changed to $model"
|
||||
}
|
||||
"async_delegation_complete" -> {
|
||||
val count = metadata.intField("task_count")
|
||||
?: metadata.intField("tasks")
|
||||
?: metadata.intField("count")
|
||||
when (count) {
|
||||
null -> "Background work completed"
|
||||
1 -> "1 background task completed"
|
||||
else -> "$count background tasks completed"
|
||||
}
|
||||
}
|
||||
"auto_continue" -> "Continued after an interrupted turn"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.intField(key: String): Int? =
|
||||
(this?.get(key) as? JsonPrimitive)?.let { primitive ->
|
||||
primitive.contentOrNull?.toIntOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
|
||||
@@ -1525,6 +1753,7 @@ class ChatHandler {
|
||||
val t = line.trim()
|
||||
if (t.isEmpty()) continue
|
||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(t)
|
||||
@@ -1682,7 +1911,10 @@ class ChatHandler {
|
||||
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
|
||||
// local title whenever the server hasn't supplied a non-blank one.
|
||||
val existingById = _sessions.value.associateBy { it.sessionId }
|
||||
val mapped = items.map { item ->
|
||||
// The drawer is always composed, even while closed, and keys rows by
|
||||
// session id. A refresh race or duplicated upstream row must not put
|
||||
// the same key into Compose's LazyColumn.
|
||||
val mapped = items.distinctBy { it.id }.map { item ->
|
||||
val startedAtMs = timestampToMillis(item.startedAt)
|
||||
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
||||
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
||||
@@ -1710,6 +1942,7 @@ class ChatHandler {
|
||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||
source = item.source,
|
||||
hasModelConfig = item.hasModelConfig,
|
||||
)
|
||||
}.sortedByDescending { it.activityTimestamp }
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
@@ -1760,7 +1993,15 @@ class ChatHandler {
|
||||
* Add a newly created session to the list.
|
||||
*/
|
||||
fun addSession(session: ChatSession) {
|
||||
_sessions.update { listOf(session) + it }
|
||||
// Gateway onSessionId and an overlapping REST refresh can both publish
|
||||
// the same freshly-created session. Treat this as an idempotent upsert,
|
||||
// preferring an existing server-enriched row while collapsing any
|
||||
// duplicates that were already present.
|
||||
_sessions.update { current ->
|
||||
val existing = current.firstOrNull { it.sessionId == session.sessionId }
|
||||
listOf(existing ?: session) +
|
||||
current.filterNot { it.sessionId == session.sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE streaming event entry points ---
|
||||
@@ -2542,6 +2783,44 @@ class ChatHandler {
|
||||
|
||||
// --- Gateway subagent lanes ---
|
||||
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
|
||||
val message = messages[targetIndex]
|
||||
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
|
||||
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
|
||||
val reference = MoaReference(
|
||||
index = nextIndex,
|
||||
count = event.count,
|
||||
label = event.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = event.available,
|
||||
)
|
||||
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
|
||||
val exactReplay = existingAtIndex == reference
|
||||
val base = if (nextIndex == 1 && !exactReplay) {
|
||||
emptyList()
|
||||
} else {
|
||||
message.moaReferences
|
||||
}
|
||||
if (exactReplay) {
|
||||
messages
|
||||
} else {
|
||||
val upserted = (base.filterNot { it.index == nextIndex } + reference)
|
||||
.sortedBy(MoaReference::index)
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
messages.toMutableList().also {
|
||||
it[targetIndex] = message.copy(moaReferences = upserted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane labels by task index, captured from `subagent.start` (goal
|
||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||
@@ -2867,13 +3146,22 @@ class ChatHandler {
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
messages
|
||||
.filterNot { msg ->
|
||||
msg.id == messageId &&
|
||||
msg.role == MessageRole.ASSISTANT &&
|
||||
msg.toolCalls.isEmpty() &&
|
||||
msg.backgroundTask == null &&
|
||||
msg.thinkingContent.isBlank() &&
|
||||
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
|
||||
}
|
||||
.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-stream reconciliation: re-scan final content for any annotation
|
||||
@@ -3073,3 +3361,15 @@ internal fun formatPhoneActionResult(
|
||||
append("Status ${result.status}.")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isIntentionalSilenceMarker(content: String): Boolean =
|
||||
when (content.trim().trim('"', '\'', '`').uppercase()) {
|
||||
"NO_REPLY",
|
||||
"[NO_REPLY]",
|
||||
"SILENT",
|
||||
"[SILENT]",
|
||||
"<SILENT>",
|
||||
"(SILENT)",
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
+436
-34
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -49,8 +50,40 @@ data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
@SerialName("gateway_mode") val gatewayMode: String? = null,
|
||||
val gateways: List<DashboardGatewayTopology> = emptyList(),
|
||||
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardGatewayTopology(
|
||||
val profile: String,
|
||||
val ports: Map<String, Int> = emptyMap(),
|
||||
@SerialName("served_profiles") val servedProfiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealthRollup(
|
||||
val supported: Boolean = false,
|
||||
val overall: String? = null,
|
||||
val components: List<DashboardComponentHealth> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealth(
|
||||
val name: String,
|
||||
val status: String,
|
||||
val message: String? = null,
|
||||
val configured: Int? = null,
|
||||
val connected: Int? = null,
|
||||
val healthy: Boolean? = null,
|
||||
val ok: Boolean? = null,
|
||||
@SerialName("unhandled_5xx_count_5m") val unhandled5xxCount5m: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -92,6 +125,54 @@ data class DashboardChatDisplaySettings(
|
||||
val toolDisplay: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardMcpOAuthFlow(
|
||||
val flowId: String,
|
||||
val serverName: String,
|
||||
val status: String,
|
||||
val authorizationUrl: String? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isTerminal: Boolean get() = status == "approved" || status == "error"
|
||||
}
|
||||
|
||||
data class DashboardCustomEndpoint(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val hasApiKey: Boolean = false,
|
||||
val apiKeyPreview: String? = null,
|
||||
val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpoints(
|
||||
val endpoints: List<DashboardCustomEndpoint>,
|
||||
val currentProvider: String? = null,
|
||||
val currentModel: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointDraft(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val apiKey: String? = null,
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val makeDefault: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointValidation(
|
||||
val ok: Boolean,
|
||||
val reachable: Boolean,
|
||||
val message: String,
|
||||
val models: List<String>,
|
||||
)
|
||||
|
||||
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
|
||||
data class ElevenLabsVoice(
|
||||
val voiceId: String,
|
||||
@@ -255,6 +336,14 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
|
||||
|
||||
/**
|
||||
* Runtime TTS provider matrix used by upstream's Tools/Capabilities picker.
|
||||
* This adds plugin provider names and readiness metadata. Command-provider
|
||||
* IDs remain sourced from the dynamic config-schema enum.
|
||||
*/
|
||||
suspend fun getTtsToolsetConfig(): Result<JsonObject> =
|
||||
getJsonObject("/api/tools/toolsets/tts/config")
|
||||
|
||||
/**
|
||||
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
|
||||
* writes the WHOLE document, so [config] MUST be the full values tree
|
||||
@@ -276,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) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,25 +557,100 @@ class DashboardApiClient(
|
||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
suspend fun setMcpServerEnabled(
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
|
||||
payload = buildJsonObject { put("enabled", enabled) },
|
||||
)
|
||||
|
||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
||||
suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
|
||||
|
||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
||||
suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun startMcpOAuth(
|
||||
name: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/auth${profileQuery(profile)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
suspend fun getMcpOAuthFlow(flowId: String): Result<DashboardMcpOAuthFlow> =
|
||||
getJsonObject("/api/mcp/oauth/flows/${pathSegment(flowId)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
/**
|
||||
* Read-only hosted-OAuth capability probe. New dashboards recognize the
|
||||
* flow-status route and return its canonical expired-flow 404; older
|
||||
* FastAPI routers return the generic route-level 404. No OAuth worker is
|
||||
* started and no provider/browser interaction occurs.
|
||||
*/
|
||||
suspend fun supportsHostedMcpOAuth(): Result<Boolean> {
|
||||
val result = getJsonObject("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__")
|
||||
return result.fold(
|
||||
onSuccess = { Result.success(true) },
|
||||
onFailure = { error ->
|
||||
val message = error.message.orEmpty()
|
||||
when {
|
||||
message.contains("OAuth flow not found or expired") -> Result.success(true)
|
||||
message.contains("HTTP 404") -> Result.success(false)
|
||||
else -> Result.failure(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
|
||||
getJsonObject("/api/providers/custom-endpoints")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun saveCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun validateCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpointValidation> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints/validate",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching { root ->
|
||||
DashboardCustomEndpointValidation(
|
||||
ok = root.booleanField("ok") == true,
|
||||
reachable = root.booleanField("reachable") == true,
|
||||
message = root.stringField("message").orEmpty(),
|
||||
models = root.stringList("models"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun activateCustomEndpoint(
|
||||
id: String,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
|
||||
|
||||
suspend fun deleteCustomEndpoint(
|
||||
id: String,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
name: String,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
enable: Boolean = true,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/mcp/catalog/install",
|
||||
path = "/api/mcp/catalog/install${profileQuery(profile)}",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put(
|
||||
@@ -554,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)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -894,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://"
|
||||
@@ -910,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("://")
|
||||
@@ -929,17 +1116,87 @@ class DashboardApiClient(
|
||||
return params.joinToString(prefix = "?", separator = "&")
|
||||
}
|
||||
|
||||
private fun parseMcpOAuthFlow(root: JsonObject): DashboardMcpOAuthFlow {
|
||||
val flowId = root.stringField("flow_id")
|
||||
?: throw IOException("MCP OAuth response did not include a flow id")
|
||||
val status = root.stringField("status")
|
||||
?: throw IOException("MCP OAuth response did not include a status")
|
||||
return DashboardMcpOAuthFlow(
|
||||
flowId = flowId,
|
||||
serverName = root.stringField("server_name").orEmpty(),
|
||||
status = status,
|
||||
authorizationUrl = root.stringField("authorization_url"),
|
||||
error = root.stringField("error"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCustomEndpoints(root: JsonObject): DashboardCustomEndpoints {
|
||||
val current = root["current"] as? JsonObject
|
||||
val endpoints = (root["endpoints"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj.stringField("id") ?: return@mapNotNull null
|
||||
DashboardCustomEndpoint(
|
||||
id = id,
|
||||
name = obj.stringField("name") ?: id,
|
||||
baseUrl = obj.stringField("base_url").orEmpty(),
|
||||
model = obj.stringField("model").orEmpty(),
|
||||
models = obj.stringList("models"),
|
||||
contextLength = obj.intField("context_length"),
|
||||
discoverModels = obj.booleanField("discover_models") != false,
|
||||
hasApiKey = obj.booleanField("has_api_key") == true,
|
||||
apiKeyPreview = obj.stringField("api_key_preview"),
|
||||
isCurrent = obj.booleanField("is_current") == true,
|
||||
)
|
||||
}
|
||||
return DashboardCustomEndpoints(
|
||||
endpoints = endpoints,
|
||||
currentProvider = current?.stringField("provider"),
|
||||
currentModel = current?.stringField("model"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun customEndpointPayload(draft: DashboardCustomEndpointDraft): JsonObject =
|
||||
buildJsonObject {
|
||||
draft.id?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||
put("name", draft.name)
|
||||
put("base_url", draft.baseUrl)
|
||||
put("model", draft.model)
|
||||
draft.models
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(MAX_CUSTOM_ENDPOINT_MODELS)
|
||||
.map(::JsonPrimitive)
|
||||
.toList()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("models", JsonArray(it)) }
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
bearerAuth: DashboardBearerAuth? = null,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
bearerAuth?.let {
|
||||
builder.addInterceptor(it)
|
||||
builder.authenticator(it)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
@@ -947,14 +1204,70 @@ class DashboardApiClient(
|
||||
?: root["providers"]
|
||||
?: authObject?.get("providers")
|
||||
val providers = parseProviders(providersElement)
|
||||
val profiles = (root["profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank)
|
||||
}
|
||||
val gateways = (root["gateways"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val profile = obj.stringField("profile") ?: return@mapNotNull null
|
||||
val ports = (obj["ports"] as? JsonObject).orEmpty().mapNotNull { (name, value) ->
|
||||
(value as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { name to it }
|
||||
}.toMap()
|
||||
val served = (obj["served_profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
DashboardGatewayTopology(profile = profile, ports = ports, servedProfiles = served)
|
||||
}
|
||||
return DashboardStatus(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
},
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
profiles = profiles,
|
||||
gatewayMode = root.stringField("gateway_mode"),
|
||||
gateways = gateways,
|
||||
componentHealth = parseComponentHealth(root),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseComponentHealth(root: JsonObject): DashboardComponentHealthRollup {
|
||||
val rawComponents = root["components"] as? JsonObject
|
||||
?: return DashboardComponentHealthRollup()
|
||||
val components = rawComponents.mapNotNull { (name, element) ->
|
||||
val component = element as? JsonObject ?: return@mapNotNull null
|
||||
val safeName = name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
DashboardComponentHealth(
|
||||
name = safeName,
|
||||
status = component.stringField("status")
|
||||
?: component.stringField("state")
|
||||
?: component.stringField("health")
|
||||
?: component.booleanField("ok")?.let { if (it) "ok" else "degraded" }
|
||||
?: component.booleanField("healthy")?.let { if (it) "ok" else "degraded" }
|
||||
?: "unknown",
|
||||
message = component.stringField("message")
|
||||
?: component.stringField("summary")
|
||||
?: component.stringField("error")
|
||||
?: component.stringField("reason"),
|
||||
configured = component.intField("configured"),
|
||||
connected = component.intField("connected"),
|
||||
healthy = component.booleanField("healthy"),
|
||||
ok = component.booleanField("ok"),
|
||||
unhandled5xxCount5m = component.intField("unhandled_5xx_count_5m"),
|
||||
)
|
||||
}.sortedBy { it.name }
|
||||
return DashboardComponentHealthRollup(
|
||||
supported = true,
|
||||
overall = root.stringField("overall")
|
||||
?: root.stringField("status")
|
||||
?: root.stringField("health"),
|
||||
components = components,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1072,6 +1385,35 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer credentials are scoped to the exact saved dashboard base, including
|
||||
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
|
||||
* not sufficient authority to receive the active connection's token.
|
||||
*/
|
||||
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
|
||||
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return candidateUrl.scheme == trustedUrl.scheme &&
|
||||
candidateUrl.host == trustedUrl.host &&
|
||||
candidateUrl.port == trustedUrl.port &&
|
||||
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
|
||||
candidateUrl.query == null &&
|
||||
trustedUrl.query == null
|
||||
}
|
||||
|
||||
fun trustedDashboardBearerAuthOrNull(
|
||||
candidate: String,
|
||||
trusted: String,
|
||||
tokenStoreProvider: () -> NativeDashboardTokenStore,
|
||||
): DashboardBearerAuth? =
|
||||
if (isNativeDashboardTransportEligible(candidate) &&
|
||||
sameDashboardBase(candidate, trusted)
|
||||
) {
|
||||
DashboardBearerAuth(candidate, tokenStoreProvider())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
@@ -1203,6 +1545,61 @@ class DashboardCookieJar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy only Hermes' authenticated dashboard session cookies to another host
|
||||
* that belongs to the same saved Connection. Dashboard cookies are host-only
|
||||
* by design, while a Connection may reach one server through LAN and
|
||||
* Tailscale hostnames/IPs. The encrypted store remains the source of truth and
|
||||
* explicit sign-out clears every mirrored host together.
|
||||
*
|
||||
* PKCE, SSO-attempt, and unrelated application cookies are intentionally not
|
||||
* copied. Secure cookies also remain Secure; this helper never downgrades them
|
||||
* for an HTTP route.
|
||||
*/
|
||||
fun mirrorDashboardSessionCookies(
|
||||
store: DashboardCookieStore,
|
||||
targetUrl: String,
|
||||
trustedHosts: Set<String>,
|
||||
clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
): Int {
|
||||
val targetHost = targetUrl.toHttpUrlOrNull()?.host?.lowercase() ?: return 0
|
||||
val allowedHosts = trustedHosts.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
if (targetHost !in allowedHosts) return 0
|
||||
|
||||
val now = clockMillis()
|
||||
val all = store.load()
|
||||
val live = all.filterNot { it.isExpired(now) }
|
||||
val existingTargetKeys = live.asSequence()
|
||||
.filter { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.map { "${it.name.lowercase()}|$targetHost|${it.path}" }
|
||||
.toSet()
|
||||
val mirrored = live.asSequence()
|
||||
.filter { it.isDashboardSessionCookie() }
|
||||
.filter { it.domain.lowercase() in allowedHosts }
|
||||
.filterNot { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.groupBy { "${it.name.lowercase()}|${it.path}" }
|
||||
.values
|
||||
.mapNotNull { candidates -> candidates.maxByOrNull { it.expiresAt } }
|
||||
.map { it.copy(domain = targetHost, hostOnly = true) }
|
||||
.filterNot { it.key in existingTargetKeys }
|
||||
.toList()
|
||||
|
||||
if (mirrored.isNotEmpty() || live.size != all.size) {
|
||||
store.save(live + mirrored)
|
||||
}
|
||||
return mirrored.size
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.isDashboardSessionCookie(): Boolean {
|
||||
val bareName = name
|
||||
.removePrefix("__Host-")
|
||||
.removePrefix("__Secure-")
|
||||
return bareName == "hermes_session" ||
|
||||
bareName == "hermes_session_at" ||
|
||||
bareName == "hermes_session_rt" ||
|
||||
bareName == "hermes_session_provider"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that resolves the backing per-connection store at request time.
|
||||
*
|
||||
@@ -1353,3 +1750,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
|
||||
|
||||
private fun JsonObject?.intField(name: String): Int? =
|
||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun JsonObject?.stringList(name: String): List<String> =
|
||||
(this?.get(name) as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
(element as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
+38
-1
@@ -84,7 +84,44 @@ fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
|
||||
* `category` field so it is robust to upstream's category-merging.
|
||||
*/
|
||||
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
|
||||
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") }
|
||||
fields.filter {
|
||||
it.key.startsWith("tts.") ||
|
||||
it.key.startsWith("stt.") ||
|
||||
it.key.startsWith("voice.")
|
||||
}
|
||||
|
||||
/** A TTS provider advertised by upstream's `hermes tools` provider registry. */
|
||||
data class TtsToolsetProvider(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String? = null,
|
||||
val isActive: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse `GET /api/tools/toolsets/tts/config` into provider choices.
|
||||
*
|
||||
* Unlike the config-schema enum, this payload adds readiness metadata and
|
||||
* reliably discovered plugin providers. Command providers remain schema-owned.
|
||||
* Older upstream builds may omit `tts_provider`;
|
||||
* those rows are ignored because their picker label is not a stable config ID.
|
||||
*/
|
||||
fun parseTtsToolsetProviders(root: JsonObject): List<TtsToolsetProvider> {
|
||||
val providers = root["providers"] as? JsonArray ?: return emptyList()
|
||||
return providers.mapNotNull { element ->
|
||||
val provider = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = provider.configString("tts_provider")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return@mapNotNull null
|
||||
TtsToolsetProvider(
|
||||
id = id,
|
||||
name = provider.configString("name")?.takeIf { it.isNotBlank() } ?: id,
|
||||
status = provider.configString("status"),
|
||||
isActive = (provider["is_active"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false,
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
|
||||
/** Read the value at a dot-path from the nested config values tree, or null. */
|
||||
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
|
||||
|
||||
+791
-50
File diff suppressed because it is too large
Load Diff
+242
-84
@@ -31,13 +31,25 @@ class GatewayEventMapper(
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
internal val currentInteraction: GatewayAsk?
|
||||
get() = pendingInteraction
|
||||
|
||||
internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
|
||||
pendingInteraction = ask
|
||||
if (!duplicate) callbacks.onInteractionRequest(ask)
|
||||
}
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var previousEventType: String? = null
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var previewedText: String? = null
|
||||
private var syntheticToolCounter = 0
|
||||
private var providerWaitStatusActive = false
|
||||
private var compactionStatusActive = false
|
||||
private var moaStatusActive = false
|
||||
private var pendingInteraction: GatewayAsk? = null
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
@@ -56,6 +68,30 @@ class GatewayEventMapper(
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
|
||||
interactionRequest(type, payload)?.let { ask ->
|
||||
restoreInteraction(ask)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
interactionExpiry(type, payload)?.let { expiry ->
|
||||
val pending = pendingInteraction
|
||||
if (pending != null && pending.matches(expiry)) {
|
||||
pendingInteraction = null
|
||||
}
|
||||
callbacks.onInteractionExpired(expiry)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
if (type in INTERACTION_RESUME_EVENTS) {
|
||||
pendingInteraction?.let { ask ->
|
||||
pendingInteraction = null
|
||||
callbacks.onInteractionResolved(
|
||||
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (type) {
|
||||
"reasoning.delta" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -95,13 +131,30 @@ class GatewayEventMapper(
|
||||
|
||||
"message.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
|
||||
clearActivityStatuses()
|
||||
sawTextDelta = true
|
||||
previewedText = null
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.interim" -> {
|
||||
val text = payload.string("text") ?: payload.string("message")
|
||||
?: payload.string("preview") ?: payload.string("rendered")
|
||||
val alreadyStreamed = payload.boolean("already_streamed") == true
|
||||
if (!text.isNullOrBlank() || alreadyStreamed) {
|
||||
clearActivityStatuses()
|
||||
if (!sawMessageStart) {
|
||||
sawMessageStart = true
|
||||
callbacks.onStart()
|
||||
}
|
||||
callbacks.onInterimMessage(text.orEmpty(), alreadyStreamed)
|
||||
previewedText = text
|
||||
sawTextDelta = alreadyStreamed
|
||||
}
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// The upstream background-completion poller currently emits
|
||||
// message.start immediately before _run_prompt_submit(), which
|
||||
@@ -114,6 +167,7 @@ class GatewayEventMapper(
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
previewedText = null
|
||||
callbacks.onStart()
|
||||
}
|
||||
|
||||
@@ -163,8 +217,21 @@ class GatewayEventMapper(
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
|
||||
val error = payload.string("error")
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
?: error?.takeIf { failed }?.let { "Error: $it" }
|
||||
val reconcilesInterim = !text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview ->
|
||||
preview.isNotEmpty() &&
|
||||
(text.startsWith(preview) || preview.startsWith(text))
|
||||
} == true
|
||||
if (reconcilesInterim) {
|
||||
callbacks.onInterimReconciled(text)
|
||||
} else if (!text.isNullOrEmpty() &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
@@ -172,6 +239,12 @@ class GatewayEventMapper(
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
if (failed) {
|
||||
callbacks.onStatusUpdate(
|
||||
ERROR_STATUS_KIND,
|
||||
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
|
||||
)
|
||||
}
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
@@ -209,36 +282,6 @@ class GatewayEventMapper(
|
||||
)
|
||||
}
|
||||
|
||||
"clarify.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"approval.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
// Current Hermes omits timeout metadata. Keep the legacy
|
||||
// no-countdown behavior unless a future contract exposes
|
||||
// the effective per-request timeout explicitly.
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
),
|
||||
)
|
||||
|
||||
"tool.output_risk" -> {
|
||||
val toolId = payload.string("tool_id")
|
||||
val risk = payload.string("risk")?.lowercase() ?: return
|
||||
@@ -259,52 +302,57 @@ class GatewayEventMapper(
|
||||
}
|
||||
}
|
||||
|
||||
// MoA activity proves auto-compaction has resumed even though
|
||||
// Android does not currently render these upstream events.
|
||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
||||
"moa.reference" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val text = payload.string("text")?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
val available = !isFailedMoaReference(text)
|
||||
callbacks.onMoaReference(
|
||||
GatewayMoaReference(
|
||||
index = payload.int("index")?.takeIf { it > 0 },
|
||||
count = payload.int("count")
|
||||
?.takeIf { it > 0 },
|
||||
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
|
||||
.ifBlank { "Advisor" },
|
||||
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = available,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"sudo.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
// Payload carries request_id ONLY — no command to show.
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
"moa.progress" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
|
||||
"secret.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
"moa.phase" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
when (payload.string("phase")?.lowercase()) {
|
||||
"aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
|
||||
"reference", "references" -> {
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"sudo.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
// Legacy phase marker retained by upstream for older consumers.
|
||||
"moa.aggregating" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
setMoaStatus("MoA: aggregating…")
|
||||
}
|
||||
|
||||
"secret.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for the proposed upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
),
|
||||
)
|
||||
"tool.progress" -> clearActivityStatuses()
|
||||
|
||||
"status.update" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -336,27 +384,120 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
private fun clearActivityStatuses() {
|
||||
clearProviderWaitAndCompaction()
|
||||
if (!moaStatusActive) return
|
||||
moaStatusActive = false
|
||||
callbacks.onStatusClear(MOA_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun clearProviderWaitAndCompaction() {
|
||||
clearProviderWaitStatus()
|
||||
if (!compactionStatusActive) return
|
||||
compactionStatusActive = false
|
||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in APPROVAL_CHOICES }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
private fun setMoaStatus(text: String) {
|
||||
moaStatusActive = true
|
||||
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
|
||||
const val ERROR_STATUS_KIND = "error"
|
||||
const val MOA_STATUS_KIND = "moa"
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||
"reasoning.delta",
|
||||
"thinking.delta",
|
||||
"reasoning.available",
|
||||
"message.delta",
|
||||
"message.interim",
|
||||
"message.start",
|
||||
"tool.generating",
|
||||
"tool.start",
|
||||
"tool.complete",
|
||||
"message.complete",
|
||||
"error",
|
||||
)
|
||||
|
||||
internal fun isFailedMoaReference(text: String): Boolean {
|
||||
val normalized = text.trimStart().lowercase()
|
||||
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
|
||||
}
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
"approval.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
)
|
||||
|
||||
"sudo.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
"secret.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
|
||||
"clarify.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"sudo.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"secret.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for a future upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
|
||||
|
||||
/**
|
||||
* Hermes 2026-07-15 emits these operational wait lines through the
|
||||
@@ -422,3 +563,20 @@ private fun JsonObject?.int(key: String): Int? =
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? =
|
||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in setOf("once", "session", "always", "deny") }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun GatewayAsk.sameRequestAs(other: GatewayAsk): Boolean =
|
||||
kind == other.kind && requestId == other.requestId
|
||||
|
||||
private fun GatewayAsk.matches(expiry: GatewayAskExpiry): Boolean =
|
||||
kind == expiry.kind &&
|
||||
(kind == GatewayAsk.Kind.APPROVAL || requestId == expiry.requestId)
|
||||
|
||||
+93
-16
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Opt-in foreground service that holds the app process up so the app's
|
||||
* connection to Hermes survives Android's background-freeze / Doze — i.e.
|
||||
* Foreground service that holds the app process up so work the user already
|
||||
* started survives Android's background-freeze / Doze. It runs automatically
|
||||
* while one or more turns are active, or continuously when the user enables
|
||||
* "persistent connection". Concretely it keeps the gateway chat WebSocket
|
||||
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
|
||||
@@ -39,13 +40,14 @@ import kotlinx.coroutines.launch
|
||||
* connection use case Google Play permits. The `specialUse` type is honest for
|
||||
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
||||
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
||||
* at submission. Off by default; only runs while the user enables the toggle.
|
||||
* at submission. Continuous idle retention is off by default; active work is
|
||||
* protected automatically and releases its lease on terminal settlement.
|
||||
*
|
||||
* # It does NOT own the socket
|
||||
*
|
||||
* The service's only job is to hold the process in the foreground. The socket
|
||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||
* idle-close timer while the toggle is on. On task removal (user swipes the app
|
||||
* idle-close timer while retention is required. On task removal (user swipes the app
|
||||
* away) the ViewModel + socket die with the process, so the service stops
|
||||
* itself rather than leave a notification that lies about being connected.
|
||||
*
|
||||
@@ -63,9 +65,31 @@ class GatewayKeepAliveService : Service() {
|
||||
private const val CHANNEL_NAME = "Persistent connection"
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
|
||||
private const val EXTRA_PERSISTENT = "persistent"
|
||||
private const val EXTRA_ACTIVE_TURNS = "active_turns"
|
||||
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
|
||||
@Volatile private var runningInstance: GatewayKeepAliveService? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
fun update(
|
||||
context: Context,
|
||||
persistent: Boolean,
|
||||
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
) {
|
||||
if (!persistent && !activeTurns.required) {
|
||||
stop(context)
|
||||
return
|
||||
}
|
||||
runningInstance?.let { service ->
|
||||
service.applyState(persistent, activeTurns)
|
||||
service.startForegroundNotification()
|
||||
return
|
||||
}
|
||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||
.setAction(ACTION_REFRESH)
|
||||
.putExtra(EXTRA_PERSISTENT, persistent)
|
||||
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
|
||||
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var persistent = false
|
||||
private var activeTurns = 0
|
||||
private var waitingSessions = 0
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runningInstance = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_REFRESH) {
|
||||
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
|
||||
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
|
||||
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
|
||||
.coerceIn(0, activeTurns)
|
||||
}
|
||||
startForegroundNotification()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
||||
// Flip the pref off so ConnectionViewModel's collector won't
|
||||
// restart us on the next foreground.
|
||||
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
persistent = false
|
||||
if (activeTurns == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
startForegroundNotification()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
|
||||
// The socket lives in the ViewModel, which dies when the task is
|
||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (runningInstance === this) runningInstance = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
persistent: Boolean,
|
||||
turns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
) {
|
||||
this.persistent = persistent
|
||||
activeTurns = turns.activeTurnCount
|
||||
waitingSessions = turns.waitingSessionCount.coerceIn(0, activeTurns)
|
||||
}
|
||||
|
||||
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||
// are all declared in the main manifest (both flavors), so the type is
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
@@ -148,17 +200,42 @@ class GatewayKeepAliveService : Service() {
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
val (title, body) = when {
|
||||
waitingSessions > 0 -> {
|
||||
val title = if (waitingSessions == 1) {
|
||||
"Hermes is waiting for input"
|
||||
} else {
|
||||
"$waitingSessions Hermes sessions need input"
|
||||
}
|
||||
title to if (activeTurns > waitingSessions) {
|
||||
"$waitingSessions waiting · ${activeTurns - waitingSessions} still working"
|
||||
} else {
|
||||
"Open the requested session to review and continue."
|
||||
}
|
||||
}
|
||||
activeTurns > 0 -> {
|
||||
val title = if (activeTurns == 1) {
|
||||
"Hermes is finishing a turn"
|
||||
} else {
|
||||
"Hermes is finishing $activeTurns turns"
|
||||
}
|
||||
title to "The connection stays active until this work completes."
|
||||
}
|
||||
else -> getString(R.string.gateway_keepalive_title) to
|
||||
getString(R.string.gateway_keepalive_body)
|
||||
}
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.gateway_keepalive_title))
|
||||
.setContentText(getString(R.string.gateway_keepalive_body))
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Turn off", stopPending)
|
||||
.build()
|
||||
if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
|
||||
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
|
||||
Ready,
|
||||
}
|
||||
|
||||
/** Profile-persisted approval policy introduced by upstream gateway contract v3. */
|
||||
enum class GatewayApprovalMode(val wireValue: String) {
|
||||
Manual("manual"),
|
||||
Smart("smart"),
|
||||
Off("off");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): GatewayApprovalMode? = when (value?.trim()?.lowercase()) {
|
||||
"manual" -> Manual
|
||||
"smart" -> Smart
|
||||
"off" -> Off
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this gateway exposes the contract-v3 profile approval-mode RPCs. */
|
||||
enum class GatewayApprovalModeCapability {
|
||||
Unknown,
|
||||
Supported,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
|
||||
*
|
||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
||||
* gateway while the dashboard probe is unresolved or ready. A capability-
|
||||
* preferred SSE fallback is selected only after a definitive unavailable,
|
||||
* unsupported, or sign-in-required verdict.
|
||||
*/
|
||||
fun resolveStreamingEndpointPreference(
|
||||
preference: String,
|
||||
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
|
||||
capabilities: ServerCapabilities,
|
||||
): String = when (preference) {
|
||||
"sessions", "completions", "runs", "gateway" -> preference
|
||||
else -> if (gateway == GatewayAvailability.Ready) {
|
||||
else -> if (
|
||||
gateway == GatewayAvailability.Ready ||
|
||||
gateway == GatewayAvailability.Unknown
|
||||
) {
|
||||
"gateway"
|
||||
} else {
|
||||
capabilities.preferredChatEndpoint()
|
||||
@@ -95,6 +122,30 @@ 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,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
|
||||
data class GatewayAutoContinue(
|
||||
val attempt: Int,
|
||||
val interruptedAt: Double?,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
val slug: String?,
|
||||
val name: String,
|
||||
val primaryPath: String?,
|
||||
)
|
||||
|
||||
/** Result of reattaching Android to an existing durable Gateway session. */
|
||||
@@ -104,9 +155,15 @@ data class GatewaySessionRecovery(
|
||||
val running: Boolean,
|
||||
val status: String?,
|
||||
val inflight: GatewayInflightTurn?,
|
||||
val queued: GatewayQueuedTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
)
|
||||
val autoContinue: GatewayAutoContinue? = null,
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null || autoContinue != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
data class GatewayBackgroundTurnCompletion(
|
||||
@@ -115,6 +172,25 @@ data class GatewayBackgroundTurnCompletion(
|
||||
val expectedAssistantText: String?,
|
||||
)
|
||||
|
||||
/** Input lifecycle from a deliberately detached Gateway turn. */
|
||||
sealed interface GatewayBackgroundInteractionEvent {
|
||||
val storedSessionId: String
|
||||
val profile: String?
|
||||
val ask: GatewayAsk
|
||||
|
||||
data class Requested(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
|
||||
data class Resolved(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
@@ -273,6 +349,14 @@ data class GatewayModelProvider(
|
||||
val totalModels: Int = 0,
|
||||
)
|
||||
|
||||
data class GatewayMoaReference(
|
||||
val index: Int?,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
@@ -280,6 +364,15 @@ data class GatewayModelOptions(
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||
internal fun isCurrentModelOptionsResponse(
|
||||
requestGeneration: Long,
|
||||
currentGeneration: Long,
|
||||
requestProfileKey: String,
|
||||
currentProfileKey: String,
|
||||
): Boolean =
|
||||
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
|
||||
|
||||
/**
|
||||
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
|
||||
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
||||
@@ -296,7 +389,9 @@ data class GatewayModelOptions(
|
||||
*
|
||||
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
|
||||
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
|
||||
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true.
|
||||
* (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
|
||||
* pins priority, `false` explicitly pins normal, and `null` omits the field so
|
||||
* the profile's service tier is inherited.
|
||||
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
|
||||
* accept it as a per-session override, so it is applied post-create instead.
|
||||
*/
|
||||
@@ -328,6 +423,19 @@ class GatewayTurnCallbacks(
|
||||
/** A gateway `message.start` opened an assistant response for this turn. */
|
||||
val onStart: () -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
/**
|
||||
* Gateway `message.interim` sealed an attempted assistant message before
|
||||
* the terminal `message.complete`. When [alreadyStreamed] is false, [text]
|
||||
* has not arrived through `message.delta` and should be rendered before
|
||||
* sealing the current assistant segment.
|
||||
*/
|
||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
||||
/**
|
||||
* The terminal text is equal/prefix-related to the sealed interim, so the
|
||||
* existing segment should be replaced in place instead of opening a second
|
||||
* assistant bubble.
|
||||
*/
|
||||
val onInterimReconciled: (text: String) -> Unit = { _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
@@ -352,6 +460,8 @@ class GatewayTurnCallbacks(
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/** Successful MoA advisor output for a transient labelled reference block. */
|
||||
val onMoaReference: (GatewayMoaReference) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
@@ -360,6 +470,8 @@ class GatewayTurnCallbacks(
|
||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||
/** Server declared a pending interaction expired; clear only the matching card. */
|
||||
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
|
||||
/** The turn resumed after a pending interaction was resolved elsewhere. */
|
||||
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
|
||||
/**
|
||||
* Gateway `status.update` lifecycle line — model fallback, retries, and
|
||||
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -28,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -76,6 +78,10 @@ data class ServerCapabilities(
|
||||
val portable: Boolean,
|
||||
/** `/health` — basic reachability. */
|
||||
val healthy: Boolean,
|
||||
/** Authenticated provider/model inventory at `/api/model/options`. */
|
||||
val modelOptions: Boolean = false,
|
||||
/** Backend-acknowledged per-session model lock. */
|
||||
val sessionModelLock: Boolean = false,
|
||||
) {
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
@@ -99,6 +105,8 @@ data class ServerCapabilities(
|
||||
runs = false,
|
||||
portable = false,
|
||||
healthy = false,
|
||||
modelOptions = false,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -138,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
modelOptions = feature("model_options") || endpoint("model_options"),
|
||||
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,6 +169,269 @@ internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ToolsetInfo(
|
||||
val name: String,
|
||||
val label: String = "",
|
||||
val description: String = "",
|
||||
val enabled: Boolean = false,
|
||||
val configured: Boolean = false,
|
||||
val tools: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolsetListResponse(val data: List<ToolsetInfo> = emptyList())
|
||||
|
||||
internal fun parseToolsetListBody(json: Json, body: String): List<ToolsetInfo>? = try {
|
||||
json.decodeFromString<ToolsetListResponse>(body).data
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** One OpenAI-compatible `/v1/models` row. [id] is always the request value. */
|
||||
data class ApiModelOption(
|
||||
val id: String,
|
||||
val root: String? = null,
|
||||
val parent: String? = null,
|
||||
) {
|
||||
/** Secondary picker copy for a configured route alias. */
|
||||
val routeDetail: String?
|
||||
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
|
||||
}
|
||||
|
||||
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
|
||||
data class ApiProviderModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
internal fun parseApiProviderModelOptionsBody(
|
||||
json: Json,
|
||||
body: String,
|
||||
): ApiProviderModelOptions? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
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,
|
||||
)
|
||||
}
|
||||
return ApiProviderModelOptions(
|
||||
providers = providers,
|
||||
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
enum class ApiModelRoutingErrorCode {
|
||||
INVENTORY_UNSUPPORTED,
|
||||
INVENTORY_UNAVAILABLE,
|
||||
PROVIDER_NOT_AUTHENTICATED,
|
||||
MODEL_NOT_AVAILABLE,
|
||||
MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
LOCK_CAPABILITY_INCOMPLETE,
|
||||
LOCK_REJECTED,
|
||||
LOCK_ACK_MISMATCH,
|
||||
LEGACY_PROVIDER_UNSUPPORTED,
|
||||
}
|
||||
|
||||
class ApiModelRoutingException(
|
||||
val code: ApiModelRoutingErrorCode,
|
||||
message: String,
|
||||
) : IOException(message)
|
||||
|
||||
sealed interface ApiModelSelectionAck {
|
||||
data object ServerDefault : ApiModelSelectionAck
|
||||
data class Locked(
|
||||
val sessionId: String,
|
||||
val model: String,
|
||||
val provider: String?,
|
||||
val effectiveModel: String = model,
|
||||
val effectiveProvider: String? = provider,
|
||||
) : ApiModelSelectionAck
|
||||
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
|
||||
}
|
||||
|
||||
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
|
||||
|
||||
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
|
||||
when {
|
||||
capabilities.sessionModelLock && capabilities.modelOptions ->
|
||||
ApiModelRoutingStrategy.LOCKED
|
||||
capabilities.sessionModelLock ->
|
||||
ApiModelRoutingStrategy.INCOMPLETE
|
||||
else ->
|
||||
ApiModelRoutingStrategy.LEGACY_HINT
|
||||
}
|
||||
|
||||
internal fun sessionTurnModelHint(
|
||||
acknowledgement: ApiModelSelectionAck,
|
||||
requestedModel: String?,
|
||||
): String? =
|
||||
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
|
||||
|
||||
internal data class ParsedApiModelLockAck(
|
||||
val sessionId: String?,
|
||||
val model: String?,
|
||||
val provider: String?,
|
||||
val state: String?,
|
||||
val effectiveModel: String?,
|
||||
val effectiveProvider: String?,
|
||||
)
|
||||
|
||||
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val runtime = root["runtime"] as? JsonObject ?: return null
|
||||
val requested = runtime["requested"] as? JsonObject
|
||||
val effective = runtime["effective"] as? JsonObject
|
||||
return ParsedApiModelLockAck(
|
||||
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun confirmedRuntimeMatches(
|
||||
runtime: JsonObject?,
|
||||
expected: ApiModelSelectionAck.Locked,
|
||||
): Boolean {
|
||||
runtime ?: return false
|
||||
val effective = runtime["effective"] as? JsonObject ?: return false
|
||||
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
|
||||
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
|
||||
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
return data.mapNotNull { row ->
|
||||
val obj = row as? JsonObject ?: return@mapNotNull null
|
||||
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
ApiModelOption(
|
||||
id = id,
|
||||
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
|
||||
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
|
||||
|
||||
/** Preserve the upstream drain code and bounded retry hint without leaking large bodies. */
|
||||
internal fun streamHttpFailureMessage(
|
||||
code: Int,
|
||||
reason: String,
|
||||
retryAfter: String?,
|
||||
body: String?,
|
||||
json: Json,
|
||||
): String {
|
||||
val error = body?.takeIf { it.length <= STREAM_ERROR_BODY_LIMIT }?.let { raw ->
|
||||
runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()?.get("error")
|
||||
}
|
||||
val errorObj = error as? JsonObject
|
||||
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.contentOrNull
|
||||
val detail = (errorObj?.get("message") as? JsonPrimitive)?.contentOrNull
|
||||
?: (error as? JsonPrimitive)?.contentOrNull
|
||||
return buildString {
|
||||
append("API error ").append(code).append(": ")
|
||||
if (!errorCode.isNullOrBlank()) append(errorCode).append(": ")
|
||||
append(detail?.takeIf { it.isNotBlank() } ?: reason)
|
||||
retryAfter?.trim()?.toIntOrNull()?.takeIf { it in 0..60 }?.let {
|
||||
append(" (Retry-After: ").append(it).append("s)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayDrainRetryDelayMillis(
|
||||
httpCode: Int?,
|
||||
retryAfter: String?,
|
||||
errorMessage: String,
|
||||
receivedEvent: Boolean,
|
||||
retryAlreadyScheduled: Boolean,
|
||||
): Long? {
|
||||
if (httpCode != 503 || receivedEvent || retryAlreadyScheduled ||
|
||||
!errorMessage.startsWith("API error 503: gateway_draining:")
|
||||
) return null
|
||||
val seconds = retryAfter?.trim()?.toIntOrNull()?.coerceIn(0, 5) ?: 1
|
||||
return seconds * 1_000L
|
||||
}
|
||||
|
||||
/** Owns the initial SSE, its one delayed drain retry, and the replacement SSE. */
|
||||
private class RetryingEventSource(
|
||||
private val originalRequest: Request,
|
||||
private val handler: Handler,
|
||||
) : EventSource {
|
||||
private val lock = Any()
|
||||
private var active: EventSource? = null
|
||||
private var retryRunnable: Runnable? = null
|
||||
private var cancelled = false
|
||||
|
||||
override fun request(): Request = originalRequest
|
||||
|
||||
fun attach(source: EventSource) {
|
||||
synchronized(lock) {
|
||||
if (cancelled) source.cancel() else active = source
|
||||
}
|
||||
}
|
||||
|
||||
fun retryAfter(delayMillis: Long, create: () -> EventSource) {
|
||||
val task = Runnable {
|
||||
synchronized(lock) {
|
||||
retryRunnable = null
|
||||
if (cancelled) return@Runnable
|
||||
// Keep creation under the same lock as cancel(): once Stop or
|
||||
// a session switch wins, no delayed POST can start afterward.
|
||||
active = create()
|
||||
}
|
||||
}
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
retryRunnable = task
|
||||
handler.postDelayed(task, delayMillis)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
val source: EventSource?
|
||||
val task: Runnable?
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
source = active
|
||||
active = null
|
||||
task = retryRunnable
|
||||
retryRunnable = null
|
||||
}
|
||||
task?.let(handler::removeCallbacks)
|
||||
source?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -174,6 +447,8 @@ class HermesApiClient(
|
||||
isLenient = true
|
||||
}
|
||||
) {
|
||||
@Volatile
|
||||
private var lastCapabilities: ServerCapabilities? = null
|
||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
@@ -199,8 +474,13 @@ class HermesApiClient(
|
||||
|
||||
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
||||
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
response != null && !response.isSuccessful -> streamHttpFailureMessage(
|
||||
code = response.code,
|
||||
reason = response.message,
|
||||
retryAfter = response.header("Retry-After"),
|
||||
body = runCatching { response.peekBody(STREAM_ERROR_BODY_LIMIT).string() }.getOrNull(),
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
)
|
||||
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
@@ -316,27 +596,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(
|
||||
@@ -445,6 +733,24 @@ class HermesApiClient(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** Authenticated read-only inventory from upstream `GET /v1/toolsets`. */
|
||||
suspend fun getToolsets(): Result<List<ToolsetInfo>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/toolsets").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(IOException("HTTP ${response.code}"))
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseToolsetListBody(json, body)
|
||||
?: return@withContext Result.failure(IOException("Malformed toolset inventory"))
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Available models ---
|
||||
|
||||
/**
|
||||
@@ -453,17 +759,13 @@ class HermesApiClient(
|
||||
* picker. Returns ids in server order; empty on any failure (the picker
|
||||
* then offers only "Server default").
|
||||
*/
|
||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
||||
suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
||||
data.mapNotNull {
|
||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
parseModelOptionsBody(json, body).orEmpty()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||
@@ -471,6 +773,208 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
|
||||
suspend fun getProviderModelOptions(
|
||||
refresh: Boolean = false,
|
||||
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val suffix = if (refresh) "?refresh=true" else ""
|
||||
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
if (response.code == 404) {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
|
||||
} else {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
|
||||
},
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
"Model inventory authorization failed (HTTP ${response.code})."
|
||||
} else {
|
||||
"Model inventory unavailable (HTTP ${response.code})."
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
|
||||
?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory returned an invalid response.",
|
||||
),
|
||||
)
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and, on capable servers, persist a model/provider lock before a
|
||||
* session turn is submitted. This never writes global config.
|
||||
*/
|
||||
suspend fun acknowledgeSessionModelSelection(
|
||||
sessionId: String,
|
||||
model: String?,
|
||||
provider: String?,
|
||||
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
|
||||
val selectedModel = AgentDisplay.requestModelName(model)
|
||||
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
|
||||
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
|
||||
// Capability snapshots can be populated by a disconnected startup
|
||||
// probe. Re-probe at the lock boundary instead of trusting a stale
|
||||
// false forever after the connection recovers.
|
||||
val capabilities = probeCapabilities()
|
||||
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
|
||||
val inventory = getProviderModelOptions().getOrElse {
|
||||
return@withContext Result.failure(it)
|
||||
}
|
||||
val aliases = getModelOptions()
|
||||
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val providerModel = selectedRoot ?: selectedModel
|
||||
val providerRow = when {
|
||||
selectedProvider != null ->
|
||||
inventory.providers.firstOrNull { it.slug == selectedProvider }
|
||||
else -> inventory.providers.singleOrNull { providerModel in it.models }
|
||||
?: inventory.providers.firstOrNull {
|
||||
it.isCurrent && providerModel in it.models
|
||||
}
|
||||
} ?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not in the API server's authenticated inventory.",
|
||||
),
|
||||
)
|
||||
if (!providerRow.authenticated) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
|
||||
"The selected provider is not authenticated on this profile.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel !in providerRow.models) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not available from ${providerRow.name}.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel in providerRow.unavailableModels) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
"The selected model is not available on the authenticated account.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val body = kotlinx.serialization.json.buildJsonObject {
|
||||
put("model", selectedModel)
|
||||
put("provider", providerRow.slug)
|
||||
}
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
|
||||
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body.string()
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
streamHttpFailureMessage(
|
||||
response.code,
|
||||
response.message,
|
||||
response.header("Retry-After"),
|
||||
responseBody,
|
||||
json,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val ack = parseApiModelLockAck(json, responseBody)
|
||||
if (
|
||||
ack?.sessionId != sessionId ||
|
||||
ack?.model != selectedModel ||
|
||||
ack?.provider != providerRow.slug ||
|
||||
ack?.state != "accepted" ||
|
||||
ack?.effectiveModel.isNullOrBlank() ||
|
||||
ack?.effectiveProvider.isNullOrBlank()
|
||||
) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
|
||||
"Server did not acknowledge the requested model lock.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val confirmedAck = requireNotNull(ack)
|
||||
Result.success(
|
||||
ApiModelSelectionAck.Locked(
|
||||
sessionId = sessionId,
|
||||
model = selectedModel,
|
||||
provider = providerRow.slug,
|
||||
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
|
||||
effectiveProvider = confirmedAck.effectiveProvider,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
"Model lock request failed before the message was sent.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
|
||||
"Server advertises an incomplete model-routing contract.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (selectedProvider != null) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
|
||||
"This Hermes version cannot safely preserve a provider selection on API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val advertised = getModelOptions().map { it.id }
|
||||
if (selectedModel !in advertised) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"This Hermes version did not advertise the selected model for API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -504,9 +1008,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
|
||||
@@ -593,6 +1095,7 @@ class HermesApiClient(
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
expectedModelLock: ApiModelSelectionAck.Locked? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
@@ -623,6 +1126,10 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("sessions")
|
||||
|
||||
@@ -636,10 +1143,17 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -715,9 +1229,17 @@ class HermesApiClient(
|
||||
}
|
||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||
"assistant.completed" -> {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
}
|
||||
} else if (event.interrupted == true) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Response interrupted")
|
||||
}
|
||||
@@ -729,9 +1251,15 @@ class HermesApiClient(
|
||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||
"run.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
} else if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
@@ -741,7 +1269,13 @@ class HermesApiClient(
|
||||
}
|
||||
"done" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
@@ -799,9 +1333,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -809,12 +1354,19 @@ class HermesApiClient(
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server closed the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
@@ -876,6 +1428,9 @@ class HermesApiClient(
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("completions")
|
||||
|
||||
@@ -886,6 +1441,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -941,9 +1497,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -956,7 +1523,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
@@ -1070,6 +1638,9 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("runs")
|
||||
|
||||
@@ -1080,6 +1651,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -1246,9 +1818,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -1261,7 +1844,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- Capability detection ---
|
||||
@@ -1319,7 +1903,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
if (!healthy) {
|
||||
lastCapabilities = ServerCapabilities.DISCONNECTED
|
||||
return@withContext ServerCapabilities.DISCONNECTED
|
||||
}
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
@@ -1333,7 +1920,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
if (advertisedCapabilities != null) {
|
||||
lastCapabilities = advertisedCapabilities
|
||||
return@withContext advertisedCapabilities
|
||||
}
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
@@ -1378,7 +1968,7 @@ class HermesApiClient(
|
||||
runs = runs,
|
||||
portable = portable,
|
||||
healthy = true,
|
||||
)
|
||||
).also { lastCapabilities = it }
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
|
||||
*
|
||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||
* consumes `message` (or `input`) and `system_message` (or
|
||||
* `instructions`, string only). `message` accepts either a plain string
|
||||
* or OpenAI-style content parts (text + `image_url`) via
|
||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
||||
* `model`, and `profile` are NOT parsed.
|
||||
* `instructions`, string only). Newer servers also parse per-request model
|
||||
* fields and reuse a backend-acknowledged session model lock when those
|
||||
* fields are omitted. Android therefore acknowledges a lock first and
|
||||
* omits `model` on that turn; the builder's model field remains only for
|
||||
* older-server compatibility. Top-level `messages`, `attachments`, and
|
||||
* `profile` are not parsed.
|
||||
*
|
||||
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||
* array), `instructions`, `conversation_history` (array of
|
||||
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
|
||||
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||
* Top-level `attachments` and `profile` are NOT parsed.
|
||||
*
|
||||
* Legacy hint fields we deliberately keep sending although current native
|
||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
||||
* `profile` on runs/completions, and `stream` on runs. They are
|
||||
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
|
||||
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
|
||||
* configuration hints (never user content, so they cannot mask data
|
||||
* loss) honored by legacy fork builds — the runs path in particular only
|
||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Ephemeral hosted MCP OAuth driver. The opaque flow id and authorization URL
|
||||
* remain in memory only; OAuth codes, callback state, and tokens never enter
|
||||
* the Android client. The dashboard owns the complete PKCE/callback exchange.
|
||||
*/
|
||||
class McpOAuthFlowCoordinator(
|
||||
private val client: DashboardApiClient,
|
||||
private val pollDelayMillis: Long = 1_000,
|
||||
private val maxPolls: Int = 900,
|
||||
private val maxConsecutiveFailures: Int = 3,
|
||||
private val sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
) {
|
||||
suspend fun start(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> = client.startMcpOAuth(serverName, profile).mapCatching { started ->
|
||||
if (started.status == "error") {
|
||||
throw IOException(started.error ?: "MCP OAuth failed to start")
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
suspend fun resume(flowId: String): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
var failures = 0
|
||||
repeat(maxPolls.coerceAtLeast(1)) {
|
||||
val current = client.getMcpOAuthFlow(flowId)
|
||||
if (current.isFailure) {
|
||||
failures += 1
|
||||
if (failures >= maxConsecutiveFailures.coerceAtLeast(1)) {
|
||||
throw current.exceptionOrNull() ?: IOException("MCP OAuth status check failed")
|
||||
}
|
||||
} else {
|
||||
failures = 0
|
||||
val flow = current.getOrThrow()
|
||||
when (flow.status) {
|
||||
"approved" -> return@runCatching flow
|
||||
"error" -> throw IOException(flow.error ?: "MCP OAuth authorization failed")
|
||||
}
|
||||
}
|
||||
sleep(pollDelayMillis.coerceAtLeast(0))
|
||||
}
|
||||
throw IOException("MCP OAuth authorization timed out")
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
suspend fun complete(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
openAuthorization: (String) -> Boolean,
|
||||
): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
val started = start(serverName, profile).getOrThrow()
|
||||
if (started.status == "approved") return@runCatching started
|
||||
val authorizationUrl = validatedAuthorizationUrl(started).getOrThrow()
|
||||
if (!openAuthorization(authorizationUrl)) {
|
||||
throw IOException("No browser is available to complete MCP OAuth")
|
||||
}
|
||||
resume(started.flowId).getOrThrow()
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun validatedAuthorizationUrl(flow: DashboardMcpOAuthFlow): Result<String> = runCatching {
|
||||
val url = flow.authorizationUrl
|
||||
?: throw IOException("MCP OAuth server did not provide an authorization URL")
|
||||
if (url.toHttpUrlOrNull()?.scheme != "https") {
|
||||
throw IOException("MCP OAuth authorization URL must use HTTPS")
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
private const val NATIVE_PKCE_FLOW = "native_pkce"
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val TOKEN_KEY = "dashboard_native_tokens_json"
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
@Serializable
|
||||
data class NativeDashboardTokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String = "",
|
||||
@SerialName("expires_at") val expiresAt: Long = 0L,
|
||||
val provider: String = "",
|
||||
@SerialName("user_id") val userId: String = "",
|
||||
)
|
||||
|
||||
interface NativeDashboardTokenStore {
|
||||
/** Stable, non-secret identity used to serialize refresh-token rotation. */
|
||||
val coordinationKey: String
|
||||
fun load(): NativeDashboardTokens?
|
||||
fun save(tokens: NativeDashboardTokens)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
|
||||
NativeTokenRefreshCoordinator.clear(store)
|
||||
}
|
||||
|
||||
class EncryptedNativeDashboardTokenStore(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = tokenStoreKey
|
||||
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
|
||||
buildRawTokenStore(context.applicationContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
override fun load(): NativeDashboardTokens? =
|
||||
store.getString(TOKEN_KEY)?.let { raw ->
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
store.putString(TOKEN_KEY, json.encodeToString(tokens))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
|
||||
* its verifier and CSRF state must never be persisted, logged, or copied into
|
||||
* Compose/SavedState UI state.
|
||||
*/
|
||||
class NativeDashboardAuthorization internal constructor(
|
||||
val authorizationUrl: String,
|
||||
internal val verifier: String,
|
||||
internal val state: String,
|
||||
internal val generation: Long,
|
||||
)
|
||||
|
||||
class NativeDashboardAuthClient(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
private val random: SecureRandom = SecureRandom(),
|
||||
) {
|
||||
private val baseUrl = baseUrl.trim().trimEnd('/')
|
||||
|
||||
fun supportsNativePkce(status: DashboardStatus): Boolean =
|
||||
NATIVE_PKCE_FLOW in status.authFlows
|
||||
|
||||
fun beginAuthorization(
|
||||
redirectUri: String,
|
||||
provider: String? = null,
|
||||
): NativeDashboardAuthorization {
|
||||
requireStrictLoopbackRedirect(redirectUri)
|
||||
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
|
||||
// trailing "=", which makes Hermes' standards-compliant S256
|
||||
// comparison fail even though both sides hashed the same bytes.
|
||||
val verifier = randomBytes(32).base64Url().trimEnd('=')
|
||||
val challenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
.trimEnd('=')
|
||||
val state = randomBytes(24).base64Url()
|
||||
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
|
||||
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val url = root.newBuilder()
|
||||
.addQueryParameter("code_challenge", challenge)
|
||||
.addQueryParameter("code_challenge_method", "S256")
|
||||
.addQueryParameter("redirect_uri", redirectUri)
|
||||
.addQueryParameter("state", state)
|
||||
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
|
||||
.build()
|
||||
.toString()
|
||||
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
return NativeDashboardAuthorization(url, verifier, state, generation)
|
||||
}
|
||||
|
||||
/**
|
||||
* A private-route dashboard may be configured with a canonical HTTPS
|
||||
* callback origin for its provider. Starting the browser on the private
|
||||
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
|
||||
* discover the provider's declared callback and start native auth there.
|
||||
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
|
||||
* to the active connection route.
|
||||
*/
|
||||
private fun resolveAuthorizationBaseUrl(provider: String?): String {
|
||||
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
|
||||
if (
|
||||
!provider.equals("nous", ignoreCase = true) ||
|
||||
configured.scheme != "http" ||
|
||||
!isPrivateNetworkLiteral(configured.host)
|
||||
) {
|
||||
return baseUrl
|
||||
}
|
||||
val loginUrl = configured.newBuilder()
|
||||
.addPathSegments("auth/login")
|
||||
.addQueryParameter("provider", provider)
|
||||
.addQueryParameter("next", "/")
|
||||
.build()
|
||||
val discoveryClient = client.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
val location = discoveryClient.newCall(
|
||||
Request.Builder().url(loginUrl).get().build(),
|
||||
).execute().use { response ->
|
||||
if (response.code !in 300..399) null else response.header("Location")
|
||||
}
|
||||
return canonicalDashboardBaseFromNousRedirect(location)
|
||||
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
|
||||
}
|
||||
|
||||
fun exchangeCallback(
|
||||
authorization: NativeDashboardAuthorization,
|
||||
callbackTarget: String,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val callback = callbackTarget.toHttpUrlOrNull()
|
||||
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
|
||||
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
|
||||
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
|
||||
throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not use the expected loopback path",
|
||||
)
|
||||
}
|
||||
if (callback.queryParameter("state") != authorization.state) {
|
||||
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
|
||||
}
|
||||
callback.queryParameter("error")?.let {
|
||||
throw NativeDashboardCallbackException(
|
||||
message = "Gateway rejected native sign-in",
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
val code = callback.queryParameter("code")
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not include an authorization code",
|
||||
)
|
||||
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
|
||||
return postTokens(
|
||||
path = "/auth/native/token",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = authorization.generation,
|
||||
commitAllowed = commitAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
|
||||
NativeTokenRefreshCoordinator.cancelAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
authorization.generation,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearStoredSession() {
|
||||
clearNativeDashboardTokens(tokenStore)
|
||||
}
|
||||
|
||||
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
|
||||
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load()
|
||||
?: tokens
|
||||
?: throw IOException("No native dashboard session is stored")
|
||||
// A sibling client may already have rotated the single-use refresh
|
||||
// token while this caller was waiting. Adopt that winner instead
|
||||
// of replaying the stale token.
|
||||
if (tokens != null && current != tokens) return@synchronized current
|
||||
if (current.refreshToken.isBlank()) {
|
||||
clearIfUnchanged(current)
|
||||
throw IOException("Native dashboard session cannot be refreshed")
|
||||
}
|
||||
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
|
||||
val generation = NativeTokenRefreshCoordinator.currentGeneration(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
try {
|
||||
postTokens(
|
||||
path = "/auth/native/refresh",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = generation,
|
||||
)
|
||||
} catch (error: NativeDashboardAuthHttpException) {
|
||||
if (error.statusCode == 400 || error.statusCode == 401) {
|
||||
clearIfUnchanged(current)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postTokens(
|
||||
path: String,
|
||||
payload: String,
|
||||
clearOnAuthFailure: Boolean,
|
||||
expectedGeneration: Long,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val url = "$baseUrl$path".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(payload.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val tokens = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
|
||||
tokenStore.clear()
|
||||
}
|
||||
throw NativeDashboardAuthHttpException(response.code)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
|
||||
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
|
||||
.also {
|
||||
if (it.accessToken.isBlank()) {
|
||||
throw IOException("Dashboard token response did not include an access token")
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
if (!commitAllowed() ||
|
||||
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
|
||||
expectedGeneration
|
||||
) {
|
||||
throw IOException("Dashboard sign-in is no longer active")
|
||||
}
|
||||
tokenStore.save(tokens)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
|
||||
|
||||
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
|
||||
if (tokenStore.load() == expected) tokenStore.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requireStrictLoopbackRedirect(redirectUri: String) {
|
||||
val url = redirectUri.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
|
||||
require(url.scheme == "http" && url.host == "127.0.0.1") {
|
||||
"Native redirect must use the 127.0.0.1 loopback address"
|
||||
}
|
||||
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
|
||||
"Native redirect must use an ephemeral port and the exact /callback path"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class NativeDashboardCallbackException(
|
||||
message: String,
|
||||
val retryable: Boolean = true,
|
||||
) : IOException(message)
|
||||
|
||||
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" ||
|
||||
(
|
||||
url.scheme == "http" &&
|
||||
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes already permits explicitly configured HTTP dashboard sessions on
|
||||
* local routes. The brokered flow is no less protected than that cookie flow,
|
||||
* but remains unavailable to arbitrary cleartext Internet hosts.
|
||||
*/
|
||||
private fun isPrivateNetworkLiteral(host: String): Boolean {
|
||||
val octets = host.split('.').mapNotNull(String::toIntOrNull)
|
||||
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
|
||||
val first = octets[0]
|
||||
val second = octets[1]
|
||||
return first == 10 ||
|
||||
(first == 172 && second in 16..31) ||
|
||||
(first == 192 && second == 168) ||
|
||||
(first == 100 && second in 64..127)
|
||||
}
|
||||
|
||||
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
|
||||
val providerUrl = location?.toHttpUrlOrNull() ?: return null
|
||||
if (
|
||||
providerUrl.scheme != "https" ||
|
||||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val callback = providerUrl.queryParameter("redirect_uri")
|
||||
?.toHttpUrlOrNull()
|
||||
?: return null
|
||||
if (callback.scheme != "https") return null
|
||||
val callbackSuffix = "/auth/callback"
|
||||
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
|
||||
val basePath = callback.encodedPath
|
||||
.removeSuffix(callbackSuffix)
|
||||
.ifBlank { "/" }
|
||||
return callback.newBuilder()
|
||||
.encodedPath(basePath)
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the native bearer to dashboard REST calls and rotates it before expiry
|
||||
* or after one 401. Refresh requests use a separate bare client, so neither a
|
||||
* stale bearer nor the authenticator can recurse into token rotation.
|
||||
*/
|
||||
class DashboardBearerAuth(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
|
||||
) : Interceptor, Authenticator {
|
||||
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
|
||||
val request = tokens?.let {
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer ${it.accessToken}")
|
||||
.build()
|
||||
} ?: chain.request()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
val previous = response.request.header("Authorization") ?: return null
|
||||
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
|
||||
val tokens = usableTokens(
|
||||
forceRefresh = true,
|
||||
failedAccessToken = failedAccessToken,
|
||||
) ?: return null
|
||||
val next = "Bearer ${tokens.accessToken}"
|
||||
if (next == previous) return null
|
||||
return response.request.newBuilder().header("Authorization", next).build()
|
||||
}
|
||||
|
||||
private fun usableTokens(
|
||||
forceRefresh: Boolean,
|
||||
failedAccessToken: String?,
|
||||
): NativeDashboardTokens? =
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load() ?: return@synchronized null
|
||||
// A request can receive its 401 after another client already
|
||||
// rotated the token. Retry with the winner; do not rotate again.
|
||||
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
|
||||
return@synchronized current
|
||||
}
|
||||
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
|
||||
if (!forceRefresh && !nearExpiry) return@synchronized current
|
||||
runCatching { authClient.refresh(current) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count += 1
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private class NativeDashboardAuthHttpException(
|
||||
val statusCode: Int,
|
||||
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
|
||||
|
||||
private object NativeTokenRefreshCoordinator {
|
||||
private val locks = ConcurrentHashMap<String, Any>()
|
||||
private val generations = ConcurrentHashMap<String, Long>()
|
||||
|
||||
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
|
||||
|
||||
fun currentGeneration(key: String): Long =
|
||||
synchronized(lockFor(key)) { generations[key] ?: 0L }
|
||||
|
||||
fun beginAuthorization(key: String): Long =
|
||||
synchronized(lockFor(key)) {
|
||||
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
|
||||
}
|
||||
|
||||
fun cancelAuthorization(key: String, expectedGeneration: Long) {
|
||||
synchronized(lockFor(key)) {
|
||||
if ((generations[key] ?: 0L) == expectedGeneration) {
|
||||
generations[key] = expectedGeneration + 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(store: NativeDashboardTokenStore) {
|
||||
synchronized(lockFor(store.coordinationKey)) {
|
||||
generations[store.coordinationKey] =
|
||||
(generations[store.coordinationKey] ?: 0L) + 1L
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenExchange(
|
||||
val code: String,
|
||||
@SerialName("code_verifier") val codeVerifier: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenRefresh(
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
val provider: String,
|
||||
)
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
|
||||
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||
private const val ACCEPT_POLL_MILLIS = 500
|
||||
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
|
||||
|
||||
internal enum class DashboardRedirectAuthMode {
|
||||
NativePkce,
|
||||
WebView,
|
||||
}
|
||||
|
||||
internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedirectAuthMode =
|
||||
if ("native_pkce" in authFlows) {
|
||||
DashboardRedirectAuthMode.NativePkce
|
||||
} else {
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
|
||||
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
|
||||
* gateway's brokered system-browser flow for Nous when it is advertised.
|
||||
*/
|
||||
internal fun androidDashboardRedirectAuthMode(
|
||||
providerName: String,
|
||||
authFlows: List<String>,
|
||||
): DashboardRedirectAuthMode =
|
||||
if (
|
||||
providerName.equals("nous", ignoreCase = true) &&
|
||||
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
|
||||
) {
|
||||
DashboardRedirectAuthMode.NativePkce
|
||||
} else {
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one native dashboard sign-in attempt.
|
||||
*
|
||||
* The listener and PKCE authorization are both local to [signIn], so leaving
|
||||
* the screen, cancellation, timeout, or callback completion closes the port
|
||||
* and discards verifier/state. Nothing secret enters Compose or saved state.
|
||||
*/
|
||||
class NativeDashboardSignInCoordinator(
|
||||
private val authClient: NativeDashboardAuthClient,
|
||||
private val timeoutMillis: Long = DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS,
|
||||
private val serverSocketFactory: () -> ServerSocket = ::ServerSocket,
|
||||
) {
|
||||
suspend fun signIn(
|
||||
provider: String?,
|
||||
launchAuthorization: suspend (String) -> Unit,
|
||||
): NativeDashboardTokens =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeout(timeoutMillis) {
|
||||
serverSocketFactory().use { server ->
|
||||
server.reuseAddress = false
|
||||
server.bind(
|
||||
InetSocketAddress(
|
||||
InetAddress.getByName("127.0.0.1"),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
)
|
||||
server.soTimeout = ACCEPT_POLL_MILLIS
|
||||
check(server.inetAddress.hostAddress == "127.0.0.1") {
|
||||
"Native sign-in listener did not bind to IPv4 loopback"
|
||||
}
|
||||
|
||||
val redirectUri = "http://127.0.0.1:${server.localPort}$CALLBACK_PATH"
|
||||
val authorization = authClient.beginAuthorization(redirectUri, provider)
|
||||
val attemptContext = currentCoroutineContext()
|
||||
var completed = false
|
||||
try {
|
||||
launchAuthorization(authorization.authorizationUrl)
|
||||
awaitValidCallback(
|
||||
server = server,
|
||||
authorization = authorization,
|
||||
commitAllowed = { attemptContext.isActive },
|
||||
).also { completed = true }
|
||||
} finally {
|
||||
if (!completed) {
|
||||
authClient.cancelAuthorization(authorization)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw IOException("Dashboard sign-in timed out")
|
||||
}
|
||||
|
||||
private suspend fun awaitValidCallback(
|
||||
server: ServerSocket,
|
||||
authorization: NativeDashboardAuthorization,
|
||||
commitAllowed: () -> Boolean,
|
||||
): NativeDashboardTokens {
|
||||
while (true) {
|
||||
val callback = acceptCallback(server)
|
||||
val tokens = callback.use { socket ->
|
||||
if (socket.inetAddress.hostAddress != "127.0.0.1") {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "403 Forbidden",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
val target = try {
|
||||
readCallbackTarget(
|
||||
input = socket.getInputStream(),
|
||||
expectedPort = server.localPort,
|
||||
)
|
||||
} catch (_: IOException) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
try {
|
||||
authClient.exchangeCallback(
|
||||
authorization,
|
||||
target,
|
||||
commitAllowed = commitAllowed,
|
||||
).also {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "200 OK",
|
||||
body = "Sign-in complete. You can return to Hermes Relay.",
|
||||
)
|
||||
}
|
||||
} catch (error: NativeDashboardCallbackException) {
|
||||
if (error.retryable) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (tokens != null) return tokens
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun acceptCallback(server: ServerSocket): Socket {
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
try {
|
||||
return server.accept().apply { soTimeout = 5_000 }
|
||||
} catch (_: SocketTimeoutException) {
|
||||
// Poll so coroutine cancellation closes the lifecycle-owned listener promptly.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCallbackTarget(input: InputStream, expectedPort: Int): String {
|
||||
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES)
|
||||
?: throw IOException("Native sign-in callback was empty")
|
||||
val requestParts = requestLine.split(' ')
|
||||
if (requestParts.size != 3 || requestParts[0] != "GET" ||
|
||||
!requestParts[1].startsWith("/") ||
|
||||
!requestParts[2].startsWith("HTTP/1.")
|
||||
) {
|
||||
throw IOException("Native sign-in callback request was malformed")
|
||||
}
|
||||
|
||||
var headerBytes = 0
|
||||
var host: String? = null
|
||||
while (true) {
|
||||
val line = readAsciiLine(input, MAX_HEADER_BYTES - headerBytes)
|
||||
?: throw IOException("Native sign-in callback headers were incomplete")
|
||||
headerBytes += line.length + 2
|
||||
if (line.isEmpty()) break
|
||||
if (line.startsWith("Host:", ignoreCase = true)) {
|
||||
host = line.substringAfter(':').trim()
|
||||
}
|
||||
if (headerBytes >= MAX_HEADER_BYTES) {
|
||||
throw IOException("Native sign-in callback headers were too large")
|
||||
}
|
||||
}
|
||||
if (host != "127.0.0.1:$expectedPort") {
|
||||
throw IOException("Native sign-in callback host was not accepted")
|
||||
}
|
||||
return requestParts[1]
|
||||
}
|
||||
|
||||
private fun readAsciiLine(input: InputStream, limit: Int): String? {
|
||||
if (limit <= 0) throw IOException("Native sign-in callback was too large")
|
||||
val bytes = ArrayList<Byte>(minOf(limit, 128))
|
||||
var previous = -1
|
||||
while (bytes.size < limit) {
|
||||
val current = input.read()
|
||||
if (current == -1) return if (bytes.isEmpty()) null else throw IOException(
|
||||
"Native sign-in callback ended unexpectedly",
|
||||
)
|
||||
if (previous == '\r'.code && current == '\n'.code) {
|
||||
bytes.removeAt(bytes.lastIndex)
|
||||
return bytes.toByteArray().toString(Charsets.US_ASCII)
|
||||
}
|
||||
bytes += current.toByte()
|
||||
previous = current
|
||||
}
|
||||
throw IOException("Native sign-in callback line was too large")
|
||||
}
|
||||
|
||||
private fun writeResponse(socket: Socket, status: String, body: String) {
|
||||
val html = """
|
||||
<!doctype html>
|
||||
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body><p>${escapeHtml(body)}</p></body></html>
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8)
|
||||
val headers = buildString {
|
||||
append("HTTP/1.1 ").append(status).append("\r\n")
|
||||
append("Content-Type: text/html; charset=utf-8\r\n")
|
||||
append("Content-Length: ").append(html.size).append("\r\n")
|
||||
append("Cache-Control: no-store\r\n")
|
||||
append("Connection: close\r\n\r\n")
|
||||
}.toByteArray(Charsets.US_ASCII)
|
||||
runCatching {
|
||||
socket.getOutputStream().apply {
|
||||
write(headers)
|
||||
write(html)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeHtml(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
/**
|
||||
* Parsed form of upstream's persisted user-image directives.
|
||||
*
|
||||
* Only canonical, full-line `@image:<absolute-path>` values are recognized.
|
||||
* Unknown or malformed directives stay visible as text. Valid directives are
|
||||
* removed from the bubble so a host-local path is never exposed in the UI.
|
||||
*/
|
||||
internal data class PersistedImageReferences(
|
||||
val cleanedText: String,
|
||||
val paths: List<String>,
|
||||
)
|
||||
|
||||
internal object PersistedImageReferenceParser {
|
||||
private const val MAX_INPUT_CHARS = 256 * 1024
|
||||
private const val MAX_PATH_CHARS = 2_048
|
||||
private const val MAX_ATTACHMENTS = 8
|
||||
|
||||
private val imageExtensions = setOf(
|
||||
"avif",
|
||||
"bmp",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
)
|
||||
|
||||
fun parse(content: String): PersistedImageReferences {
|
||||
if (content.isEmpty() || content.length > MAX_INPUT_CHARS || "@image:" !in content) {
|
||||
return PersistedImageReferences(content, emptyList())
|
||||
}
|
||||
|
||||
val keptLines = ArrayList<String>()
|
||||
val paths = ArrayList<String>()
|
||||
|
||||
for (line in content.lines()) {
|
||||
val path = parseDirectiveLine(line)
|
||||
if (path == null) {
|
||||
keptLines += line
|
||||
} else if (paths.size < MAX_ATTACHMENTS) {
|
||||
paths += path
|
||||
}
|
||||
// Recognized refs beyond the attachment cap are still removed:
|
||||
// exposing a server-local path is worse than omitting an excessive
|
||||
// attachment from a deliberately bounded gallery.
|
||||
}
|
||||
|
||||
return PersistedImageReferences(
|
||||
cleanedText = keptLines.joinToString("\n").trim(),
|
||||
paths = paths,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDirectiveLine(line: String): String? {
|
||||
if (!line.startsWith("@image:")) return null
|
||||
val rawValue = line.removePrefix("@image:")
|
||||
if (rawValue.isEmpty() || rawValue.length > MAX_PATH_CHARS) return null
|
||||
|
||||
val path = unwrapCanonicalValue(rawValue) ?: return null
|
||||
if (path.isBlank() || path.any { it == '\u0000' || it == '\r' || it == '\n' }) return null
|
||||
if (!isAbsolutePath(path) || !hasImageExtension(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private fun unwrapCanonicalValue(value: String): String? {
|
||||
val first = value.first()
|
||||
if (first !in charArrayOf('`', '"', '\'')) {
|
||||
return value.takeIf { candidate -> candidate.none { it.isWhitespace() } }
|
||||
}
|
||||
if (value.length < 3 || value.last() != first) return null
|
||||
val inner = value.substring(1, value.lastIndex)
|
||||
return inner.takeIf { first !in it }
|
||||
}
|
||||
|
||||
private fun isAbsolutePath(path: String): Boolean =
|
||||
path.startsWith("/") ||
|
||||
(
|
||||
path.length >= 3 &&
|
||||
path[0].isLetter() &&
|
||||
path[1] == ':' &&
|
||||
(path[2] == '\\' || path[2] == '/')
|
||||
)
|
||||
|
||||
private fun hasImageExtension(path: String): Boolean {
|
||||
val fileName = path.substringAfterLast('/').substringAfterLast('\\')
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
|
||||
return extension in imageExtensions
|
||||
}
|
||||
}
|
||||
+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() }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+351
-34
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.network.shared.VoiceAudioClient
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -15,13 +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:
|
||||
@@ -32,22 +52,21 @@ import java.util.concurrent.TimeUnit
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
|
||||
* must carry the same exact-origin cookie or native bearer session used by
|
||||
* Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
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 {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -56,14 +75,10 @@ class StandardHermesVoiceClient(
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
@@ -80,21 +95,22 @@ 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()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
@@ -108,6 +124,7 @@ class StandardHermesVoiceClient(
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
@@ -115,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)
|
||||
@@ -130,7 +144,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
@@ -148,10 +162,61 @@ class StandardHermesVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||
val callClient = callClient(baseUrl)
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
|
||||
.getOrThrow()
|
||||
.stringField("ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
val websocketUrl = DashboardApiClient.gatewayWebSocketUrl(
|
||||
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(
|
||||
request = request,
|
||||
callbacks = callbacks,
|
||||
json = json,
|
||||
socketFactory = webSocketFactory ?: callClient::newWebSocket,
|
||||
).also { it.connect() }
|
||||
.let { Result.success<VoiceSpeechStream?>(it) }
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
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))
|
||||
|
||||
private fun executeJson(
|
||||
request: Request,
|
||||
operation: String,
|
||||
callClient: OkHttpClient,
|
||||
): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
@@ -193,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"
|
||||
@@ -242,3 +301,261 @@ class StandardHermesVoiceClient(
|
||||
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
private val json: Json,
|
||||
private val socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
) : VoiceSpeechStream {
|
||||
private val lock = Any()
|
||||
private val outcome = CompletableDeferred<VoiceSpeechStreamOutcome>()
|
||||
private val pendingFrames = ArrayDeque<String>()
|
||||
private var socket: WebSocket? = null
|
||||
private var opened = false
|
||||
private var stopped = false
|
||||
private var finished = false
|
||||
private var audioStarted = false
|
||||
private var sampleRate = DEFAULT_SAMPLE_RATE
|
||||
private var oddByteCarry: Byte? = null
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
val frames = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) {
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
socket = webSocket
|
||||
opened = true
|
||||
pendingFrames.toList().also { pendingFrames.clear() }
|
||||
}
|
||||
frames.forEach(webSocket::send)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val root = runCatching { json.decodeFromString<JsonObject>(text) }.getOrNull() ?: return
|
||||
when (root.stringField("type")) {
|
||||
"start" -> {
|
||||
val nextRate = (root["sample_rate"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: DEFAULT_SAMPLE_RATE
|
||||
val channels = (root["channels"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: 1
|
||||
if (channels != 1) {
|
||||
settle(
|
||||
status = VoiceSpeechStreamStatus.Fallback,
|
||||
error = IOException("Hermes speech stream returned $channels channels; mono required"),
|
||||
)
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
synchronized(lock) { sampleRate = nextRate }
|
||||
callbacks.onStart(nextRate, channels)
|
||||
}
|
||||
"fallback" -> {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) {
|
||||
VoiceSpeechStreamStatus.Completed
|
||||
} else {
|
||||
VoiceSpeechStreamStatus.Fallback
|
||||
},
|
||||
)
|
||||
webSocket.close(1000, "fallback")
|
||||
}
|
||||
"end" -> {
|
||||
settle(VoiceSpeechStreamStatus.Completed)
|
||||
webSocket.close(1000, "complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
val delivery = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
var incoming = bytes.toByteArray()
|
||||
oddByteCarry?.let { carry ->
|
||||
incoming = byteArrayOf(carry) + incoming
|
||||
oddByteCarry = null
|
||||
}
|
||||
val usable = incoming.size - (incoming.size % 2)
|
||||
if (usable < incoming.size) oddByteCarry = incoming.last()
|
||||
if (usable == 0) return
|
||||
audioStarted = true
|
||||
incoming.copyOf(usable) to sampleRate
|
||||
}
|
||||
runCatching { callbacks.onPcm(delivery.first, delivery.second) }
|
||||
.onFailure { error ->
|
||||
settle(VoiceSpeechStreamStatus.Failed, error)
|
||||
webSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
settleForDisconnect(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
val created = socketFactory(request, listener)
|
||||
synchronized(lock) {
|
||||
if (socket == null) socket = created
|
||||
if (stopped) created.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun append(text: String) {
|
||||
if (text.isEmpty()) return
|
||||
sendFrame(buildJsonObject { put("text", text) })
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
synchronized(lock) {
|
||||
if (finished || stopped || outcome.isCompleted) return
|
||||
finished = true
|
||||
}
|
||||
sendFrame(buildJsonObject { put("done", true) })
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val current = synchronized(lock) {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
socket
|
||||
}
|
||||
if (current != null) {
|
||||
current.send(json.encodeToString(JsonObject.serializer(), buildJsonObject { put("stop", true) }))
|
||||
current.cancel()
|
||||
}
|
||||
settle(VoiceSpeechStreamStatus.Stopped)
|
||||
}
|
||||
|
||||
override suspend fun awaitOutcome(): VoiceSpeechStreamOutcome = outcome.await()
|
||||
|
||||
private fun sendFrame(frame: JsonObject) {
|
||||
val encoded = json.encodeToString(JsonObject.serializer(), frame)
|
||||
val current = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
if (!opened) {
|
||||
pendingFrames.addLast(encoded)
|
||||
return
|
||||
}
|
||||
socket
|
||||
}
|
||||
if (current?.send(encoded) != true) {
|
||||
settleForDisconnect(IOException("Hermes speech stream send failed"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleForDisconnect(error: Throwable) {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) VoiceSpeechStreamStatus.Failed else VoiceSpeechStreamStatus.Fallback,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun settle(status: VoiceSpeechStreamStatus, error: Throwable? = null) {
|
||||
val result = synchronized(lock) {
|
||||
if (outcome.isCompleted) return
|
||||
VoiceSpeechStreamOutcome(
|
||||
status = status,
|
||||
audioStarted = audioStarted,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
outcome.complete(result)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_SAMPLE_RATE = 24_000
|
||||
}
|
||||
}
|
||||
|
||||
internal const val STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS = 180L
|
||||
|
||||
internal fun standardHermesDashboardAudioTimeoutSeconds(requestedSeconds: Long): Long =
|
||||
requestedSeconds.coerceIn(
|
||||
minimumValue = 180L,
|
||||
maximumValue = 600L,
|
||||
)
|
||||
|
||||
internal fun standardHermesDashboardAudioClient(
|
||||
baseClient: OkHttpClient,
|
||||
timeoutSeconds: Long = STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS,
|
||||
): OkHttpClient {
|
||||
val boundedTimeoutSeconds = standardHermesDashboardAudioTimeoutSeconds(timeoutSeconds)
|
||||
return baseClient.newBuilder()
|
||||
.callTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.readTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.writeTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
+7
-2
@@ -163,7 +163,8 @@ data class SessionItem(
|
||||
@SerialName("message_count") val messageCount: Int? = null,
|
||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val resolvedLastActivity: Double?
|
||||
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
||||
@@ -259,6 +260,8 @@ data class MessageItem(
|
||||
val toolCallId: String? = null,
|
||||
val timestamp: Double? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = null,
|
||||
@SerialName("display_kind") val displayKind: String? = null,
|
||||
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
|
||||
// Reasoning persisted with the assistant message (upstream serializes
|
||||
// both names; reasoning is the canonical one). Restored into
|
||||
// ChatMessage.thinkingContent so the Thought-process block survives a
|
||||
@@ -389,7 +392,9 @@ data class HermesSseEvent(
|
||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||
val text: String? = null, // /v1/runs reasoning.available text
|
||||
// Usage/token fields (on assistant.completed / run.completed)
|
||||
val usage: UsageInfo? = null
|
||||
val usage: UsageInfo? = null,
|
||||
// Native session routing proof on run.started and terminal events.
|
||||
val runtime: JsonObject? = null,
|
||||
) {
|
||||
/** Resolve the event type from whichever field is populated. */
|
||||
val resolvedType: String?
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
|
||||
/**
|
||||
* Action-required notifications for Gateway turns blocked on user input.
|
||||
*
|
||||
* Notification copy is deliberately generic. Approval commands, clarification
|
||||
* questions, secret prompts, environment-variable names, and password requests
|
||||
* stay inside the authenticated chat surface and never appear on the lock
|
||||
* screen. A stable tag derived from the durable session and request identity
|
||||
* makes replay/reconnect delivery replace the existing notification.
|
||||
*/
|
||||
object InteractionRequestNotifier {
|
||||
|
||||
private const val TAG = "InteractionNotifier"
|
||||
internal const val CHANNEL_ID = "chat_interactions"
|
||||
private const val CHANNEL_NAME = "Hermes needs input"
|
||||
private const val GROUP_KEY = "gateway-interactions"
|
||||
internal const val NOTIFICATION_ID = 3823
|
||||
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
|
||||
|
||||
internal fun shouldPost(
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
hasPermission: Boolean,
|
||||
): Boolean = alertsEnabled && !appForeground && hasPermission
|
||||
|
||||
internal fun requestKey(sessionId: String, ask: GatewayAsk, profile: String? = null): String {
|
||||
val requestIdentity = ask.requestId?.takeIf { it.isNotBlank() } ?: "session"
|
||||
return "${profile ?: DEFAULT_PROFILE_ROUTE_VALUE}:$sessionId:${ask.kind.name}:$requestIdentity"
|
||||
}
|
||||
|
||||
internal fun notificationTag(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String = "gateway-interaction:${requestKey(sessionId, ask, profile)}"
|
||||
|
||||
internal fun chatRoute(sessionId: String, profile: String? = null): String =
|
||||
"chat?sessionId=${Uri.encode(sessionId)}&profile=${Uri.encode(profile ?: DEFAULT_PROFILE_ROUTE_VALUE)}"
|
||||
|
||||
internal fun safeTitle(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Hermes needs approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Hermes has a question"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Hermes needs sensitive input"
|
||||
}
|
||||
|
||||
internal fun safeBody(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Open Hermes to review the requested action."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open Hermes to answer and continue this turn."
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Open Hermes to respond securely."
|
||||
}
|
||||
|
||||
internal fun safeExpandedBody(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String {
|
||||
val action = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL ->
|
||||
"Review the requested action. Nothing is approved from the notification."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
|
||||
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
|
||||
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
|
||||
}
|
||||
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
|
||||
val sessionLabel = sessionId.takeLast(12)
|
||||
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
|
||||
}
|
||||
|
||||
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Review approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Answer"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Respond securely"
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notify(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
): Boolean {
|
||||
ensureChannel(context)
|
||||
if (!shouldPost(alertsEnabled, appForeground, hasPostNotificationsPermission(context))) {
|
||||
return false
|
||||
}
|
||||
|
||||
val tag = notificationTag(sessionId, ask, profile)
|
||||
val requestKey = requestKey(sessionId, ask, profile)
|
||||
val requestCode = requestKey.hashCode()
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.Builder()
|
||||
.scheme("hermes-relay")
|
||||
.authority("interaction")
|
||||
.appendPath(requestKey)
|
||||
.build()
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, chatRoute(sessionId, profile))
|
||||
}
|
||||
val tapPending = PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val title = safeTitle(ask)
|
||||
val body = safeBody(ask)
|
||||
val expandedBody = safeExpandedBody(sessionId, ask, profile)
|
||||
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes needs your input")
|
||||
.setContentText("Open Hermes to continue.")
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.setGroup(GROUP_KEY)
|
||||
.addAction(0, actionLabel(ask), tapPending)
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
NotificationManagerCompat.from(context).notify(tag, NOTIFICATION_ID, notification)
|
||||
true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "notify failed", it)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, sessionId: String, ask: GatewayAsk, profile: String? = null) {
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(notificationTag(sessionId, ask, profile), NOTIFICATION_ID)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancel failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only this feature's notifications. Android keeps notifications
|
||||
* across process death, so the active-notification scan is also used when
|
||||
* MainActivity returns without an in-memory request registry.
|
||||
*/
|
||||
fun cancelAll(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
runCatching {
|
||||
manager.activeNotifications
|
||||
.filter { it.notification.channelId == CHANNEL_ID }
|
||||
.forEach { manager.cancel(it.tag, it.id) }
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancelAll failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Alerts when a Hermes turn is waiting for your response."
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
setShowBadge(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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
+85
-55
@@ -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(),
|
||||
@@ -268,7 +290,6 @@ fun ActiveCardRelayStatusSection(
|
||||
@Composable
|
||||
fun ActiveCardFeaturesSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
@@ -282,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()
|
||||
|
||||
@@ -348,31 +368,27 @@ fun ActiveCardFeaturesSection(
|
||||
StandardVoiceAvailability.Unknown -> CapabilityTone.Neutral
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
!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 {
|
||||
!relayEnabled || !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 {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
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
|
||||
@@ -614,7 +630,6 @@ private fun CapabilityRow(
|
||||
@Composable
|
||||
fun ActiveCardAdvancedSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
|
||||
onPairRelay: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -654,7 +669,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled, showApi = true, showRelay = false)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = true, showRelay = false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -731,7 +746,7 @@ fun ActiveCardAdvancedSection(
|
||||
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
|
||||
Text(stringResource(R.string.active_section_other_relay_methods))
|
||||
}
|
||||
if (relayEnabled && relayEditorOpen) {
|
||||
if (relayEditorOpen) {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -746,7 +761,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled = true, showApi = false, showRelay = true)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = false, showRelay = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,7 +813,6 @@ fun ActiveCardAdvancedSection(
|
||||
@Composable
|
||||
private fun ManualUrlSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
showApi: Boolean,
|
||||
showRelay: Boolean,
|
||||
) {
|
||||
@@ -960,7 +974,7 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
}
|
||||
|
||||
if (relayEnabled && showRelay) {
|
||||
if (showRelay) {
|
||||
HorizontalDivider()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -1444,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) }
|
||||
@@ -1455,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(
|
||||
@@ -1545,7 +1562,7 @@ fun ActiveCardSecurityPosture(
|
||||
icon = Icons.Filled.Link,
|
||||
label = stringResource(R.string.active_section_relay_session),
|
||||
value = pairedLabel,
|
||||
positive = currentPairedSession != null,
|
||||
positive = relaySessionUsable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1560,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,
|
||||
)
|
||||
}
|
||||
@@ -1584,7 +1609,7 @@ fun ActiveCardSecurityPosture(
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onRevokeRelay,
|
||||
enabled = currentPairedSession != null,
|
||||
enabled = relaySessionUsable && currentPairedSession != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.LinkOff, contentDescription = null)
|
||||
@@ -2098,6 +2123,9 @@ fun ActiveCardRoutesSection(
|
||||
outcomeFor = { candidate ->
|
||||
routeProbeOutcomes[connectionViewModel.routeOutcomeKey(candidate)]
|
||||
},
|
||||
dashboardAuthenticated = connection.dashboardLastStatus?.authenticated,
|
||||
dashboardSignInRequired = connection.dashboardLastStatus?.authRequired == true &&
|
||||
connection.dashboardLastStatus.authenticated != true,
|
||||
preferredRole = preferredRole,
|
||||
manualOverrideRole = manualOverrideRole,
|
||||
onUseNow = { candidate -> connectionViewModel.useRouteNow(candidate.role) },
|
||||
@@ -2127,10 +2155,12 @@ fun ActiveCardRoutesSection(
|
||||
if (routeEditorOpen) {
|
||||
RouteEditorDialog(
|
||||
original = routeEditorOriginal,
|
||||
onSave = { role, apiUrl, onResult ->
|
||||
relayEnabled = connection.relayUrl.isNotBlank() ||
|
||||
endpoints.any { it.relay != null },
|
||||
onSave = { role, dashboardUrl, onResult ->
|
||||
connectionViewModel.saveExtraRoute(
|
||||
role = role,
|
||||
apiUrl = apiUrl,
|
||||
dashboardUrl = dashboardUrl,
|
||||
original = routeEditorOriginal,
|
||||
onResult = onResult,
|
||||
)
|
||||
|
||||
@@ -72,11 +72,13 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
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 androidx.compose.ui.res.stringResource
|
||||
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
|
||||
@@ -261,8 +263,12 @@ fun AgentTextFlow(
|
||||
motionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
// Readable, non-faded mirror of the visible tail — used as the live-region
|
||||
// text on both paths so assistive tech hears the words.
|
||||
@@ -496,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(
|
||||
@@ -517,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.
|
||||
@@ -603,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
|
||||
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
||||
if (!content.contains("![")) return content to emptyList()
|
||||
val images = mutableListOf<ChatInlineImage>()
|
||||
var inlineDataImages = 0
|
||||
var inlineDataBytes = 0L
|
||||
var inlineOverflowNoticeAdded = false
|
||||
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
||||
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
|
||||
val alt = m.groupValues[2].trim()
|
||||
val src = normalizeImageSrc(m.groupValues[3].trim())
|
||||
val isInlineData = src.startsWith("data:image/", ignoreCase = true)
|
||||
val inlineDataSize = inlineImageDecodedSizeUpperBound(src)
|
||||
val exceedsInlineBudget = isInlineData && (
|
||||
inlineDataSize == null || inlineDataSize > INLINE_IMAGE_DATA_MAX_BYTES ||
|
||||
inlineDataImages >= INLINE_IMAGE_DATA_MAX_PER_MESSAGE ||
|
||||
inlineDataBytes + inlineDataSize > INLINE_IMAGE_DATA_MAX_TOTAL_BYTES
|
||||
)
|
||||
if (exceedsInlineBudget) {
|
||||
return@replace if (inlineOverflowNoticeAdded) {
|
||||
""
|
||||
} else {
|
||||
inlineOverflowNoticeAdded = true
|
||||
"\n\n_Additional inline images omitted for memory safety._\n\n"
|
||||
}
|
||||
}
|
||||
images += ChatInlineImage(
|
||||
alt = alt,
|
||||
src = normalizeImageSrc(m.groupValues[3].trim()),
|
||||
src = src,
|
||||
sensitive = spoilerWrapped || isSensitiveAltText(alt),
|
||||
)
|
||||
if (inlineDataSize != null) {
|
||||
inlineDataImages += 1
|
||||
inlineDataBytes += inlineDataSize
|
||||
}
|
||||
""
|
||||
}
|
||||
if (images.isEmpty()) return content to emptyList()
|
||||
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
|
||||
return s.startsWith("http://") || s.startsWith("https://")
|
||||
}
|
||||
|
||||
private fun ChatInlineImage.isInlineDataImage(): Boolean =
|
||||
src.startsWith("data:image/", ignoreCase = true)
|
||||
|
||||
/**
|
||||
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
|
||||
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
|
||||
@@ -232,6 +258,7 @@ fun ChatInlineImages(
|
||||
images.forEach { image ->
|
||||
when {
|
||||
image.isRemote() -> RemoteChatImage(image, maxWidth)
|
||||
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
|
||||
// A server-local file the agent referenced — fetch it through
|
||||
// /media/by-path and render inline; on failure the notice shows
|
||||
// the ACTUAL reason (for debugging) instead of a generic message.
|
||||
@@ -253,6 +280,94 @@ fun ChatInlineImages(
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface DataUrlImagePhase {
|
||||
data object Loading : DataUrlImagePhase
|
||||
data class Loaded(
|
||||
val bitmap: ImageBitmap,
|
||||
val mime: String,
|
||||
) : DataUrlImagePhase
|
||||
data object Rejected : DataUrlImagePhase
|
||||
}
|
||||
|
||||
/** Render the bounded data URLs emitted by upstream `_resolve_media_to_data_urls`. */
|
||||
@Composable
|
||||
private fun DataUrlChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var phase by remember(image.src) { mutableStateOf<DataUrlImagePhase>(DataUrlImagePhase.Loading) }
|
||||
var viewerOpen by remember(image.src) { mutableStateOf(false) }
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(image.src) { mutableStateOf(false) }
|
||||
LaunchedEffect(image.src) {
|
||||
phase = withInlineImageDecodeLock {
|
||||
val decoded = decodeInlineImageDataUrl(image.src)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(decoded.bytes, 0, decoded.bytes.size, bounds)
|
||||
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bitmap = BitmapFactory.decodeByteArray(
|
||||
decoded.bytes,
|
||||
0,
|
||||
decoded.bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||
},
|
||||
)
|
||||
?.asImageBitmap() ?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
DataUrlImagePhase.Loaded(bitmap, decoded.mime)
|
||||
}
|
||||
}
|
||||
when (val current = phase) {
|
||||
DataUrlImagePhase.Loading -> Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
DataUrlImagePhase.Rejected -> UnrenderableImageNotice(
|
||||
image.copy(src = "inline image"),
|
||||
reason = "Unsupported or oversized inline image.",
|
||||
)
|
||||
is DataUrlImagePhase.Loaded -> {
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Bitmap(
|
||||
bitmap = current.bitmap,
|
||||
displayName = image.alt.ifBlank { "image" },
|
||||
mime = current.mime,
|
||||
// Decode the already-retained data URL only when the
|
||||
// user requests Save/Share; don't keep a second 5 MiB
|
||||
// byte array beside every thumbnail.
|
||||
bytesProvider = { decodeInlineImageDataUrlOffMain(image.src)?.bytes },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
sensitive = image.sensitive,
|
||||
initiallyRevealed = revealed,
|
||||
)
|
||||
}
|
||||
InlineImageColumn(image, maxWidth) {
|
||||
BlurredMedia(
|
||||
blurred = !revealed && shouldBlurImage(blurMode, image.sensitive),
|
||||
onReveal = { revealed = true },
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = current.bitmap,
|
||||
contentDescription = image.alt.ifBlank { "Generated image" },
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -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
|
||||
@@ -74,7 +76,7 @@ import kotlinx.coroutines.delay
|
||||
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
||||
* !isStreaming -> VOICE // GraphicEq, primary
|
||||
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
|
||||
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* canCorrect (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* else -> QUEUE // Send glyph + clock badge, tertiary
|
||||
* ```
|
||||
*/
|
||||
@@ -120,7 +122,7 @@ data class ChatInputPickerControl(
|
||||
* the limit) only when length > [charLimit] - 200 — supportingText
|
||||
* reflows the bar, the overline doesn't.
|
||||
* - [caption] renders a single relayMetadataStyle line above the bar
|
||||
* (steer/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* (correct/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* is STEER, muted otherwise. Null collapses the row.
|
||||
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
||||
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
||||
@@ -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.
|
||||
@@ -160,6 +168,9 @@ fun ChatInputBar(
|
||||
onModelPickerClick: (() -> Unit)? = null,
|
||||
effortControl: ChatInputPickerControl? = null,
|
||||
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
|
||||
topContent: (@Composable () -> Unit)? = null,
|
||||
topContentVisible: Boolean = topContent != null,
|
||||
suppressVoiceTrailing: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
@@ -191,7 +202,7 @@ fun ChatInputBar(
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Caption row — steer/queue hinting, single line, no buttons.
|
||||
// Caption row — correct/queue hinting, single line, no buttons.
|
||||
AnimatedVisibility(visible = caption != null) {
|
||||
Text(
|
||||
text = caption ?: lastCaption.orEmpty(),
|
||||
@@ -246,35 +257,57 @@ fun ChatInputBar(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
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
|
||||
@@ -401,42 +434,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 +521,7 @@ fun ChatInputBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputPickerChip(
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
|
||||
/**
|
||||
* Stable, presentation-only summary for a message's attachment group.
|
||||
*
|
||||
* Attachment bytes, fetch state, retry callbacks, and persistence remain owned
|
||||
* by the existing attachment pipeline; this helper only chooses the compact
|
||||
* label shown while that pipeline is folded away.
|
||||
*/
|
||||
internal data class AttachmentGroupSummary(
|
||||
val count: Int,
|
||||
val firstName: String?,
|
||||
val firstType: AttachmentRenderMode,
|
||||
val remainingCount: Int,
|
||||
)
|
||||
|
||||
internal fun attachmentGroupSummary(attachments: List<Attachment>): AttachmentGroupSummary? {
|
||||
val first = attachments.firstOrNull() ?: return null
|
||||
return AttachmentGroupSummary(
|
||||
count = attachments.size,
|
||||
firstName = first.fileName?.trim()?.takeIf(String::isNotEmpty),
|
||||
firstType = first.renderMode,
|
||||
remainingCount = (attachments.size - 1).coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack-style disclosure for all attachments belonging to one message.
|
||||
*
|
||||
* The fold state is saveable and keyed by the message's stable Compose
|
||||
* identity, so attachment lifecycle updates do not unexpectedly reopen a
|
||||
* group the user collapsed. The compact header always remains available,
|
||||
* making preview, retry, download, and file actions recoverable with one tap.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CollapsibleAttachmentGroup(
|
||||
messageKey: String,
|
||||
attachments: List<Attachment>,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val summary = attachmentGroupSummary(attachments) ?: return
|
||||
var expanded by rememberSaveable(messageKey) { mutableStateOf(true) }
|
||||
val stateLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_expanded else R.string.attachment_group_collapsed,
|
||||
)
|
||||
val actionLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_collapse else R.string.attachment_group_expand,
|
||||
)
|
||||
val typeLabel = stringResource(summary.firstType.labelResource())
|
||||
val detail = when {
|
||||
summary.firstName != null && summary.remainingCount > 0 ->
|
||||
stringResource(
|
||||
R.string.attachment_group_named_more,
|
||||
summary.firstName,
|
||||
typeLabel,
|
||||
summary.remainingCount,
|
||||
)
|
||||
summary.firstName != null ->
|
||||
stringResource(R.string.attachment_group_named, summary.firstName, typeLabel)
|
||||
summary.remainingCount > 0 ->
|
||||
stringResource(R.string.attachment_group_typed_more, typeLabel, summary.remainingCount)
|
||||
else -> typeLabel
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-toggle-$messageKey")
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = actionLabel
|
||||
role = Role.Button
|
||||
stateDescription = stateLabel
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.attachment_group_count,
|
||||
summary.count,
|
||||
summary.count,
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-content-$messageKey"),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AttachmentRenderMode.labelResource(): Int = when (this) {
|
||||
AttachmentRenderMode.IMAGE -> R.string.attachment_type_image
|
||||
AttachmentRenderMode.VIDEO -> R.string.attachment_type_video
|
||||
AttachmentRenderMode.AUDIO -> R.string.attachment_type_audio
|
||||
AttachmentRenderMode.PDF -> R.string.attachment_type_pdf
|
||||
AttachmentRenderMode.TEXT -> R.string.attachment_type_text
|
||||
AttachmentRenderMode.GENERIC -> R.string.attachment_type_file
|
||||
}
|
||||
+1750
-37
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,6 @@ import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
|
||||
@@ -197,8 +196,6 @@ fun ConnectionWizard(
|
||||
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
@@ -621,7 +618,6 @@ fun ConnectionWizard(
|
||||
WizardStep.RelayChoice -> RelayChoiceStep(
|
||||
connectionLabel = activeConnection?.label.orEmpty(),
|
||||
dashboardUrl = currentDashboardUrl,
|
||||
relayEnabled = relayEnabled,
|
||||
onPickScan = {
|
||||
chosenMethod = PairMethod.Scan
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
@@ -638,7 +634,6 @@ fun ConnectionWizard(
|
||||
)
|
||||
|
||||
WizardStep.Method -> MethodStep(
|
||||
relayEnabled = relayEnabled,
|
||||
onPickStandard = {
|
||||
chosenMethod = PairMethod.Standard
|
||||
standardError = null
|
||||
@@ -1701,7 +1696,6 @@ private fun FoundCapabilityLine(label: String, value: String, ready: Boolean) {
|
||||
private fun RelayChoiceStep(
|
||||
connectionLabel: String,
|
||||
dashboardUrl: String,
|
||||
relayEnabled: Boolean,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
onPickShowCode: () -> Unit,
|
||||
@@ -1769,14 +1763,12 @@ private fun RelayChoiceStep(
|
||||
subtitle = stringResource(R.string.cw_method_pair_code_subtitle),
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { openExternalUrl(context, RelaySetupDocsUrl) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1794,7 +1786,6 @@ private fun RelayChoiceStep(
|
||||
|
||||
@Composable
|
||||
private fun MethodStep(
|
||||
relayEnabled: Boolean,
|
||||
onPickStandard: () -> Unit,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
@@ -1931,14 +1922,12 @@ private fun MethodStep(
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
|
||||
if (onSkip != null) {
|
||||
TextButton(
|
||||
|
||||
@@ -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
|
||||
@@ -165,7 +167,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 +176,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 +248,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,
|
||||
|
||||
@@ -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))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -224,3 +224,10 @@ private fun DiagnosticLogEntry.detailLine(): String? {
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user