Compare commits

...
Author SHA1 Message Date
Bailey Dixon 58f642dceb merge: sync Android share intents with dev
# Conflicts:
#	CHANGELOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt
2026-08-22 13:08:24 -04:00
Bailey Dixon 3ed64ba251 merge: integrate Android connection recovery 2026-08-22 12:28:47 -04:00
Bailey Dixon a6467e84cb fix(android): recover connection setup failures 2026-08-22 12:28:33 -04:00
Bailey Dixon e69ca817e4 fix(android): handle shared content drafts 2026-08-21 23:54:13 -04:00
Bailey Dixon 733ece9523 merge: back-merge main after Android 1.12.0 and Server 1.9.0 2026-08-21 21:00:36 -04:00
Bailey Dixon 5cd18f8607 Merge pull request #394 from Codename-11/dev
release: Android 1.12.0 and Server 1.9.0
2026-08-21 20:35:20 -04:00
Bailey Dixon 46faddbad6 fix(ci): remove privileged review dispatch 2026-08-21 20:17:51 -04:00
Bailey Dixon 8683b84653 fix(ci): isolate untrusted review builds 2026-08-21 20:15:45 -04:00
Bailey Dixon 0d35d549f9 test(android): isolate theme screenshot state 2026-08-21 19:54:13 -04:00
Bailey Dixon a6d86b0110 release(server): server-v1.9.0 2026-08-21 19:09:37 -04:00
Bailey Dixon d8f343bf50 release(android): android-v1.12.0 2026-08-21 19:09:36 -04:00
Bailey Dixon 5408ec8d30 test(release): harden release coverage 2026-08-21 19:06:32 -04:00
Bailey Dixon ba12c8354e merge: unify Android conversation binding 2026-08-21 18:25:00 -04:00
Bailey Dixon 28b4bd9d8d fix(android): unify conversation binding state 2026-08-21 18:24:45 -04:00
Bailey Dixon 7bd493816f fix(android): align profile identity after locale changes 2026-08-21 17:18:34 -04:00
Bailey Dixon aa2c719aea merge: improve Android custom theme authoring 2026-08-21 16:18:06 -04:00
Bailey Dixon a8830a37b3 fix(android): improve custom theme authoring 2026-08-21 16:17:54 -04:00
Bailey Dixon 66b15524f0 merge: integrate Android locale session restoration 2026-08-21 15:29:31 -04:00
Bailey Dixon 50afb4eed9 merge: integrate Android appearance themes 2026-08-21 13:50:59 -04:00
Bailey Dixon 11787f0eab feat(android): add saved custom themes 2026-08-21 13:50:42 -04:00
Bailey Dixon 9b88ea2680 merge: sync review candidate bundles into local dev 2026-08-21 13:39:31 -04:00
Bailey Dixon 708d27e4ea merge: add review candidate bundles 2026-08-21 13:38:15 -04:00
Bailey Dixon cc197b1598 feat: add review candidate bundles
Build side-by-side Android Candidate APKs with visible provenance, stable-install isolation, and update-channel separation.

Add exact-SHA Android and Relay review artifacts, allow prerelease tags from dev, and keep stable production tags main-only.
2026-08-21 13:25:03 -04:00
Bailey Dixon 356f370a4f fix(android): propagate appearance shape across app 2026-08-21 10:08:18 -04:00
Bailey Dixon 25a206ca04 merge: recover Android Gateway stream continuity 2026-08-21 09:48:08 -04:00
Bailey Dixon 92a06478cb docs: route gateway changes through contract lab 2026-08-21 09:43:09 -04:00
Bailey Dixon 4f25ab02e3 test(android): add reusable gateway contract lab 2026-08-21 09:20:18 -04:00
Bailey Dixon 6324ee4fff merge: integrate relay endpoint normalization 2026-08-21 08:43:19 -04:00
Bailey Dixon ec7f33d337 fix(android): normalize relay endpoint routes 2026-08-20 22:56:12 -04:00
Bailey Dixon f6ee586bc2 fix(android): recover missing gateway terminal frames 2026-08-20 22:53:33 -04:00
Bailey Dixon f1106112b8 fix(android): preserve session identity across locale changes 2026-08-20 22:33:40 -04:00
Bailey Dixon 26841fb002 Merge pull request #391 from Codename-11/chore/backmerge-android-1.11.0
chore: back-merge Android 1.11.0 release
2026-08-20 21:19:53 -04:00
Bailey Dixon 926ffedee9 chore: back-merge Android 1.11.0 release 2026-08-20 21:19:28 -04:00
Bailey Dixon 889c2fb316 Merge pull request #390 from Codename-11/dev
release(android): android-v1.11.0
2026-08-20 20:56:04 -04:00
Bailey Dixon 260c21737c merge: prepare Android 1.11.0 release 2026-08-20 20:12:40 -04:00
Bailey Dixon 7036219f90 release(android): android-v1.11.0 2026-08-20 20:12:06 -04:00
Bailey Dixon d741acab27 merge: clear Android release verification blockers 2026-08-20 19:53:04 -04:00
Bailey Dixon 8d9970449c test(android): align localized route diagnostics 2026-08-20 19:52:38 -04:00
Bailey Dixon be50f9a726 fix(android): preserve stopped recovery placeholders 2026-08-20 19:52:37 -04:00
Bailey Dixon f21923d39b merge: resync remote dev after website hotfix 2026-08-20 17:31:51 -04:00
Bailey Dixon 27970d4020 Merge pull request #389 from Codename-11/chore/backmerge-website-remote-access-link
chore: back-merge website link hotfix
2026-08-20 17:27:59 -04:00
Bailey Dixon 94992ff37f chore: back-merge website link hotfix 2026-08-20 17:27:01 -04:00
Bailey Dixon a25de7fe31 Merge pull request #388 from Codename-11/fix/website-remote-access-link
fix(website): repair remote access links
2026-08-20 17:24:45 -04:00
Bailey Dixon 2006d552e2 fix(website): repair remote access links 2026-08-20 17:22:23 -04:00
Bailey Dixon ab532d0696 merge: sync remote dev before release 2026-08-20 17:11:47 -04:00
Bailey Dixon 66971cb0e2 Merge pull request #383 from ophirhan/fix/soft-keyboard-newline
fix(android): only physical Enter sends; let IME return key insert newline
2026-08-20 14:20:47 -04:00
Bailey Dixon 9ae8de2c9d merge: fully expand Android Bridge screen access sheet 2026-08-20 13:17:59 -04:00
Bailey Dixon b5174d6279 fix(android): fully expand screen access sheet 2026-08-20 13:17:41 -04:00
Bailey Dixon 7ac5a48e18 merge: clarify Android Bridge access controls 2026-08-20 13:01:43 -04:00
Bailey Dixon ea6cb5a6a7 fix(android): clarify bridge access controls 2026-08-20 13:01:29 -04:00
Bailey Dixon d5cccd664a merge: preserve unlimited Android Bridge state 2026-08-20 12:40:14 -04:00
Bailey Dixon a48349e3cf fix(android): preserve unlimited bridge access state 2026-08-20 12:39:54 -04:00
Bailey Dixon d476704c3f merge: allow unlimited Android Bridge screen access 2026-08-20 11:53:15 -04:00
Bailey Dixon f7a070ecfe feat(android): allow unlimited bridge screen access 2026-08-20 11:52:52 -04:00
Bailey Dixon 37084566a0 merge: clarify Android Bridge access setup 2026-08-20 11:05:47 -04:00
Bailey Dixon c43f22f18d feat(android): clarify bridge access setup 2026-08-20 11:04:59 -04:00
Bailey Dixon 6244ab3781 merge: surface Android stored session resume failures 2026-08-20 09:14:08 -04:00
Bailey Dixon 9b1852a986 merge: reconcile current dev for Android resume failure fix
# Conflicts:
#	CHANGELOG.md
2026-08-20 08:54:52 -04:00
Bailey Dixon 99f853c98e fix(android): surface stored session resume failures 2026-08-20 08:53:49 -04:00
ophirhan 39782a5ff1 fix(android): only physical Enter sends; let IME return key insert newline
The #318 keyboard handling made any KEYCODE_ENTER key event submit the
message when physicalEnterSends is enabled. Some IMEs dispatch the soft
keyboard return key as a synthesized KEYCODE_ENTER key event (deviceId
-1), so on those keyboards the return key sent the message instead of
inserting a newline - leaving no way to type multi-line prompts from
the touchscreen.

Gate the submit path on physical keys (deviceId != -1) so IME-dispatched
Enter falls through to the default newline insertion while hardware Enter
keeps the send behavior. Adds a regression test for the IME key-event path.

Closes #367
2026-08-20 14:18:56 +03:00
Bailey Dixon 0d660c0e41 merge: add granular Android Bridge capability grants 2026-08-19 21:10:05 -04:00
Bailey Dixon 6b14ac0e0e Merge branch 'dev' into feature/android-bridge-capability-grants 2026-08-19 21:01:31 -04:00
Bailey Dixon 59b5424c49 Merge branch 'fix/android-power-audit-377' into dev 2026-08-19 20:58:24 -04:00
Bailey Dixon 0f83af76f6 fix(android): bound power-sensitive runtime work 2026-08-19 20:08:15 -04:00
Bailey Dixon 6a39e8dc1a feat(android): add granular bridge capability grants 2026-08-19 19:43:04 -04:00
Bailey Dixon e8473e14c8 Merge pull request #376 from Codename-11/chore/backmerge-android-1.10.0
chore: back-merge Android 1.10.0 release
2026-08-18 22:23:49 -04:00
Bailey Dixon e05018bd0b chore: back-merge Android 1.10.0 release 2026-08-18 22:22:49 -04:00
231 changed files with 14411 additions and 1489 deletions
+89 -11
View File
@@ -34,6 +34,7 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
version_code: ${{ steps.version.outputs.version_code }}
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- uses: actions/checkout@v7
with:
@@ -56,8 +57,14 @@ jobs:
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
fi
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
if [[ "$REF_VERSION" == *-* ]]; then
PRERELEASE=true
else
PRERELEASE=false
fi
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
- name: Verify version sync
run: |
@@ -81,14 +88,24 @@ jobs:
- name: Verify public privacy policy URLs
run: python3 scripts/check-privacy-policy.py --live
- name: Verify tagged commit belongs to main
- name: Verify tag belongs to the correct integration branch
env:
PRERELEASE: ${{ steps.version.outputs.prerelease }}
run: |
set -euo pipefail
git fetch origin main --no-tags
tag_commit="$(git rev-parse HEAD)"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
if [ "$PRERELEASE" = "true" ]; then
git fetch origin dev --no-tags
if ! git merge-base --is-ancestor "$tag_commit" origin/dev; then
echo "Android prereleases must be tagged from dev; $tag_commit is not in origin/dev" >&2
exit 1
fi
else
git fetch origin main --no-tags
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Stable Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
fi
- name: Require successful Play preflight for this exact release tree
@@ -175,7 +192,8 @@ jobs:
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
- name: Build release artifacts (APK + AAB)
- name: Build stable release artifacts (APK + AAB)
if: ${{ needs.validate.outputs.prerelease != 'true' }}
env:
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
@@ -191,10 +209,27 @@ jobs:
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
run: ./gradlew bundleRelease assembleRelease
- name: Build side-by-side release candidate APK
if: ${{ needs.validate.outputs.prerelease == 'true' }}
env:
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
SOURCE_SHA="$(git rev-parse HEAD)"
./gradlew :app:assembleSideloadCandidate \
-Pcandidate.kind=rc \
-Pcandidate.label="Android ${VERSION}" \
-Pcandidate.sourceRef="android-v${VERSION}" \
-Pcandidate.sourceSha="$SOURCE_SHA" \
--console=plain
# The Play AAB carries its mapping for Play Console deobfuscation, but
# sideload issue reports need the exact mapping from this immutable build.
# Keep both variants as a workflow artifact (not a public release asset).
- name: Retain R8 mappings for retrace
if: ${{ needs.validate.outputs.prerelease != 'true' }}
uses: actions/upload-artifact@v7
with:
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
@@ -204,12 +239,28 @@ jobs:
if-no-files-found: error
retention-days: 90
- name: Scan release DEX for unsupported collection APIs
- name: Retain candidate R8 mapping for retrace
if: ${{ needs.validate.outputs.prerelease == 'true' }}
uses: actions/upload-artifact@v7
with:
name: android-rc-r8-mapping-${{ needs.validate.outputs.version }}-${{ github.sha }}
path: app/build/outputs/mapping/sideloadCandidate/mapping.txt
if-no-files-found: error
retention-days: 90
- name: Scan stable release DEX for unsupported collection APIs
if: ${{ needs.validate.outputs.prerelease != 'true' }}
run: |
python3 scripts/check-android-collection-apis.py \
--apk app/build/outputs/apk/googlePlay/release/*.apk \
--apk app/build/outputs/apk/sideload/release/*.apk
- name: Scan candidate DEX for unsupported collection APIs
if: ${{ needs.validate.outputs.prerelease == 'true' }}
run: |
python3 scripts/check-android-collection-apis.py \
--apk app/build/outputs/apk/sideload/candidate/*.apk
- name: List produced artifacts (debug aid)
run: |
echo "=== APK outputs ==="
@@ -217,7 +268,8 @@ jobs:
echo "=== AAB outputs ==="
find app/build/outputs/bundle -name '*.aab' -print 2>/dev/null || true
- name: Generate checksums
- name: Generate stable checksums
if: ${{ needs.validate.outputs.prerelease != 'true' }}
# Flavor dimension adds an extra path segment to the AGP output layout.
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
# (note the concatenated camelCase — AGP path quirk, documented but
@@ -229,6 +281,13 @@ jobs:
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Generate candidate checksums
if: ${{ needs.validate.outputs.prerelease == 'true' }}
run: |
cd app/build/outputs
sha256sum apk/sideload/candidate/*.apk > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Require Play credentials for stable release
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
@@ -255,13 +314,14 @@ jobs:
# Public distribution happens only after Play accepts the production
# submission above. This keeps a Play-detected release blocker from
# appearing after the sideload APK is already public.
- name: Create GitHub Release
- name: Create stable GitHub Release
if: ${{ needs.validate.outputs.prerelease != 'true' }}
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
tag_name: android-v${{ needs.validate.outputs.version }}
body_path: RELEASE_NOTES.md
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
prerelease: false
# Deliberate 2-asset policy (#144): attach ONLY the installable
# sideload APK and Play AAB, plus checksums covering those files.
files: |
@@ -269,13 +329,31 @@ jobs:
app/build/outputs/bundle/googlePlayRelease/*.aab
app/build/outputs/SHA256SUMS.txt
- name: Create candidate GitHub prerelease
if: ${{ needs.validate.outputs.prerelease == 'true' }}
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
tag_name: android-v${{ needs.validate.outputs.version }}
body_path: RELEASE_NOTES.md
prerelease: true
fail_on_unmatched_files: true
files: |
app/build/outputs/apk/sideload/candidate/*.apk
app/build/outputs/SHA256SUMS.txt
- name: Release summary
env:
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
PRERELEASE: ${{ needs.validate.outputs.prerelease }}
run: |
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
if [ "$PRERELEASE" = "true" ] && [ -n "$HERMES_KEYSTORE_BASE64" ]; then
echo "✅ **Release-signed Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
elif [ "$PRERELEASE" = "true" ]; then
echo "⚠️ **Debug-signed Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
elif [ -n "$HERMES_KEYSTORE_BASE64" ]; then
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
else
echo "⚠️ **Debug-signed** (no \`HERMES_KEYSTORE_BASE64\` secret) — NOT suitable for Play Store. Add the secret in repo settings to enable release signing." >> "$GITHUB_STEP_SUMMARY"
+14 -5
View File
@@ -48,16 +48,25 @@ jobs:
exit 1
fi
- name: Verify tagged commit belongs to main
- name: Verify tag belongs to the correct integration branch
shell: bash
working-directory: .
run: |
set -euo pipefail
git fetch origin main --no-tags
version="${GITHUB_REF_NAME#desktop-v}"
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Desktop releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
if [[ "$version" == *-* ]]; then
git fetch origin dev --no-tags
if ! git merge-base --is-ancestor "$tag_commit" origin/dev; then
echo "Desktop prereleases must be tagged from dev; $tag_commit is not in origin/dev" >&2
exit 1
fi
else
git fetch origin main --no-tags
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Stable Desktop releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
fi
build-cli-binaries:
+20 -8
View File
@@ -27,21 +27,31 @@ jobs:
- name: Verify Server version sync and changelog
run: |
python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
if ! grep -Eq "^## \[Server ${TAG_VERSION}\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no Server release heading for $TAG_VERSION"
exit 1
fi
env:
TAG_VERSION: ${{ steps.version.outputs.version }}
- name: Verify tagged commit belongs to main
- name: Verify tag belongs to the correct integration branch
env:
TAG_VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
git fetch origin main --no-tags
tag_commit="$(git rev-parse HEAD)"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Server releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
if [[ "$TAG_VERSION" == *-* ]]; then
git fetch origin dev --no-tags
if ! git merge-base --is-ancestor "$tag_commit" origin/dev; then
echo "Server prereleases must be tagged from dev; $tag_commit is not in origin/dev" >&2
exit 1
fi
else
git fetch origin main --no-tags
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Stable Server releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
fi
test:
@@ -77,7 +87,9 @@ jobs:
python -m pytest \
plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py
plugin/tests/test_session_grants.py \
plugin/tests/test_proactive_channel.py \
plugin/tests/test_android_phone_status.py
package:
name: Build and publish Plugin package
+192
View File
@@ -0,0 +1,192 @@
name: Build Review Bundle
on:
pull_request:
branches:
- dev
types:
- labeled
permissions:
contents: read
pull-requests: read
concurrency:
group: review-bundle-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
resolve:
if: ${{ github.event.label.name == 'review-candidate' }}
name: Resolve exact source
runs-on: ubuntu-latest
outputs:
repository: ${{ steps.source.outputs.repository }}
sha: ${{ steps.source.outputs.sha }}
short_sha: ${{ steps.source.outputs.short_sha }}
label: ${{ steps.source.outputs.label }}
artifact_slug: ${{ steps.source.outputs.artifact_slug }}
source_kind: ${{ steps.source.outputs.source_kind }}
source_value: ${{ steps.source.outputs.source_value }}
steps:
- name: Resolve pull request or exact SHA
id: source
uses: actions/github-script@v8
with:
script: |
const kind = "pull_request";
const source = process.env.PR_NUMBER;
const repository = process.env.PR_HEAD_REPOSITORY;
const sha = process.env.PR_HEAD_SHA;
if (!repository || !sha) {
core.setFailed("the PR head repository is no longer available");
return;
}
const label = `PR #${source}`;
const slug = `pr-${source}`;
core.setOutput("repository", repository);
core.setOutput("sha", sha);
core.setOutput("short_sha", sha.slice(0, 12));
core.setOutput("label", label);
core.setOutput("artifact_slug", slug);
core.setOutput("source_kind", kind);
core.setOutput("source_value", source);
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
build:
name: Build matched Android + Relay bundle
needs: resolve
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout exact review source
uses: actions/checkout@v7
with:
repository: ${{ needs.resolve.outputs.repository }}
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable source
env:
EXPECTED_SHA: ${{ needs.resolve.outputs.sha }}
run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: true
- name: Set up Python 3.11
uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Build side-by-side candidate APK
env:
SOURCE_LABEL: ${{ needs.resolve.outputs.label }}
SOURCE_REF: ${{ needs.resolve.outputs.source_kind }}:${{ needs.resolve.outputs.source_value }}
SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
run: |
./gradlew :app:assembleSideloadCandidate \
-Pcandidate.kind=review \
-Pcandidate.label="$SOURCE_LABEL" \
-Pcandidate.sourceRef="$SOURCE_REF" \
-Pcandidate.sourceSha="$SOURCE_SHA" \
--console=plain
- name: Build Relay packages
run: |
python -m pip install build
python -m build
- name: Verify candidate application identity
run: |
apk="$(find app/build/outputs/apk/sideload/candidate -name '*.apk' -print -quit)"
test -n "$apk"
aapt="$(find "$ANDROID_HOME/build-tools" -type f -name aapt -print | sort -V | tail -1)"
test -x "$aapt"
"$aapt" dump badging "$apk" | grep -F "package: name='com.axiomlabs.hermesrelay.sideload.candidate'"
"$aapt" dump badging "$apk" | grep -F "application-label:'Hermes Candidate'"
- name: Assemble review bundle
env:
SOURCE_KIND: ${{ needs.resolve.outputs.source_kind }}
SOURCE_VALUE: ${{ needs.resolve.outputs.source_value }}
SOURCE_REPOSITORY: ${{ needs.resolve.outputs.repository }}
SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
SOURCE_LABEL: ${{ needs.resolve.outputs.label }}
SHORT_SHA: ${{ needs.resolve.outputs.short_sha }}
run: |
mkdir -p review-bundle/android review-bundle/relay
cp app/build/outputs/apk/sideload/candidate/*.apk review-bundle/android/
cp dist/*.whl dist/*.tar.gz review-bundle/relay/
git archive \
--format=tar.gz \
--output="review-bundle/relay/hermes-relay-source-${SHORT_SHA}.tar.gz" \
HEAD plugin pyproject.toml relay_server
cp docs/review-candidates.md review-bundle/INSTALL.md
python - <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
manifest = {
"schema_version": 1,
"kind": "review",
"label": os.environ["SOURCE_LABEL"],
"source": {
"kind": os.environ["SOURCE_KIND"],
"value": os.environ["SOURCE_VALUE"],
"repository": os.environ["SOURCE_REPOSITORY"],
"sha": os.environ["SOURCE_SHA"],
},
"android": {
"application_id": "com.axiomlabs.hermesrelay.sideload.candidate",
"stable_install_affected": False,
},
"relay": {
"side_by_side_in_same_hermes_process": False,
"staging_or_snapshot_rollback_required": True,
},
"generated_at": datetime.now(timezone.utc).isoformat(),
}
Path("review-bundle/REVIEW_MANIFEST.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
cd review-bundle
find android relay -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS.txt
- name: Upload matched review bundle
uses: actions/upload-artifact@v7
with:
name: hermes-relay-review-${{ needs.resolve.outputs.artifact_slug }}-${{ needs.resolve.outputs.short_sha }}
path: review-bundle/
if-no-files-found: error
retention-days: 14
- name: Review summary
env:
SOURCE_LABEL: ${{ needs.resolve.outputs.label }}
SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
run: |
echo "## Hermes-Relay review bundle" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- Source: **$SOURCE_LABEL**" >> "$GITHUB_STEP_SUMMARY"
echo "- Commit: \`$SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Android package: \`com.axiomlabs.hermesrelay.sideload.candidate\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Stable Android installs are not replaced." >> "$GITHUB_STEP_SUMMARY"
echo "- Relay review requires a staging Hermes instance or an explicit snapshot/rollback window." >> "$GITHUB_STEP_SUMMARY"
+12 -1
View File
@@ -12,6 +12,7 @@ not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
- Release process → **[RELEASE.md](RELEASE.md)**
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
- Gateway/session/reconnect testing → **[docs/gateway-contract-testing.md](docs/gateway-contract-testing.md)**
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
@@ -21,7 +22,8 @@ not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
|---|---|
| Integration branch | `dev`; normal feature, fix, docs, and chore PRs target `dev` |
| Release branch | `main`; release history and hotfix integration only |
| Tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
| Production tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
| Candidate tag source | An exact release-prepared and tested `dev` SHA; prerelease suffix required (`-alpha`, `-beta`, or `-rc.N`) |
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
| Hotfix base | The immutable production tag for the affected surface |
@@ -44,6 +46,15 @@ a staging branch.
through upstream PRs or the optional relay plugin, never fork patches.
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
- **Use the Gateway contract lab when its boundary changes.** Changes to
Gateway chat events, session identity/resume/activation, streaming completion,
queue ownership, reconnect/lifecycle recovery, or authoritative history must
reuse or extend the declarative fixture scenarios, run the relevant Android
instrumentation when rendered/lifecycle behavior is affected, and run the
scenario manifest through current-upstream conformance. Physical ADB
certification is required only when device/runtime behavior is claimed. All
of these lanes are on demand; do not add scheduled execution without explicit
approval.
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
`dev` and PR back to `dev`; merge commits/no-ff are the repository policy.
Version bumps happen only during release preparation on `dev`, and production
+51
View File
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- **Android shares open as complete reviewable drafts.** Shared links and text now survive fresh-chat draft restoration, while single or multiple shared images and files enter the same composer attachment flow. Mixed text-and-file shares are supported and nothing is sent automatically.
- **Adding or renewing an Android connection no longer stalls during local preparation.** Pair setup keeps its allocated target exact, performs an explicit validated handoff when renewing an existing connection, and continues with that connection's scoped authentication state.
- **Unavailable Android chat routes now fail visibly.** Send attempts with no usable Gateway or API fallback expose a retryable failure, while required profile-scoped history reads report an error instead of treating the wrong or missing history as an empty conversation.
- **Android Diagnostics reports secure-storage degradation and recovery without exposing credentials.** Keystore fallback, encrypted-store self-healing, and temporary in-memory storage are recorded with secret-free recovery guidance.
## [Android 1.12.0] - 2026-08-21
### Added
- **Android can create and save custom themes.** The Custom workshop provides a live chat preview, editable Background, Surface, Accent, and Text roles, Light or Dark ownership, saved Soft/Balanced/Sharp shape, and bounded rename, duplicate, and delete actions. Up to 20 presets remain local to the device.
- **Maintainers can build matched Android and Relay review candidates without cutting a release.** Candidate artifacts share exact source provenance and checksums, install beside stable builds with isolated data, and remain excluded from stable update prompts.
### Changed
- **Appearance shape now applies consistently across the app.** Soft, Balanced, and Sharp styling reaches chat, settings, sheets, dialogs, terminal, voice, Bridge, and other shared surfaces, while accent and shape changes apply immediately. (#385)
- **Selecting an All Profiles session now activates its owning agent.** Header identity, avatar, transcript, drafts, routing, and persistence move together. Merely browsing All Profiles changes nothing, and a profile lock hides All Profiles and rejects cross-profile opens.
### Fixed
- **Language changes preserve the active profile and session.** Activity recreation retains the exact connection, agent, session, and All Profiles browser state without replacing them with stale persisted values. The persistent connection notification also relocalizes without reconnecting. (#381)
- **Gateway chats recover when a terminal frame is missed.** An authoritative idle state settles the active turn, retains its durable session, and reconciles history without resubmitting through fallback transport. (#365)
- **Relay endpoint forms normalize to the correct sibling routes.** Saved base, `/ws`, and `/health` URLs resolve idempotently without producing paths such as `/relay/ws/health`; malformed or ambiguous routes still fail closed. (#380)
## [Server 1.9.0] - 2026-08-21
### Added
- **Reconnect-delivered phone messages carry explicit backlog context.** Relay marks messages flushed from its bounded offline queue and emits one ordered completion event so compatible clients can label delayed messages and summarize the batch without generating one banner per item.
- **Phone status reports granular Bridge capability grants.** Human-readable status and the `android_phone_status` tool distinguish permanent, timed, and unlimited capabilities while retaining the existing Android permission and safety state.
## [1.11.0] - 2026-08-20
### Added
- **Sideload Bridge access is explicitly capability-scoped.** Read-only, read-and-confirm, and custom presets grant only selected powers for the active connection. Screen inspection and control can be allowed for a bounded period or explicitly left unlimited, and Relay status reports the resulting permanent, timed, and unlimited grants.
### Changed
- **The sideload Bridge screen is a summary-first access cockpit.** Agent access, unattended mode, selected Android requirements, and advanced safety controls are separated clearly while the complete permission matrix and power-user controls remain available one tap deeper.
### Fixed
- **Android keeps failed session resumes visible and in context.** Continuing a stored Gateway session no longer falls through to a fresh session when Hermes rejects or mis-scopes the resume. Failed turns remain error-marked and expose a composer-adjacent recovery panel with route-aware details, explicit retry/dismiss actions, and sanitized Diagnostics evidence.
- **Software-keyboard Return inserts a newline across both common Android IME paths.** Keyboards that commit text directly and keyboards that synthesize `KEYCODE_ENTER` now keep multiline composition separate from physical-keyboard Send behavior. (#367)
- **Cancelled answer recovery retains its Stopped status.** Empty recovery placeholders with a persistent status badge are no longer discarded during stream finalization.
- **Android screen-on idle no longer continuously redraws the ASCII sphere.** Idle holds a stable frame while thinking, streaming, and voice states retain full-rate motion; inactive voice waveforms and closed session drawers also stop their frame loops.
- **Android capture and audio effects release power-sensitive resources at their actual lifecycle boundaries.** Screen capture attaches its MediaProjection surface only for a requested frame, unattended Bridge wake locks release when the command finishes, and barge-in AEC/noise suppression attach to the microphone capture session instead of playback.
- **Experimental wake-word listening reuses its PCM normalization buffer.** Continuous opt-in listening no longer allocates a new float frame for every inference call.
## [1.10.0] - 2026-08-18
### Added
+9 -3
View File
@@ -193,6 +193,10 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
### Testing
- **Android:** JUnit + Compose testing for UI, MockK for mocks
- **Gateway/session/reconnect work:** follow the on-demand scenario,
current-upstream conformance, Android instrumentation, and physical-proof
routing in `docs/gateway-contract-testing.md`; do not infer device behavior
from fixture or source checks.
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
- **CI and release gates:** follow the repository-wide requirements in
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
@@ -204,6 +208,7 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
| `docs/gateway-contract-testing.md` | On-demand reusable Gateway scenarios, upstream conformance, Android instrumentation, and ADB certification |
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
| **App — Core** | |
@@ -235,9 +240,10 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
| **App — Bridge** | |
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
| `bridge/BridgeSafetyManager.kt` | Connection-scoped capabilities + timed screen expiry + blocklist + destructive confirmation; unknown, denied, and expired commands fail closed |
| `bridge/BridgeCapabilities.kt` / `data/BridgeCapabilityPolicyRepository.kt` | Closed method/path registry + no-backup-bound per-Connection Always/Never/Timed policy; global safety vocabulary and timer duration remain in `BridgeSafetyPreferences.kt` |
| `ui/screens/BridgeScreen.kt` | Bridge cockpit — master → Agent access posture/setup → single Unattended Access control → capability-scoped Android readiness (expandable full matrix) → Advanced safety/full editor → activity log |
| `ui/components/BridgeAccessCards.kt` | Native access cockpit + first-use preset and screen-lease sheets (renewable idle limits or warned Until-off dedicated-device mode); preserves full permission/safety drilldowns while keeping selected policy/readiness above the fold |
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
+18
View File
@@ -29,6 +29,20 @@ scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start relay server (dev, no TLS)
```
### Review bundles
Maintainers can produce a matched Android + Relay handoff for one pull request
without cutting a release. Run **Actions → Build Review Bundle** with the PR
number or an exact 40-character SHA. The short-lived artifact contains a
side-by-side Candidate APK, Relay packages/source from the same commit,
provenance, checksums, and install/rollback guidance.
Review bundles never bump versions, create tags, upload to Play, or replace the
stable Android app. Relay review still requires a staging Hermes instance or an
explicit immutable snapshot/rollback window because two Relay plugins cannot
own the same tools and hooks in one Hermes process. See
[Review builds and release candidates](docs/review-candidates.md).
Linux/macOS equivalent lives at `scripts/dev.sh`.
### Fast Android iteration
@@ -200,6 +214,10 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
cycle; hosted CI remains the exhaustive all-variant gate.
- **Focused Android unit test:** `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"`
- **Android unit tests:** `scripts/dev.bat test` (runs the sideload debug JUnit + MockK + Compose suite)
- **Gateway contract lab:** [`docs/gateway-contract-testing.md`](docs/gateway-contract-testing.md)
covers the on-demand vanilla-Gateway fixture, Android instrumentation,
upstream conformance, and physical-device ADB certification. No contract or
device lane is scheduled automatically.
- **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.
+74
View File
@@ -1,5 +1,79 @@
# Hermes-Relay — Dev Log
## 2026-08-21 — Android sharesheet draft handoff
Android's sharesheet target now accepts single and multiple text, link, image,
and file shares. Mixed payloads open a fresh reviewable chat draft, preserve the
shared text items in source order in the composer, and reuse the existing bounded
attachment ingestion pipeline without sending automatically.
The handoff remains pending until the exact destination session has been created
and its persisted composer draft has restored. This prevents the draft restore
introduced for conversation continuity from overwriting a shared link or text,
and identity fencing prevents an older asynchronous session creation from
consuming a newer share intent. Attachment ingestion now also preserves coroutine
cancellation so leaving the destination cannot consume a partially imported share.
External file payloads accept only grantable `content://` URIs; sender-controlled
file paths, web URLs, malformed opaque URIs, and custom schemes never reach
Relay's content resolver. Multi-file shares import at most ten attachments and
tell the user when additional eligible files were omitted, bounding aggregate
base64 memory and CPU work on the exported activity path.
API session-creation failures keep the identity-fenced share pending instead of
consuming it. The existing chat error remains visible, and returning to the app
explicitly re-arms one retry without creating an immediate failure loop.
Verification covered the focused sideload JVM regression suite, Kotlin compilation
for both Android flavors, Google Play app lint, the Android and user-doc locale
validators, the public route contract, sideload APK assembly, and inspection of
the packaged manifest's `SEND` and `SEND_MULTIPLE` wildcard MIME filters.
## 2026-08-20 — Android 1.11.0 Bridge access and lower idle power
Hermes-Relay Android 1.11.0 is published from the immutable
`android-v1.11.0` tag, with Google Play versionCode 46 submitted to the
Production track. The release adds per-connection Bridge capability presets,
custom grants, and explicit bounded or unlimited screen access while preserving
the master kill switch and Android permission requirements.
Stored-session resume failures now remain visible without silently changing
conversation context, software-keyboard Return works across direct-text and
synthesized-Enter IMEs, and cancelled recovery keeps its Stopped state. Idle
render loops, screen-capture surfaces, audio effects, wake-word buffers, and
unattended wake locks now follow tighter lifecycle boundaries to reduce power
use without removing persistent Relay reachability.
## 2026-08-20 — Android stored-session resume failures stay visible
Android now treats a failed Gateway `session.resume` as authoritative for the
selected stored conversation. The client no longer creates a replacement
session and submits the continuation after a resume rejection or profile-scope
mismatch, preventing a context-free turn from silently selecting different
runtime state.
Gateway terminal failures and pre-submit transport failures now share a
session-scoped panel immediately above the composer. The panel keeps the failed
transcript row intact, shows only confirmed route/model/provider identity,
offers explicit Details, Retry, and Dismiss actions, and records bounded,
redacted evidence in the existing Diagnostics review/share flow. No route or
model is changed automatically.
## 2026-08-18 — Android 1.10.0 chat continuity and streaming Markdown
Hermes-Relay Android 1.10.0 is published from the immutable
`android-v1.10.0` tag, with the production Play submission committed as
versionCode 45. The release preserves exact-session composer drafts across
restarts, converts large pastes into reviewable attachments, and keeps standard
chat compatible with unmodified upstream Hermes.
Assistant replies now render completed Markdown structures incrementally while
holding an incomplete streaming tail stable. Stable message identity and a
bounded bottom-follow controller prevent completion-time replacement, stacked
scroll animations, and transcript-distance velocity from moving a reader who
has deliberately scrolled away. Foreground reconnect reattaches the visible
Gateway session, malformed imported credentials fail closed, and software
keyboard Return remains distinct from the dedicated Send action.
## 2026-08-17 — Android composer continuity and large-paste review
Android's multiline composer now leaves the software IME action as Return while
+9 -11
View File
@@ -1,22 +1,21 @@
# Hermes-Relay-Server v__VERSION__
**Release Date:** August 14, 2026
**Release Date:** August 21, 2026
This release adds an official, opt-in Relay pane for Hermes Desktop through the supported runtime Plugin SDK. It keeps Relay management profile-scoped and user-invoked without opening a pane during startup, reconnects, profile changes, or plugin updates.
## Summary
This release makes delayed phone delivery and active Bridge access easier to understand. Relay now identifies messages flushed after reconnect, emits one completion signal for the backlog, and reports permanent, timed, and unlimited phone capabilities through status surfaces.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
## Added
### Added
- **Reconnect backlog context.** Messages flushed from the bounded offline queue carry an explicit delayed-delivery marker, followed by one ordered completion event with the delivered count.
- **Granular phone capability status.** Relay status and `android_phone_status` report permanent, timed, and unlimited Bridge capabilities alongside existing Android permissions and safety state.
- **Official Hermes Desktop pane.** The unified plugin package registers a movable native pane for Relay status, paired devices, bridge activity, media, pairing, revocation, and remote-access management.
- **Explicit entry points.** Labeled sidebar, status-bar, and command-palette actions register and reveal the pane lazily; repeated opens reuse the same surface.
- **Profile-scoped state.** Cached Relay state follows the active Hermes profile and is disposed cleanly when the plugin unloads.
## Changed
### Changed
- **Plugin loading stays passive.** Loading, startup, reconnects, profile changes, and updates never reveal the pane or perform pane-owned network work.
- **Phone surfacing semantics are explicit.** Default delivery persists to Threads and notifies, Inbox delivery remains silent, and Session delivery targets an available active conversation before falling back to a notification.
## Install / update
@@ -31,7 +30,6 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
## Verify
hermes relay doctor
# Agent/tool callers can use desktop_health to list desktop targets.
python scripts/check-plugin-version-sync.py --expect __VERSION__
---
+6
View File
@@ -302,6 +302,12 @@ scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start the relay server (dev, no TLS)
```
Gateway, session, streaming, reconnect, or authoritative-history changes use
the reusable, on-demand [Gateway contract lab](docs/gateway-contract-testing.md).
It includes deterministic protocol scenarios, current-upstream conformance,
Android instrumentation, and opt-in physical-device certification; none of
those lanes is scheduled automatically.
### Tech Stack
| Component | Stack |
+29 -19
View File
@@ -177,10 +177,11 @@ then tagging `main`. Feature completion means merged and verified on `dev`; it
does not mean released.
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
an immutable release-candidate tag to staging. Record that source in the Forge
release issue/session. Never deploy a moving branch name as the source of record
and never create a staging branch. Production deploys only immutable
`android-v*`, `server-v*`, or `desktop-v*` tags cut from `main`.
an immutable prerelease tag (`-alpha`, `-beta`, or `-rc.N`) cut from a
release-prepared `dev` commit. Record that source in the Forge release
issue/session. Never deploy a moving branch name as the source of record and
never create a staging branch. Stable production tags are cut only from the new
`main` tip after the approved `dev` → `main` release merge.
### Normal contribution and release flow
@@ -418,10 +419,17 @@ it sit alongside in `[Unreleased]`, and ship them together. A release
is a statement to users that "this is a thing worth updating to," so
the threshold is intent-driven, not event-driven.
If you want to dogfood accumulated `main` state without declaring GA,
tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
the unstable build.
If you want to dogfood a frozen `dev` release candidate without declaring GA,
tag the exact release-prepared `dev` commit with a **prerelease** tag such as
`android-vX.Y.Z-rc.N` or `server-vX.Y.Z-rc.N`. Android prereleases publish the
side-by-side Candidate app and never upload to Play. Server prereleases publish
opt-in packages for staging and do not automatically replace production.
See [Review builds and release candidates](docs/review-candidates.md).
For one-PR review, do not bump versions or create a tag. Run **Build Review
Bundle** for the PR number or exact SHA. It produces one short-lived matched
Android + Relay artifact; the Candidate app uses a separate application ID and
the Relay package requires an explicit staging or snapshot/rollback install.
## Release train ownership
@@ -828,19 +836,20 @@ plugin changes from forcing an Android app `versionCode` bump.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
1. Verifies the stable tag resolves to a commit contained in `main` and that the
tag matches `appVersionName` in
1. Verifies a stable tag resolves to a commit contained in `main`, or a
prerelease tag resolves to a commit contained in `dev`, and that the tag matches `appVersionName` in
`gradle/libs.versions.toml` (mismatches fail the workflow).
2. Runs the Android debug build and the stable sideload pairing/connection
regression slice with explicit timeouts.
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
4. Builds all four flavored release artifacts
4. For stable releases, builds all four flavored release artifacts
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
googlePlay AAB are attached (see §Release assets).
googlePlay AAB are attached. For prereleases, builds only the side-by-side
`sideloadCandidate` APK.
5. Generates `SHA256SUMS.txt` covering the two attached files.
6. Promotes the exact preflighted Production draft to `completed`; a missing
credential or rejected Play edit fails before public GitHub publication.
6. For stable releases only, promotes the exact preflighted Production draft to
`completed`; prereleases never upload to Play.
7. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
@@ -849,8 +858,8 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
On every push of a tag matching `server-v*`,
`.github/workflows/release-plugin.yml`:
1. Verifies the tag commit is contained in `main`, validates the tag against
all server/plugin-owned version metadata checked by
1. Verifies a stable tag commit is contained in `main`, or a prerelease tag is
contained in `dev`, then validates the tag against all server/plugin-owned version metadata checked by
`scripts/check-plugin-version-sync.py`, and requires the matching release
heading in `CHANGELOG.md`.
2. Runs plugin syntax checks and the focused route/auth/session test slice.
@@ -864,9 +873,10 @@ On every push of a tag matching `desktop-v*`,
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
`desktop-v0.3.0`) so the install/pin commands stay accurate. It rejects tags
whose commit is not contained in `main`, whose version differs from
`desktop/package.json`, or whose version has no `CHANGELOG.md` release heading.
`desktop-v0.3.0`) so the install/pin commands stay accurate. It requires stable
tags to be contained in `main` and prerelease tags to be contained in `dev`,
with a version matching `desktop/package.json` and a corresponding
`CHANGELOG.md` release heading.
Fill its Summary and
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
Dashboard-only changes are covered by
+18 -26
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.10.0
# Hermes-Relay-Android v1.12.0
**Release Date:** August 18, 2026
**Release Date:** August 21, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.10.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.12.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,35 +12,27 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This release makes Android chat more continuous: drafts survive restarts, large
pastes become reviewable attachments, live replies render with incremental
Markdown, and foreground reconnect or completion no longer disrupts the open
conversation.
This release adds saved custom themes and makes appearance shape consistent throughout Android. It also keeps profile and session identity intact across All Profiles navigation and language changes, recovers Gateway chats when a completion frame is missed, and accepts common Relay endpoint forms without producing invalid routes.
## Added
- Preserve text, quote/edit context, and pending attachments in the exact
connection, profile, and session draft across app restarts.
- Convert large pastes into reviewable text attachments before sending while
retaining compatible text delivery on fallback transports.
- Render paragraphs, lists, links, fenced code, and tables incrementally from
the first streamed token without replacing the message at completion.
- Create up to 20 local custom themes with editable palette roles, Light or Dark ownership, saved shape, live chat preview, rename, duplicate, and delete controls.
## Changed
- Apply Soft, Balanced, or Sharp styling consistently across chat, settings, sheets, dialogs, terminal, voice, Bridge, and other shared surfaces.
- Activate a session's owning agent when selecting it from All Profiles; profile locks hide that browser and reject cross-profile opens.
## Fixed
- Reattach the visible Gateway session after background/foreground reconnect
and reconcile missed work without leaving the conversation.
- Expose Return on the software keyboard while keeping the dedicated Send
action and physical-keyboard behavior distinct.
- Reject malformed imported credentials before network-header construction or
encrypted-state replacement.
- Keep intentional scrollback fixed and bottom-follow stable while Markdown,
voice actions, timestamps, and token metadata settle.
- Preserve the exact connection, agent, session, transcript, draft, and All Profiles state through an app-language change.
- Relocalize the persistent connection notification without restarting the active connection.
- Settle and reconcile active Gateway turns when the terminal completion frame was missed.
- Normalize Relay base, `/ws`, and `/health` endpoint forms without producing duplicate route segments.
## Install / Verify
- App version: **1.10.0** (versionCode **45**).
- Standard Chat, sessions, Manage, profile identity, streaming Markdown, and
Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat,
foreground session reattachment, or streaming Markdown.
- App version: **1.12.0** (versionCode **47**).
- Standard Chat, sessions, Manage, profile switching, custom themes, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin is not required for standard Android chat, session continuity, themes, or Gateway recovery.
+55 -1
View File
@@ -6,6 +6,60 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Certify Android Gateway missing-terminal recovery on physical devices
Deterministic fake-Gateway coverage now proves that a foreground turn with
rapid deltas and tool activity can lose its WebSocket before
`message.complete`, reactivate the exact live runtime, observe authoritative
`running=false`, and reconcile persisted history without navigation, API
fallback, duplicate submission, or a silent streaming latch. Complete the
remaining hardware matrix before treating issue #365 as device-certified:
On-demand contract-lab certification passed on an Android 16 SM-S938U using
the sideload app and instrumentation APK. The embedded device test exercised
Activity `STARTED` to `RESUMED` while streaming; the external fixture test then
proved prompt submission, controlled socket loss, exact activation,
authoritative HTTP history, idle settlement, and no API fallback. The ADB
runner separately completed launch, Home/foreground, force-stop, and process
recreation without enabling radio mutation. This is deterministic fixture
proof, not certification against the reporter's host/device or a live provider.
- Re-run long multi-turn/tool-heavy chats against current vanilla upstream on
the originally reported Android/device family and one Android 14+ device.
- Exercise foreground-open chat, background/foreground, Wi-Fi/cellular loss,
socket replacement, queued follow-ups, profile/session switches, and process
recreation while capturing the content-free Gateway recovery diagnostic.
- Confirm selection, user-owned scrollback, streaming Markdown, and follow
behavior remain stable while authoritative history catches up.
---
## Certify Android power fixes across the reported device matrix
Issue #377's static estimates are not device measurements. The code now keeps
the idle Sphere static, gates inactive waveform/drawer animation, detaches the
MediaProjection surface between requested frames, binds AEC/NS to the capture
session, releases unattended wake locks at command completion, and reuses the
wake-word normalization buffer. Complete the remaining physical proof before
assigning battery percentages or declaring the report closed:
- Re-run the reported Android 13 / Pixel 4 XL workload with screen-on and
screen-off intervals separated, and with experimental wake listening both
disabled and explicitly enabled. Capture scoped CPU/thread/network/wakelock
evidence plus Battery Historian or Perfetto without resetting batterystats
unless the device owner approves the reset.
- On Android 14+ and a foldable/rotation path, request two screenshots around a
geometry change and verify the existing VirtualDisplay resizes, its surface
is detached between requests, and the projection token is not reused.
- On at least one device with platform AEC, run Standard and Realtime barge-in
through playback and confirm the effect is enabled on the AudioRecord session,
the microphone remains single-owner, interruption still works, and teardown
leaves no audio effect or capture session active.
- Compare Wi-Fi and cellular separately. Treat radio-tail claims as unproven
until packet timing and mobile-radio active time reproduce them on hardware.
---
## Certify the official Desktop Relay plugin
The unified `plugin/desktop/plugin.js` implementation is covered by source-level
@@ -1271,7 +1325,7 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
supported CUA range; restore a mandatory health gate only if the upstream
probe is bounded and cannot leave UI Automation falsely busy.
- **MediaProjection consent flow** — wired in MainActivity (2026-04-12), needs end-to-end test on a real device
- **WorkManager upgrade for auto-disable timer** — currently a coroutine `Job + delay()` in `AutoDisableWorker.kt`; documented at top of file. Upgrade when androidx.work joins the classpath
- **WorkManager upgrade for timed screen-access expiry notification** — authority already fails closed from persisted absolute expiry after restart; the prompt notification is currently a coroutine `Job + delay()` coordinated by `BridgeSafetyManager` / `AutoDisableWorker`. Upgrade only if background notification timing becomes important after androidx.work joins the classpath.
- **Wave 3 voice-bridge multi-turn confirmation** — currently a 5s TTS countdown with cancel; conversational confirmation is the follow-up
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
+27
View File
@@ -1,5 +1,8 @@
import java.util.Properties
fun String.asBuildConfigString(): String =
"\"" + replace("\\", "\\\\").replace("\"", "\\\"") + "\""
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
@@ -9,6 +12,10 @@ plugins {
val supportedHermesDevAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val hermesDevAbi = providers.gradleProperty("hermes.devAbi").orNull
val candidateKind = providers.gradleProperty("candidate.kind").orElse("review").get()
val candidateLabel = providers.gradleProperty("candidate.label").orElse("Local review").get()
val candidateSourceRef = providers.gradleProperty("candidate.sourceRef").orElse("local").get()
val candidateSourceSha = providers.gradleProperty("candidate.sourceSha").orElse("unknown").get()
hermesDevAbi?.let { requestedAbi ->
require(requestedAbi in supportedHermesDevAbis) {
"Unsupported hermes.devAbi '$requestedAbi'. Expected one of: " +
@@ -65,6 +72,11 @@ android {
// Feature flags — DEV_MODE enables all experimental features in debug builds
buildConfigField("boolean", "DEV_MODE", "false")
buildConfigField("boolean", "CANDIDATE_BUILD", "false")
buildConfigField("String", "CANDIDATE_KIND", "".asBuildConfigString())
buildConfigField("String", "CANDIDATE_LABEL", "".asBuildConfigString())
buildConfigField("String", "CANDIDATE_SOURCE_REF", "".asBuildConfigString())
buildConfigField("String", "CANDIDATE_SOURCE_SHA", "".asBuildConfigString())
}
signingConfigs {
@@ -161,6 +173,18 @@ android {
signingConfigs.getByName("debug")
}
}
create("candidate") {
initWith(getByName("release"))
applicationIdSuffix = ".candidate"
versionNameSuffix = "-candidate"
isDebuggable = false
matchingFallbacks += listOf("release")
buildConfigField("boolean", "CANDIDATE_BUILD", "true")
buildConfigField("String", "CANDIDATE_KIND", candidateKind.asBuildConfigString())
buildConfigField("String", "CANDIDATE_LABEL", candidateLabel.asBuildConfigString())
buildConfigField("String", "CANDIDATE_SOURCE_REF", candidateSourceRef.asBuildConfigString())
buildConfigField("String", "CANDIDATE_SOURCE_SHA", candidateSourceSha.asBuildConfigString())
}
}
compileOptions {
@@ -366,6 +390,9 @@ dependencies {
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
testImplementation(libs.konsist)
androidTestImplementation(libs.compose.ui.test.junit4)
// On-device vanilla-Gateway contract tests exercise the production
// Dashboard ticket + WebSocket stack over real loopback sockets.
androidTestImplementation(libs.okhttp.mockwebserver)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
@@ -6,8 +6,6 @@ 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
@@ -0,0 +1,187 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.platform.app.InstrumentationRegistry
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
/**
* Opt-in physical-device/emulator adapter for the shared Python fixture.
*
* Pass `-e gatewayFixtureBaseUrl http://127.0.0.1:8765` after exposing the
* host fixture with `adb reverse`. With no argument this test alone is skipped;
* the embedded regression remains fully standalone.
*/
class GatewayExternalFixtureInstrumentedTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private var gatewayScope: CoroutineScope? = null
private var gatewayClient: GatewayChatClient? = null
private var viewModel: ChatViewModel? = null
@After
fun tearDown() {
viewModel?.updateGatewayClient(null)
gatewayClient?.shutdown()
gatewayScope?.cancel()
}
@Test
fun terminalGapActivate_externalFixtureRecoversFromAuthoritativeHttpHistory() {
val fixtureBaseUrl = InstrumentationRegistry.getArguments()
.getString(ARG_FIXTURE_BASE_URL)
?.trim()
?.trimEnd('/')
assumeTrue(
"Pass -e $ARG_FIXTURE_BASE_URL <url> to run the external fixture lane",
!fixtureBaseUrl.isNullOrBlank(),
)
requireNotNull(fixtureBaseUrl)
val okHttp = OkHttpClient.Builder()
.callTimeout(10, TimeUnit.SECONDS)
.build()
val initialState = readFixtureJson(okHttp, "$fixtureBaseUrl/__fixture__/state")
assertEquals("terminal_gap_activate", initialState["scenario"]?.jsonString())
assertEquals("1", initialState["remaining_turns"].toString())
val dashboard = DashboardApiClient(fixtureBaseUrl, okHttp)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { gatewayScope = it }
val gateway = GatewayChatClient(
initialDashboardClient = dashboard,
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = scope,
reconnectJitterUnit = { 0.0 },
).also { gatewayClient = it }
val handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
val vm = ChatViewModel().also {
// Deliberately omit HermesApiClient: this lane has no API-server
// fallback surface, so a passing turn proves Gateway ownership.
it.initialize(null, handler)
it.streamingEndpoint = "gateway"
it.setProfileMessageLoaderWithMode { profile, sessionId, mode ->
dashboard.getSessionMessages(sessionId, profile, mode)
}
it.updateGatewayClient(gateway)
it.setChatVisible(true)
}.also { viewModel = it }
compose.setContent {
val messages by vm.messages.collectAsStateWithLifecycle()
val streaming by vm.isStreaming.collectAsStateWithLifecycle()
MaterialTheme {
Column(Modifier.testTag("external-contract-transcript")) {
Text(
text = if (streaming) "STREAMING" else "IDLE",
modifier = Modifier.testTag("external-stream-state"),
)
messages.forEach { message ->
Text(
text = "${message.role.name}:${message.content}",
modifier = Modifier.testTag("external-message-${message.id}"),
)
}
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
vm.sendMessage("Exercise terminal gap.")
compose.waitUntil(10_000) {
!handler.isStreaming.value &&
!gateway.hasActiveTurn() &&
handler.messages.value.any {
it.role == MessageRole.ASSISTANT && it.content == AUTHORITATIVE_ANSWER
}
}
compose.onNodeWithTag("external-contract-transcript").assertIsDisplayed()
compose.onNodeWithTag("external-stream-state").assertTextEquals("IDLE")
compose.onAllNodesWithText("${MessageRole.ASSISTANT.name}:$AUTHORITATIVE_ANSWER")
.assertCountEquals(1)
val messages = handler.messages.value
assertEquals(
1,
messages.count {
it.role == MessageRole.ASSISTANT && it.content == AUTHORITATIVE_ANSWER
},
)
assertEquals(1, messages.count { it.role == MessageRole.USER })
assertFalse(messages.any { it.isStreaming || it.isThinkingStreaming })
assertEquals("gateway", vm.streamingEndpoint)
val evidence = readFixtureJson(okHttp, "$fixtureBaseUrl/__fixture__/evidence")
assertEquals("terminal_gap_activate", evidence["scenario"]?.jsonString())
val entries = evidence["entries"] as? JsonArray ?: JsonArray(emptyList())
assertEquals(1, entries.rpcCount("prompt.submit"))
assertEquals(1, entries.rpcCount("session.activate"))
val state = readFixtureJson(okHttp, "$fixtureBaseUrl/__fixture__/state")
assertEquals("terminal_gap_activate", state["scenario"]?.jsonString())
assertEquals("2", state["history_rows"].toString())
}
private fun readFixtureJson(client: OkHttpClient, url: String): JsonObject {
val request = Request.Builder().url(url).get().build()
return client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "fixture HTTP ${response.code}" }
Json.parseToJsonElement(response.body.string()).jsonObject
}
}
private fun JsonArray.rpcCount(method: String): Int = count { element ->
val entry = element as? JsonObject ?: return@count false
entry["kind"]?.jsonString() == "rpc" && entry["method"]?.jsonString() == method
}
private fun kotlinx.serialization.json.JsonElement.jsonString(): String? =
(this as? JsonPrimitive)?.contentOrNull
private companion object {
const val ARG_FIXTURE_BASE_URL = "gatewayFixtureBaseUrl"
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val AUTHORITATIVE_ANSWER = "Persisted after the socket gap."
}
}
@@ -0,0 +1,356 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* On-device contract coverage for issue #365.
*
* This deliberately uses the production [GatewayChatClient], [ChatViewModel],
* and [ChatHandler]. [DeviceGatewayFixture] supplies only the upstream HTTP/WSS
* boundary, so Android main-looper dispatch and Compose collection are real.
*/
class GatewayForegroundRecoveryInstrumentedTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var gatewayScope: CoroutineScope
private lateinit var gatewayClient: GatewayChatClient
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private lateinit var serverSocket: WebSocket
@Volatile
private var persistedHistory: List<MessageItem> = emptyList()
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture()
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
viewModel = ChatViewModel().also {
it.initialize(
HermesApiClient(fixture.server.url("/").toString(), "fixture-key"),
handler,
)
it.streamingEndpoint = "gateway"
it.setProfileMessageLoader { Result.success(persistedHistory) }
it.updateGatewayClient(gatewayClient)
it.setChatVisible(true)
}
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
MaterialTheme {
Column(Modifier.testTag("contract-transcript")) {
Text(
text = if (streaming) "STREAMING" else "IDLE",
modifier = Modifier.testTag("stream-state"),
)
messages.forEach { message ->
Text(
text = "${message.role.name}:${message.content}",
modifier = Modifier.testTag("message-${message.id}"),
)
}
}
}
}
assertTrue(runBlocking { gatewayClient.prewarmAwait(STORED_SESSION_ID) })
serverSocket = fixture.awaitServerSocket()
fixture.awaitRpc("session.resume")
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
fixture.shutdown()
}
@Test
fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() {
viewModel.sendMessage("Run a long foreground task")
fixture.awaitRpc("prompt.submit")
// A multiplexed Gateway shares one socket. Foreign-session events must
// neither render nor settle the visible turn.
serverSocket.send(fixture.event("message.start", null, FOREIGN_SESSION_ID))
serverSocket.send(
fixture.event(
"message.delta",
buildJsonObject { put("text", FOREIGN_ANSWER) },
FOREIGN_SESSION_ID,
),
)
serverSocket.send(
fixture.event(
"message.complete",
buildJsonObject { put("text", FOREIGN_ANSWER) },
FOREIGN_SESSION_ID,
),
)
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
serverSocket.send(
fixture.event(
"tool.start",
buildJsonObject {
put("tool_id", "tool-foreground")
put("name", "terminal")
},
LIVE_SESSION_ID,
),
)
serverSocket.send(
fixture.event(
"message.delta",
buildJsonObject { put("text", PARTIAL_ANSWER) },
LIVE_SESSION_ID,
),
)
compose.waitUntil(5_000) { handler.isStreaming.value }
compose.onNodeWithTag("stream-state").assertTextEquals("STREAMING")
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
assertFalse(handler.messages.value.any { it.content.contains(FOREIGN_ANSWER) })
// Exercise the real Activity collection boundary while the turn is
// still live. STARTED models a covered/backgrounded activity without
// destroying the test host; returning to RESUMED must preserve the
// same turn and transcript without navigation.
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.waitUntil(5_000) { handler.isStreaming.value }
compose.onNodeWithTag("stream-state").assertTextEquals("STREAMING")
// The server finishes while this socket is detached. The replacement
// socket cannot replay message.complete; exact-session activation
// reports running=false and history is now authoritative.
persistedHistory = listOf(
MessageItem(
id = PERSISTED_ANSWER_ID,
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive(AUTHORITATIVE_ANSWER),
),
)
fixture.recoveryRunning = false
serverSocket.close(1011, "fixture foreground gap")
serverSocket = fixture.awaitServerSocket()
fixture.awaitRpc("session.activate")
compose.waitUntil(5_000) {
!handler.isStreaming.value &&
handler.messages.value.singleOrNull()?.id == PERSISTED_ANSWER_ID
}
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
compose.onNodeWithTag("message-$PERSISTED_ANSWER_ID")
.assertTextEquals("${MessageRole.ASSISTANT.name}:$AUTHORITATIVE_ANSWER")
val visible = handler.messages.value
assertEquals(1, visible.size)
assertEquals(AUTHORITATIVE_ANSWER, visible.single().content)
assertFalse(visible.single().isStreaming)
assertFalse(visible.any { it.content.contains(PARTIAL_ANSWER) })
assertFalse(visible.any { it.content.contains(FOREIGN_ANSWER) })
assertEquals(
"history catch-up must not duplicate the authoritative assistant row",
1,
compose.onAllNodesWithTag("message-$PERSISTED_ANSWER_ID").fetchSemanticsNodes().size,
)
assertEquals(
"the prompt must never be resubmitted during recovery",
1,
fixture.rpcCount("prompt.submit"),
)
assertEquals(
"the exact live session should be activated once",
1,
fixture.rpcCount("session.activate"),
)
assertEquals(0, fixture.requestsTo("/v1/chat/completions"))
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
const val FOREIGN_SESSION_ID = "live-foreign"
const val PERSISTED_ANSWER_ID = "persisted-foreground-answer"
const val PARTIAL_ANSWER = "Partial foreground answer"
const val AUTHORITATIVE_ANSWER = "Foreground task finished."
const val FOREIGN_ANSWER = "Wrong session content"
}
}
/** Minimal real-socket implementation of the vanilla Gateway contract used above. */
internal class AndroidGatewayContractFixture {
val server = MockWebServer()
private val json = Json { ignoreUnknownKeys = true }
private val sockets = LinkedBlockingQueue<WebSocket>()
private val allSockets = ConcurrentLinkedQueue<WebSocket>()
private val rpcLog = ConcurrentLinkedQueue<Pair<String, JsonObject>>()
private val requestPaths = ConcurrentLinkedQueue<String>()
private val ticketCount = AtomicInteger(0)
@Volatile
var recoveryRunning = false
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
sockets.add(webSocket)
allSockets.add(webSocket)
webSocket.send(event("gateway.ready", null, null))
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text) as? JsonObject ?: return
val method = (frame["method"] as? JsonPrimitive)?.contentOrNull ?: return
val id = (frame["id"] as? JsonPrimitive)?.contentOrNull?.toLongOrNull() ?: return
val params = frame["params"] as? JsonObject ?: JsonObject(emptyMap())
rpcLog.add(method to params)
val result = when (method) {
"session.resume" -> sessionSnapshot("fixture-live-1")
"session.activate" -> sessionSnapshot(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "fixture-live-1",
)
"prompt.submit", "session.interrupt" -> buildJsonObject { put("ok", true) }
else -> JsonObject(emptyMap())
}
webSocket.send(
buildJsonObject {
put("jsonrpc", "2.0")
put("id", id)
put("result", result)
}.toString(),
)
}
}
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path.orEmpty()
requestPaths.add(path)
return when {
path.startsWith("/api/auth/ws-ticket") -> MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(
"""{"ticket":"device-${ticketCount.incrementAndGet()}","ttl_seconds":30}""",
)
path.startsWith("/api/ws") -> MockResponse().withWebSocketUpgrade(listener)
else -> MockResponse().setResponseCode(404)
}
}
}
server.start()
}
private fun sessionSnapshot(sessionId: String): JsonObject = buildJsonObject {
put("session_id", sessionId)
put("running", recoveryRunning)
put("status", if (recoveryRunning) "streaming" else "idle")
put("info", buildJsonObject { put("profile_name", "default") })
}
fun event(type: String, payload: JsonObject?, sessionId: String?): String =
buildJsonObject {
put("jsonrpc", "2.0")
put("method", "event")
put("params", buildJsonObject {
put("type", type)
payload?.let { put("payload", it) }
sessionId?.let { put("session_id", it) }
})
}.toString()
fun awaitServerSocket(): WebSocket =
sockets.poll(5, TimeUnit.SECONDS) ?: error("Gateway WebSocket did not open")
fun awaitRpc(method: String): JsonObject {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
rpcLog.firstOrNull { it.first == method }?.let { return it.second }
Thread.sleep(20)
}
error("Gateway RPC $method not observed; saw ${rpcLog.map { it.first }}")
}
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
fun shutdown() {
allSockets.forEach { socket -> runCatching { socket.close(1001, "teardown") } }
runCatching { server.shutdown() }
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:icon="@mipmap/ic_launcher_candidate"
android:label="Hermes Candidate"
android:roundIcon="@mipmap/ic_launcher_candidate_round"
tools:replace="android:icon,android:label" />
</manifest>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/candidate_icon_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/candidate_icon_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="candidate_icon_background">#FFB300</color>
</resources>
@@ -1 +1 @@
See Markdown take shape while replies stream without a final message rebuild or scroll jump. Return from another app and resume the open Hermes session automatically. Composer drafts and pending attachments now survive restarts, large pastes become reviewable text attachments, and the software keyboard exposes Return while the dedicated button sends.
Create and save custom themes with full palette and shape controls. Shapes now apply consistently throughout the app. All Profiles sessions switch to their owning agent and survive language changes with the correct header, icon, and transcript. Gateway chats recover when a terminal frame is missed, persistent connection notifications relocalize without reconnecting, and Relay URLs normalize correctly from base, /ws, or /health forms.
@@ -1 +1 @@
新增可在界面中漫游的 Petdex 浮动宠物、由已安装 Hermes 插件提供的安全原生页面,以及支持本地“Hey Hermes”的可选 Android 数字助理。本次更新还新增俄语,并改进语音恢复、路线切换、实时聊天稳定性和宠物移动。
创建并保存带完整配色和形状控制的自定义主题。形状现在会一致应用到整个应用。通过“所有配置文件”选择会话时会切换到其所属智能体,并在更改语言后保留正确的标题、图标和对话内容。Gateway 漏掉终止帧时可恢复聊天,持久连接通知会随语言更新而无需重连,Relay 基础、/ws 与 /health 地址也会正确规范化。
+8 -3
View File
@@ -48,12 +48,17 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- User-mediated text handoff. The app opens a fresh Chat draft
and fills the composer; it never sends from an external intent. -->
<!-- User-mediated sharesheet handoff. Shared text and files open in
a fresh reviewable Chat draft; external intents never send. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- The loopback native-PKCE result page uses this fixed, tokenless
link only to bring the installed flavor back to the foreground.
+56
View File
@@ -1,5 +1,61 @@
{
"versions": [
{
"version": "1.12.0",
"title": "Themes and identity that stay put",
"date": "2026-08-21",
"sections": [
{
"header": "Make the app yours",
"bullets": [
"Create and save custom themes with editable palette roles, Light or Dark ownership, shape, and a live chat preview.",
"Apply Soft, Balanced, or Sharp styling consistently across chat, settings, sheets, dialogs, terminal, voice, and Bridge."
]
},
{
"header": "Keep the right agent active",
"bullets": [
"Selecting a session from All Profiles activates its owning agent with the correct header, avatar, transcript, draft, and routing.",
"Language changes preserve the exact active profile and session while relocalizing the persistent connection notification without reconnecting."
]
},
{
"header": "Recover cleanly",
"bullets": [
"Settle and reconcile Gateway turns when a terminal completion frame is missed without resubmitting through fallback transport.",
"Normalize Relay base, /ws, and /health endpoint forms without producing duplicate route segments."
]
}
]
},
{
"version": "1.11.0",
"title": "Access with clear boundaries",
"date": "2026-08-20",
"sections": [
{
"header": "Choose what Bridge can do",
"bullets": [
"Use read-only, read-and-confirm, or custom capability presets for the active connection in sideload builds.",
"Allow screen inspection and control for a bounded period or explicitly keep access unlimited."
]
},
{
"header": "Recover without losing context",
"bullets": [
"Keep stored-session failures visible with route-aware details and clear retry or dismiss actions.",
"Insert newlines across more software keyboards and retain Stopped status when answer recovery is cancelled."
]
},
{
"header": "Use less power while idle",
"bullets": [
"Pause invisible Sphere, waveform, and drawer animation loops when no motion is needed.",
"Attach capture surfaces only for requested frames and release audio or wake-lock resources at their lifecycle boundaries."
]
}
]
},
{
"version": "1.10.0",
"title": "Chat that stays put",
+7 -7
View File
@@ -1,8 +1,8 @@
v1.10.0 - Chat that stays put
v1.12.0 - Themes and identity that stay put
* See Markdown take shape while replies stream, without a final message rebuild.
* Keep the bottom smoothly followed—or scroll back without being pulled away.
* Return from another app and resume the open Hermes session automatically.
* Keep composer drafts and pending attachments across app restarts.
* Turn large pastes into reviewable text attachments before sending.
* Use Return on the software keyboard while the dedicated button sends.
* Create and save custom themes with full palette and shape controls.
* Apply Soft, Balanced, or Sharp styling consistently throughout the app.
* Switch All Profiles sessions with the correct owning agent, icon, and transcript.
* Preserve the active profile and session through app-language changes.
* Recover Gateway chats when a terminal completion frame is missed.
* Accept Relay base, /ws, and /health endpoint forms without invalid routes.
@@ -14,6 +14,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.animation.doOnEnd
import androidx.core.content.IntentCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@@ -25,8 +26,8 @@ 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.util.SharedTextRequest
import com.hermesandroid.relay.util.extractSharedText
import com.hermesandroid.relay.util.SharedContentRequest
import com.hermesandroid.relay.util.extractSharedContent
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
@@ -129,7 +130,7 @@ class MainActivity : AppCompatActivity() {
// in RelayApp's NavRouteRequest collector — we just pump the request
// into the SharedFlow here.
consumeNavRouteIntent(intent)
consumeSharedTextIntent(intent)
consumeSharedContentIntent(intent)
val consumedAssistantActivation =
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(
this,
@@ -156,7 +157,7 @@ class MainActivity : AppCompatActivity() {
// instead of onCreate. RelayApp's collector handles both cases.
setIntent(intent)
consumeNavRouteIntent(intent)
consumeSharedTextIntent(intent)
consumeSharedContentIntent(intent)
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(this, intent)
// === END PHASE3-safety-rails-followup ===
}
@@ -167,13 +168,42 @@ class MainActivity : AppCompatActivity() {
NavRouteRequest.tryRequest(route)
}
private fun consumeSharedTextIntent(intent: Intent?) {
val sharedText = extractSharedText(
action = intent?.action,
mimeType = intent?.type,
text = intent?.getCharSequenceExtra(Intent.EXTRA_TEXT),
) ?: return
SharedTextRequest.tryRequest(sharedText)
private fun consumeSharedContentIntent(intent: Intent?) {
intent ?: return
val streamUris = buildList {
if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
IntentCompat.getParcelableArrayListExtra(
intent,
Intent.EXTRA_STREAM,
android.net.Uri::class.java,
)?.let(::addAll)
} else {
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, android.net.Uri::class.java)
?.let(::add)
}
}
val clipUris = buildList {
val clipData = intent.clipData ?: return@buildList
repeat(clipData.itemCount) { index -> clipData.getItemAt(index).uri?.let(::add) }
}
val clipTexts = buildList {
val clip = intent.clipData ?: return@buildList
repeat(clip.itemCount) { index -> clip.getItemAt(index).text?.let(::add) }
}
val sharedTexts = if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
intent.getCharSequenceArrayListExtra(Intent.EXTRA_TEXT).orEmpty()
} else {
listOfNotNull(intent.getCharSequenceExtra(Intent.EXTRA_TEXT))
}
val payload = extractSharedContent(
action = intent.action,
texts = sharedTexts,
subject = intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT),
streamUriStrings = streamUris.map(android.net.Uri::toString),
clipTexts = clipTexts,
clipUriStrings = clipUris.map(android.net.Uri::toString),
)
SharedContentRequest.tryRequest(payload)
}
private fun configureAssistantWindow(intent: Intent?) {
@@ -212,6 +242,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
SharedContentRequest.retryFailed()
// Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this)
@@ -21,6 +21,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.add
import kotlinx.serialization.json.put
/**
@@ -226,6 +228,7 @@ class BridgeStatusReporter(
val destructiveVerbsCount = safetySnapshot?.destructiveVerbs?.size ?: 0
val autoDisableMinutes = safetySnapshot?.autoDisableMinutes ?: 0
val autoDisableAtMs = safetyManager?.autoDisableAtMs?.value
val capabilityPolicy = safetyManager?.activeCapabilityPolicy?.value
val deviceName = Build.MODEL ?: "unknown"
@@ -281,6 +284,33 @@ class BridgeStatusReporter(
put("auto_disable_at_ms", autoDisableAtMs)
}
})
put("capabilities", buildJsonObject {
put("schema_version", capabilityPolicy?.schemaVersion ?: 1)
put("permanent", buildJsonArray {
capabilityPolicy?.permanentGrants
?.sortedBy { it.wireId }
?.forEach { add(it.wireId) }
})
put("timed", buildJsonObject {
capabilityPolicy?.timedExpiriesMs
?.filterValues {
it != com.hermesandroid.relay.bridge.BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
}
?.toSortedMap(compareBy { it.wireId })
?.forEach { (capability, expiry) ->
put(capability.wireId, expiry)
}
})
put("unlimited", buildJsonArray {
capabilityPolicy?.timedExpiriesMs
?.filterValues {
it == com.hermesandroid.relay.bridge.BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
}
?.keys
?.sortedBy { it.wireId }
?.forEach { add(it.wireId) }
})
})
// v0.4.1: unattended-access state so the agent can decide
// upfront whether commands will reach apps with the screen
@@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
*
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
* the hard switch — if it's off we never receive events. On top of that the
* user can flip a soft master in Settings (`bridge_master_enabled`); when
* user can flip a soft master in Settings (`bridge_master_enabled_v2`); when
* that's false we still run (Android requires it to stay connected) but we
* refuse to execute commands. [isMasterEnabled] is a StateFlow the UI
* observes and the command handler checks before dispatching actions.
@@ -61,7 +61,9 @@ class HermesAccessibilityService : AccessibilityService() {
private const val TAG = "HermesA11yService"
/** Master-enable DataStore key — read + toggled from Settings UI. */
val KEY_BRIDGE_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
val KEY_BRIDGE_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled_v2")
private val KEY_LEGACY_BRIDGE_MASTER_ENABLED =
booleanPreferencesKey("bridge_master_enabled")
/**
* Static reference to the live service instance, or null if the
@@ -92,6 +94,7 @@ class HermesAccessibilityService : AccessibilityService() {
suspend fun setMasterEnabled(context: Context, enabled: Boolean) {
context.applicationContext.relayDataStore.edit { prefs ->
prefs[KEY_BRIDGE_MASTER_ENABLED] = enabled
prefs[KEY_LEGACY_BRIDGE_MASTER_ENABLED] = false
}
}
}
@@ -15,6 +15,7 @@ import android.os.HandlerThread
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import com.hermesandroid.relay.data.RelayEndpointContract
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
@@ -152,12 +153,14 @@ class ScreenCapture(
// 13 and below but breaks the second /screenshot request on 14+.
//
// Fix: keep the VirtualDisplay + ImageReader + HandlerThread alive
// across captures, keyed by the MediaProjection instance. Rebuild only
// when the projection reference changes (fresh consent grant) or the
// dimensions change (orientation flip). The ImageReader's
// setOnImageAvailableListener drains the buffer continuously; each
// captureAndUpload() installs a one-shot [pendingCapture] callback
// that fires on the next frame.
// across captures, keyed by the MediaProjection instance. The reader
// surface is attached only while a request is waiting, then detached so
// SurfaceFlinger is not continuously mirroring into a drain-and-drop loop.
// Rebuild only when the projection reference changes (fresh consent
// grant). Orientation/size changes resize the existing VirtualDisplay and
// replace its detached ImageReader, preserving Android 14's single-create
// contract. Each captureAndUpload() installs a one-shot [pendingCapture]
// callback that fires on the next attached frame.
//
// Thread model:
// - `captureMutex` serializes concurrent captureAndUpload() calls
@@ -273,6 +276,7 @@ class ScreenCapture(
*/
fun releaseCache() {
synchronized(cacheLock) {
runCatching { cachedDisplay?.setSurface(null) }
runCatching { cachedDisplay?.release() }
runCatching { cachedReader?.close() }
runCatching { cachedThread?.quitSafely() }
@@ -326,6 +330,7 @@ class ScreenCapture(
}
return try {
attachCaptureSurface()
val timeoutMs = captureTimeoutMs()
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
@@ -336,6 +341,24 @@ class ScreenCapture(
} catch (t: Throwable) {
pendingCaptureRef.compareAndSet(deferred, null)
throw t
} finally {
detachCaptureSurface()
}
}
private fun attachCaptureSurface() {
synchronized(cacheLock) {
val display = cachedDisplay ?: throw IOException("capture display unavailable")
val surface = cachedReader?.surface ?: throw IOException("capture surface unavailable")
display.setSurface(surface)
Log.d(TAG, "screen capture surface attached for pending frame")
}
}
private fun detachCaptureSurface() {
synchronized(cacheLock) {
runCatching { cachedDisplay?.setSurface(null) }
.onFailure { Log.v(TAG, "screen capture surface detach failed: ${it.message}") }
}
}
@@ -350,11 +373,12 @@ class ScreenCapture(
/**
* Build (or reuse) the cached VirtualDisplay + ImageReader + HandlerThread
* for this projection. Rebuilds when:
* for this projection. Rebuilds the display when:
*
* - The projection reference has changed (new consent grant landed)
* - The captured dimensions don't match the current display (orientation
* flipped, foldable opened/closed, display switched)
*
* Geometry changes resize that existing display and replace its detached
* consumer surface, as required for Android 14's one-display-per-token rule.
*
* Must be called while [captureMutex] is held so the cached fields
* aren't racing another capture.
@@ -368,52 +392,41 @@ class ScreenCapture(
synchronized(cacheLock) {
val projectionChanged = cachedProjection !== projection
val dimensionsChanged = width != cachedWidth || height != cachedHeight
if (!projectionChanged && !dimensionsChanged && cachedDisplay != null && cachedReader != null) {
val densityChanged = densityDpi != cachedDensity
if (!projectionChanged && !dimensionsChanged && !densityChanged &&
cachedDisplay != null && cachedReader != null
) {
return
}
// Android 14 permits only one createVirtualDisplay() call per
// MediaProjection. Resize the existing display and replace only
// its detached consumer surface when the device geometry changes.
if (!projectionChanged && cachedDisplay != null && cachedThread != null) {
val display = cachedDisplay ?: return
val thread = cachedThread ?: return
val handler = cachedHandler ?: Handler(thread.looper)
display.setSurface(null)
runCatching { cachedReader?.close() }
display.resize(width, height, densityDpi)
cachedReader = createImageReader(width, height, handler)
cachedHandler = handler
cachedWidth = width
cachedHeight = height
cachedDensity = densityDpi
Log.i(TAG, "screen capture pipeline resized ${width}x$height dpi=$densityDpi")
return
}
// Tear down any stale cache before building fresh.
runCatching { cachedDisplay?.setSurface(null) }
runCatching { cachedDisplay?.release() }
runCatching { cachedReader?.close() }
runCatching { cachedThread?.quitSafely() }
val thread = HandlerThread("HermesScreenCapture").apply { start() }
val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, MAX_IMAGES
)
// Persistent listener — fires on every frame the VirtualDisplay
// produces. If there's a pending capture request, we encode
// the frame and complete it; otherwise we just drain the image
// so the ImageReader buffer stays clear.
reader.setOnImageAvailableListener({ r ->
val waiter = pendingCaptureRef.get()
if (waiter == null || !waiter.isActive) {
// Drain-and-drop — nobody's asking for a screenshot
// right now but frames are still arriving.
runCatching { r.acquireLatestImage() }.getOrNull()?.close()
return@setOnImageAvailableListener
}
var image: Image? = null
try {
image = r.acquireLatestImage()
?: return@setOnImageAvailableListener
val png = imageToPngBytes(image, width, height)
// Only complete the EXACT deferred we latched onto,
// so a stale listener firing after supersession doesn't
// resolve a new request.
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.complete(png)
}
} catch (t: Throwable) {
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.completeExceptionally(t)
}
} finally {
runCatching { image?.close() }
}
}, handler)
val reader = createImageReader(width, height, handler)
val display = try {
projection.createVirtualDisplay(
@@ -422,7 +435,7 @@ class ScreenCapture(
height,
densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
reader.surface,
null,
null,
handler,
)
@@ -461,6 +474,38 @@ class ScreenCapture(
}
}
private fun createImageReader(width: Int, height: Int, handler: Handler): ImageReader {
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, MAX_IMAGES,
)
// The listener receives frames only while captureFrame() has attached
// this reader's surface. The empty-waiter branch drains a frame already
// queued at the detach boundary.
reader.setOnImageAvailableListener({ source ->
val waiter = pendingCaptureRef.get()
if (waiter == null || !waiter.isActive) {
runCatching { source.acquireLatestImage() }.getOrNull()?.close()
return@setOnImageAvailableListener
}
var image: Image? = null
try {
image = source.acquireLatestImage()
?: return@setOnImageAvailableListener
val png = imageToPngBytes(image, width, height)
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.complete(png)
}
} catch (t: Throwable) {
if (pendingCaptureRef.compareAndSet(waiter, null)) {
waiter.completeExceptionally(t)
}
} finally {
runCatching { image?.close() }
}
}, handler)
return reader
}
/**
* Convert an [Image] from `ImageReader` into a PNG byte array. The
* plane's `rowStride` may be wider than `width * 4` — we must crop
@@ -511,10 +556,8 @@ class ScreenCapture(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = RelayEndpointContract.parseOrNull(relayUrl)?.httpBaseUrl
?: return Result.failure(IOException("Invalid relay URL"))
val url = "$httpBase/media/upload"
val body = MultipartBody.Builder()
@@ -70,7 +70,7 @@ 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 com.hermesandroid.relay.ui.theme.PersistedHermesRelayTheme
import java.util.UUID
import kotlin.math.max
import kotlin.math.roundToInt
@@ -131,7 +131,7 @@ private class HermesVoiceInteractionSession(
setViewTreeViewModelStoreOwner(viewOwner)
setViewTreeSavedStateRegistryOwner(viewOwner)
setContent {
HermesRelayTheme {
PersistedHermesRelayTheme {
AssistantSessionSurface(
expanded = surfaceExpanded,
onExpandedChange = { surfaceExpanded = it },
@@ -52,18 +52,12 @@ import kotlin.math.max
*
* We configure [AudioRecord] with [MediaRecorder.AudioSource.VOICE_COMMUNICATION]
* so the platform's voice-call AEC pipeline is in play, and additionally try
* to attach [AcousticEchoCanceler] + [NoiseSuppressor] keyed to the ExoPlayer
* audio session id so TTS audio is cancelled from the mic stream specifically.
* to attach [AcousticEchoCanceler] + [NoiseSuppressor] to the capture
* [AudioRecord] session. Android audio preprocessors belong to the capture
* path; a playback session is not a valid attachment target for AEC/NS.
* Without AEC, the device's own speaker output would trip the VAD the moment
* TTS started and we'd interrupt ourselves.
*
* The ExoPlayer audio session id is not stable at the moment we want to start
* listening — Media3 allocates the underlying AudioTrack lazily on first
* playback, and callers may hit [start] before that's happened (e.g. the very
* first sentence of a turn). We poll [audioSessionIdProvider] for up to 1 s
* before giving up on AEC and proceeding with the mic-hardware AEC alone.
* See the `AEC_SESSION_POLL_*` constants below.
*
* ### Graceful degradation
*
* - `AudioRecord.getState() != STATE_INITIALIZED` → log WARN, emit nothing,
@@ -93,8 +87,8 @@ import kotlin.math.max
class BargeInListener internal constructor(
private val audioSource: AudioFrameSource,
private val vadEngine: VadEngine,
private val audioSessionIdProvider: () -> Int,
private val readerDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val nowMsProvider: () -> Long = System::currentTimeMillis,
) {
companion object {
@@ -108,12 +102,6 @@ class BargeInListener internal constructor(
* brief delay (GC pause, dispatcher contention). */
private const val AUDIO_BUFFER_FRAMES = 4
/** ExoPlayer may return `0` for its audio session id until its
* AudioTrack is first allocated (on playback start). Poll the
* provider briefly before giving up on AEC and proceeding without. */
private const val AEC_SESSION_POLL_INTERVAL_MS = 50L
private const val AEC_SESSION_POLL_TIMEOUT_MS = 1_000L
/**
* Factory for the production path. Builds an [AudioRecordSource] from
* a `Context` and wires it to the listener. The returned listener has
@@ -122,11 +110,9 @@ class BargeInListener internal constructor(
fun create(
context: Context,
vadEngine: VadEngine,
audioSessionIdProvider: () -> Int,
): BargeInListener = BargeInListener(
audioSource = AudioRecordSource(context.applicationContext),
vadEngine = vadEngine,
audioSessionIdProvider = audioSessionIdProvider,
)
}
@@ -239,9 +225,8 @@ class BargeInListener internal constructor(
return@launch
}
Log.i(TAG, "Barge-in AudioRecord reader started")
// 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.
// Effects attach beside the reader so capture can begin even
// on devices that reject or omit the optional preprocessors.
effectsJob = launch { maybeAttachEffects() }
while (isActive) {
@@ -282,7 +267,7 @@ class BargeInListener internal constructor(
val gated = rmsGate.observe(
frame = frameBuffer,
rawSpeech = result.probability > 0f,
nowMs = System.currentTimeMillis(),
nowMs = nowMsProvider(),
playbackGraceMs = playbackGraceMs,
confirmedSpeech = result.isSpeech,
playbackActiveOverride = playbackActiveProvider?.invoke(),
@@ -368,14 +353,12 @@ class BargeInListener internal constructor(
}
private suspend fun maybeAttachEffects() {
val sessionId = awaitNonZeroSessionId()
val sessionId = audioSource.audioSessionId
if (sessionId == 0) {
Log.i(
TAG,
"AEC not attached — ExoPlayer audio session id was still 0 " +
"after ${AEC_SESSION_POLL_TIMEOUT_MS}ms poll; continuing " +
"without effects (mic-hardware AEC from VOICE_COMMUNICATION " +
"still in play)",
"AEC not attached — AudioRecord capture session id is 0; " +
"continuing without optional effects",
)
return
}
@@ -411,20 +394,6 @@ class BargeInListener internal constructor(
}
}
private suspend fun awaitNonZeroSessionId(): Int {
val immediate = audioSessionIdProvider()
if (immediate != 0) return immediate
var waited = 0L
while (waited < AEC_SESSION_POLL_TIMEOUT_MS) {
delay(AEC_SESSION_POLL_INTERVAL_MS)
waited += AEC_SESSION_POLL_INTERVAL_MS
val id = audioSessionIdProvider()
if (id != 0) return id
}
return 0
}
private fun releaseEffects() {
aec?.let {
runCatching { it.enabled = false }
@@ -445,6 +414,9 @@ class BargeInListener internal constructor(
* reader coroutine.
*/
internal interface AudioFrameSource {
/** Capture-session id used by Android audio preprocessors. */
val audioSessionId: Int
/**
* Allocate underlying native resources. Returns true on success.
* Returning false from here short-circuits the listener without any
@@ -481,6 +453,9 @@ class BargeInListener internal constructor(
private class AudioRecordSource(context: Context) : AudioFrameSource {
private var record: AudioRecord? = null
override val audioSessionId: Int
get() = record?.audioSessionId ?: 0
@SuppressLint("MissingPermission")
override fun initialize(): Boolean {
val sampleRate = 16_000
@@ -6,6 +6,9 @@ import android.os.Build
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import java.util.concurrent.ConcurrentHashMap
/**
@@ -40,10 +43,87 @@ internal object SecureStoreCache {
* the token store and the dashboard cookie store so a given file always yields
* the SAME backend, via [SecureStoreCache].
*/
internal fun buildRawTokenStore(context: Context, prefsName: String): SessionTokenStore =
KeystoreTokenStore.tryCreate(context, prefsName)
?: runCatching { LegacyEncryptedPrefsTokenStore(context, prefsName) }
.getOrElse { InMemoryTokenStore() }
internal fun buildRawTokenStore(context: Context, prefsName: String): SessionTokenStore {
KeystoreTokenStore.tryCreate(context, prefsName)?.let { return it }
runCatching { LegacyEncryptedPrefsTokenStore(context, prefsName) }
.getOrNull()
?.let {
SecureStorageDiagnostics.preferredStoreUnavailable()
return it
}
SecureStorageDiagnostics.inMemoryStoreOnly()
return InMemoryTokenStore()
}
/** Secret-free diagnostics for credential-store degradation and recovery. */
internal object SecureStorageDiagnostics {
fun preferredStoreUnavailable() {
val title = "Secure credential storage fallback activated"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = title,
detail = "Preferred Android Keystore storage could not initialize; using encrypted compatibility storage.",
operation = "Initialize secure credential storage",
suggestion = "Re-authenticate if saved credentials are unavailable.",
)
}
}
fun preferredStoreRecovered() {
val title = "Keystore credential storage recovered"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = title,
detail = "Unreadable Keystore-backed credential storage was cleared and rebuilt; saved sign-in state may need to be restored.",
operation = "Recover secure credential storage",
suggestion = "Sign in or pair again if this connection no longer has credentials.",
)
}
}
fun legacyStoreRecovered() {
val title = "Encrypted credential storage recovered"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = title,
detail = "Unreadable encrypted credential storage was cleared and rebuilt; saved sign-in state may need to be restored.",
operation = "Recover secure credential storage",
suggestion = "Sign in or pair again if this connection no longer has credentials.",
)
}
}
fun inMemoryStoreOnly() {
val title = "Credential storage is temporary"
recordIfAbsent(title) {
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Error,
title = title,
detail = "Persistent encrypted storage is unavailable; credentials will last only until the app process stops.",
operation = "Initialize secure credential storage",
suggestion = "Restart the device and re-authenticate; include Diagnostics if the problem continues.",
)
}
}
private inline fun recordIfAbsent(title: String, record: () -> Unit) {
synchronized(this) {
val alreadyVisible = DiagnosticsLog.entries.value.any {
it.category == DiagnosticCategory.Auth && it.title == title
}
if (!alreadyVisible) record()
}
}
}
/**
* Abstraction over the storage backend for the relay session token + API key
@@ -161,6 +241,7 @@ class KeystoreTokenStore private constructor(
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
}
prefs = buildPrefs()
SecureStorageDiagnostics.preferredStoreRecovered()
}
companion object {
@@ -328,7 +409,9 @@ class LegacyEncryptedPrefsTokenStore(
} catch (e2: Exception) {
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e2.message}")
}
buildPrefs()
buildPrefs().also {
SecureStorageDiagnostics.legacyStoreRecovered()
}
}
private fun buildPrefs(): SharedPreferences {
@@ -354,6 +437,7 @@ class LegacyEncryptedPrefsTokenStore(
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
}
prefs = buildPrefs()
SecureStorageDiagnostics.legacyStoreRecovered()
}
// AES256_GCM via MasterKey is hardware-backed (TEE) on essentially every
@@ -15,25 +15,22 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
/**
* Phase 3 — safety-rails `bridge-safety-rails`
*
* Canonical "turn the bridge off after idle" unit of work. Not a real
* Canonical timed-screen-expiry notification unit. Not a real
* `androidx.work.CoroutineWorker` — the project intentionally does not
* depend on androidx.work — but its shape mirrors one exactly: a single
* suspend [run] method that performs the work and returns.
*
* Why this pattern instead of dropping a WorkManager dep:
* - Auto-disable is a pure in-memory decision: the toggle lives in our
* own DataStore, no inter-process scheduling is required.
* - Capability expiry is persisted as absolute wall-clock timestamps;
* the in-process job exists only to prune promptly and notify.
* - Android's AlarmManager / WorkManager are needed when the work must
* survive process death. For bridge, process death already implies
* the service is disconnected and the master toggle re-evaluates
* fresh on the next launch. So a coroutine-owned `delay` does it.
* - Every command reschedules the timer, so the idle window is always
* reset against wall clock. No drift concerns.
* survive process death. Authorization itself does survive because the
* command boundary compares persisted expiry with the current clock.
* - Only timed screen inspection/control commands reset the timer.
*
* When WorkManager is added later (say, if notif-listener needs background-posted
* notifications on a schedule), this file is a natural upgrade point:
@@ -51,17 +48,10 @@ class AutoDisableWorker(private val context: Context) {
}
/**
* Execute the auto-disable: flip the master toggle off and post a
* one-shot "bridge paused" notification. Idempotent — safe to call
* twice (the second call just re-writes the same DataStore value
* and overrides the existing notification).
* Post a one-shot notification after timed screen authority is revoked.
* Idempotent — a repeated call replaces the existing notification.
*/
suspend fun run() {
try {
HermesAccessibilityService.setMasterEnabled(context, false)
} catch (t: Throwable) {
Log.w(TAG, "run: failed to flip master toggle", t)
}
postNotification()
}
@@ -92,8 +82,7 @@ class AutoDisableWorker(private val context: Context) {
.setContentTitle(context.getString(R.string.bridge_notification_auto_disabled_title))
.setContentText(context.getString(R.string.bridge_notification_auto_disabled_body))
.setStyle(NotificationCompat.BigTextStyle().bigText(
"Hermes bridge was idle for too long, so device control has been turned off " +
"automatically. Open the Bridge tab to turn it back on if you still need it."
context.getString(R.string.bridge_notification_auto_disabled_body)
))
.setContentIntent(tapPending)
.setAutoCancel(true)
@@ -115,7 +104,7 @@ class AutoDisableWorker(private val context: Context) {
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Fires once when the bridge auto-disables after being idle."
description = "Fires once when timed Bridge screen access expires after idle."
setShowBadge(false)
}
nm.createNotificationChannel(channel)
@@ -0,0 +1,113 @@
package com.hermesandroid.relay.bridge
import kotlinx.serialization.Serializable
/** Stable, auditable authority groups for every phone-side Bridge command. */
@Serializable
enum class BridgeCapability(val wireId: String, val timed: Boolean) {
DEVICE_INFO("device_info", false),
CONTACTS_READ("contacts_read", false),
LOCATION_READ("location_read", false),
CLIPBOARD_READ("clipboard_read", false),
CLIPBOARD_WRITE("clipboard_write", false),
MEDIA_CONTROL("media_control", false),
COMMUNICATIONS("communications", false),
OUTBOUND_SHARING("outbound_sharing", false),
SCREEN_INSPECTION("screen_inspection", true),
SCREEN_CONTROL("screen_control", true),
}
enum class BridgeCapabilityGrant { EXEMPT, PERMANENT, TIMED }
data class BridgeCommandAuthority(
val capability: BridgeCapability? = null,
val grant: BridgeCapabilityGrant,
)
/**
* Closed command registry. Authorization is resolved from both path and HTTP
* method so method-split commands such as clipboard read/write cannot share a
* grant accidentally. Unknown paths and method combinations return null and
* must be denied by the command boundary.
*
* Composite Python tools (android_navigate/android_macro) do not get a broad
* grant: every primitive route they dispatch is checked here independently.
*/
object BridgeCommandRegistry {
private data class Key(val method: String, val path: String)
private fun permanent(capability: BridgeCapability) =
BridgeCommandAuthority(capability, BridgeCapabilityGrant.PERMANENT)
private fun timed(capability: BridgeCapability) =
BridgeCommandAuthority(capability, BridgeCapabilityGrant.TIMED)
private val exempt = BridgeCommandAuthority(grant = BridgeCapabilityGrant.EXEMPT)
private val routes: Map<Key, BridgeCommandAuthority> = buildMap {
fun route(method: String, path: String, authority: BridgeCommandAuthority) {
put(Key(method, path), authority)
}
route("GET", "/ping", exempt)
route("POST", "/setup", exempt)
route("POST", "/wait", exempt)
route("GET", "/current_app", permanent(BridgeCapability.DEVICE_INFO))
route("GET", "/get_apps", permanent(BridgeCapability.DEVICE_INFO))
route("GET", "/apps", permanent(BridgeCapability.DEVICE_INFO))
route("POST", "/search_contacts", permanent(BridgeCapability.CONTACTS_READ))
route("GET", "/location", permanent(BridgeCapability.LOCATION_READ))
route("GET", "/clipboard", permanent(BridgeCapability.CLIPBOARD_READ))
route("POST", "/clipboard", permanent(BridgeCapability.CLIPBOARD_WRITE))
route("POST", "/media", permanent(BridgeCapability.MEDIA_CONTROL))
route("POST", "/call", permanent(BridgeCapability.COMMUNICATIONS))
route("POST", "/send_sms", permanent(BridgeCapability.COMMUNICATIONS))
route("POST", "/share_media", permanent(BridgeCapability.OUTBOUND_SHARING))
route("POST", "/send_mms", permanent(BridgeCapability.OUTBOUND_SHARING))
listOf("/screen", "/screenshot", "/screen_hash", "/events").forEach {
route("GET", it, timed(BridgeCapability.SCREEN_INSPECTION))
}
listOf("/find_nodes", "/describe_node", "/diff_screen", "/events/stream").forEach {
route("POST", it, timed(BridgeCapability.SCREEN_INSPECTION))
}
listOf(
"/tap", "/tap_text", "/long_press", "/type", "/swipe", "/drag",
"/scroll", "/press_key", "/open_app", "/return_to_hermes",
"/send_intent", "/broadcast",
).forEach { route("POST", it, timed(BridgeCapability.SCREEN_CONTROL)) }
}
fun resolve(path: String, method: String): BridgeCommandAuthority? =
routes[Key(method.trim().uppercase(), path.trim())]
fun registeredRoutes(): Set<Pair<String, String>> =
routes.keys.mapTo(linkedSetOf()) { it.method to it.path }
}
@Serializable
data class BridgeCapabilityPolicy(
val schemaVersion: Int = CURRENT_SCHEMA_VERSION,
val permanentGrants: Set<BridgeCapability> = emptySet(),
val timedExpiriesMs: Map<BridgeCapability, Long> = emptyMap(),
) {
companion object {
const val CURRENT_SCHEMA_VERSION = 1
/** Explicit sentinel for a user-selected "Until turned off" lease. */
const val NEVER_EXPIRES_AT_MS: Long = Long.MAX_VALUE
}
fun allows(capability: BridgeCapability, nowMs: Long): Boolean =
if (capability.timed) {
(timedExpiriesMs[capability] ?: 0L) > nowMs
} else {
capability in permanentGrants
}
fun expiryFor(capability: BridgeCapability): Long? = timedExpiriesMs[capability]
fun isUnlimited(capability: BridgeCapability): Boolean =
timedExpiriesMs[capability] == NEVER_EXPIRES_AT_MS
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetySettings
import com.hermesandroid.relay.data.BridgeCapabilityPolicyRepository
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -14,6 +15,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
@@ -25,8 +28,8 @@ import java.util.concurrent.atomic.AtomicLong
/**
* Phase 3 — safety-rails `bridge-safety-rails`
*
* Central enforcement point for Tier 5 safety: per-app blocklist, destructive
* verb confirmation, and idle-based auto-disable. Owned as a singleton-per-
* Central enforcement point for Tier 5 safety: connection-scoped capabilities,
* per-app blocklist, destructive confirmation, and timed screen access. Owned as a singleton-per-
* process by [ConnectionViewModel] and injected into [BridgeCommandHandler].
*
* # Integration surface
@@ -47,9 +50,9 @@ import java.util.concurrent.atomic.AtomicLong
* reacts, which is exactly the UX we want (the server sees a slow
* response, not a denial race).
*
* - [rescheduleAutoDisable] — every accepted command bumps the idle timer
* forward; after [BridgeSafetySettings.autoDisableMinutes] of silence
* the master toggle flips off and a one-shot notification fires.
* - [rescheduleAutoDisable] — accepted timed screen commands bump the idle
* expiry forward; after [BridgeSafetySettings.autoDisableMinutes] of
* silence only timed screen authority is revoked and a notification fires.
* [cancelAutoDisable] cancels the pending timer (called when the master
* toggle flips off manually, so we don't race the timer against the
* user).
@@ -71,15 +74,14 @@ import java.util.concurrent.atomic.AtomicLong
* The Android app does not depend on androidx.work. [AutoDisableWorker]
* documents the canonical pattern, but the live path is a coroutine
* `Job` owned by this manager, delayed by the configured minutes. This is
* acceptable because we are the in-memory owner of the master-toggle flow
* — no inter-process or cross-restart scheduling is needed. On process
* death the master toggle is simply evaluated fresh from DataStore, and
* any command not explicitly sent within the idle window never actually
* happens because the app isn't running.
* acceptable because authorization stores an absolute expiry in DataStore.
* After process death or reconnect, the command boundary compares that expiry
* to wall clock and denies stale authority even if the notification job did not run.
*/
class BridgeSafetyManager(
context: Context,
private val scope: CoroutineScope,
private val activeConnectionId: StateFlow<String?>,
) {
companion object {
private const val TAG = "BridgeSafetyMgr"
@@ -94,10 +96,14 @@ class BridgeSafetyManager(
*/
fun peek(): BridgeSafetyManager? = INSTANCE
fun install(context: Context, scope: CoroutineScope): BridgeSafetyManager {
fun install(
context: Context,
scope: CoroutineScope,
activeConnectionId: StateFlow<String?>,
): BridgeSafetyManager {
val existing = INSTANCE
if (existing != null) return existing
val created = BridgeSafetyManager(context.applicationContext, scope)
val created = BridgeSafetyManager(context.applicationContext, scope, activeConnectionId)
INSTANCE = created
return created
}
@@ -105,6 +111,10 @@ class BridgeSafetyManager(
private val appContext: Context = context.applicationContext
private val prefsRepo = BridgeSafetyPreferencesRepository(appContext)
private val capabilityRepo = BridgeCapabilityPolicyRepository(appContext)
private val _activeCapabilityPolicy = MutableStateFlow(BridgeCapabilityPolicy())
val activeCapabilityPolicy: StateFlow<BridgeCapabilityPolicy> =
_activeCapabilityPolicy.asStateFlow()
/** Latest settings snapshot — UI + checks read this via [settings]. */
private val _settings = MutableStateFlow(BridgeSafetySettings())
@@ -140,12 +150,12 @@ class BridgeSafetyManager(
private val pendingConfirmations = ConcurrentHashMap<Long, PendingConfirmation>()
private val nextRequestId = AtomicLong(0L)
/** Coroutine job that fires auto-disable after idle. */
/** Coroutine job that prunes timed screen authority after idle. */
@Volatile
private var autoDisableJob: Job? = null
/**
* Remaining time (epoch millis) for the current auto-disable job, or
* Remaining time (epoch millis) for current timed screen authority, or
* null when idle. BridgeSafetySummaryCard reads this as a countdown.
*/
private val _autoDisableAtMs = MutableStateFlow<Long?>(null)
@@ -167,6 +177,100 @@ class BridgeSafetyManager(
trustedHydrated = true
}
}
scope.launch {
activeConnectionId.collectLatest { connectionId ->
schedulePersistedExpiry(connectionId)
capabilityRepo.policy(connectionId).collect { policy ->
_activeCapabilityPolicy.value = policy
}
}
}
}
data class CapabilityAuthorization(
val allowed: Boolean,
val authority: BridgeCommandAuthority? = null,
val errorCode: String? = null,
)
fun capabilityPolicy(connectionId: String?): Flow<BridgeCapabilityPolicy> =
capabilityRepo.policy(connectionId)
suspend fun authorizeCapability(
path: String,
method: String,
nowMs: Long = System.currentTimeMillis(),
): CapabilityAuthorization {
val authority = BridgeCommandRegistry.resolve(path, method)
?: return CapabilityAuthorization(false, errorCode = "unknown_bridge_command")
if (authority.grant == BridgeCapabilityGrant.EXEMPT) {
return CapabilityAuthorization(true, authority)
}
val connectionId = activeConnectionId.value
?: return CapabilityAuthorization(false, authority, "bridge_policy_unbound")
val capability = authority.capability
?: return CapabilityAuthorization(false, authority, "bridge_policy_invalid")
val policy = capabilityRepo.snapshot(connectionId)
return if (policy.allows(capability, nowMs)) {
CapabilityAuthorization(true, authority)
} else {
CapabilityAuthorization(
false,
authority,
if (capability.timed) "bridge_capability_expired" else "bridge_capability_denied",
)
}
}
suspend fun setPermanentCapability(
connectionId: String?,
capability: BridgeCapability,
allowed: Boolean,
) {
capabilityRepo.setPermanent(connectionId, capability, allowed)
}
suspend fun replacePermanentCapabilities(
connectionId: String?,
capabilities: Set<BridgeCapability>,
) {
capabilityRepo.replacePermanent(connectionId, capabilities)
}
suspend fun setTimedCapability(
connectionId: String?,
capability: BridgeCapability,
allowed: Boolean,
) {
if (!allowed) {
capabilityRepo.revoke(connectionId, capability)
if (capability == BridgeCapability.SCREEN_CONTROL) {
prefsRepo.setUnattendedAccessEnabled(false)
}
schedulePersistedExpiry(connectionId)
return
}
val fireAt = System.currentTimeMillis() + currentSettings().autoDisableMinutes * 60_000L
capabilityRepo.grantTimed(connectionId, capability, fireAt)
schedulePersistedExpiry(connectionId)
}
suspend fun replaceTimedCapabilities(
connectionId: String?,
capabilities: Set<BridgeCapability>,
durationMinutes: Int,
unlimited: Boolean = false,
) {
val fireAt = if (unlimited) {
BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
} else {
System.currentTimeMillis() + durationMinutes * 60_000L
}
capabilityRepo.replaceTimed(connectionId, capabilities, fireAt)
if (BridgeCapability.SCREEN_CONTROL !in capabilities) {
prefsRepo.setUnattendedAccessEnabled(false)
}
schedulePersistedExpiry(connectionId)
}
// ── Blocklist ────────────────────────────────────────────────────────
@@ -307,26 +411,35 @@ class BridgeSafetyManager(
pending.deferred.complete(allowed)
}
// ── Auto-disable timer ───────────────────────────────────────────────
// ── Timed screen-access expiry ──────────────────────────────────────
/**
* Cancel any pending timer and arm a fresh one. Called on every accepted
* bridge command — an actively-used bridge never auto-disables.
* Refresh active timed grants and arm their shared idle expiry. Permanent
* capability activity never calls this method.
*/
fun rescheduleAutoDisable() {
val connectionId = activeConnectionId.value ?: return
val minutes = _settings.value.autoDisableMinutes
val delayMs = minutes * 60_000L
val fireAt = System.currentTimeMillis() + delayMs
val fireAt = System.currentTimeMillis() + minutes * 60_000L
autoDisableJob?.cancel()
_autoDisableAtMs.value = fireAt
autoDisableJob = (scope + SupervisorJob()).launch {
try {
val snapshot = capabilityRepo.snapshot(connectionId)
val nowMs = System.currentTimeMillis()
val finite = snapshot.timedExpiriesMs.filterValues {
it != BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS && it > nowMs
}
if (finite.isEmpty()) {
_autoDisableAtMs.value = null
return@launch
}
capabilityRepo.refreshActiveTimed(connectionId, fireAt)
_autoDisableAtMs.value = fireAt
val delayMs = (fireAt - System.currentTimeMillis()).coerceAtLeast(0L)
delay(delayMs)
Log.i(TAG, "Auto-disable fired after $minutes min of idle")
// Hand off to the canonical worker so both code paths look
// identical from a behavioral standpoint (notification +
// master-toggle flip).
Log.i(TAG, "Timed Bridge capabilities expired after $minutes min of idle")
capabilityRepo.pruneExpired(connectionId, System.currentTimeMillis())
clearUnattendedIfControlEnded(connectionId)
AutoDisableWorker(appContext).run()
} catch (_: Throwable) {
// Cancellation is expected on reschedule — swallow quietly.
@@ -342,6 +455,48 @@ class BridgeSafetyManager(
_autoDisableAtMs.value = null
}
fun revokeTimedCapabilities() {
val connectionId = activeConnectionId.value ?: return
cancelAutoDisable()
scope.launch {
capabilityRepo.revokeTimed(connectionId)
prefsRepo.setUnattendedAccessEnabled(false)
}
}
private suspend fun schedulePersistedExpiry(connectionId: String?) {
autoDisableJob?.cancel()
val policy = capabilityRepo.snapshot(connectionId)
val nextExpiry = policy.timedExpiriesMs.values
.filter { it != BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS }
.maxOrNull()
if (nextExpiry == null) {
_autoDisableAtMs.value = null
return
}
if (nextExpiry <= System.currentTimeMillis()) {
capabilityRepo.pruneExpired(connectionId, System.currentTimeMillis())
clearUnattendedIfControlEnded(connectionId)
_autoDisableAtMs.value = null
return
}
_autoDisableAtMs.value = nextExpiry
autoDisableJob = (scope + SupervisorJob()).launch {
delay((nextExpiry - System.currentTimeMillis()).coerceAtLeast(0L))
capabilityRepo.pruneExpired(connectionId, System.currentTimeMillis())
clearUnattendedIfControlEnded(connectionId)
AutoDisableWorker(appContext).run()
if (activeConnectionId.value == connectionId) _autoDisableAtMs.value = null
}
}
private suspend fun clearUnattendedIfControlEnded(connectionId: String?) {
val policy = capabilityRepo.snapshot(connectionId)
if (!policy.allows(BridgeCapability.SCREEN_CONTROL, System.currentTimeMillis())) {
prefsRepo.setUnattendedAccessEnabled(false)
}
}
// ── Internals ────────────────────────────────────────────────────────
/**
@@ -242,11 +242,9 @@ object UnattendedAccessManager {
* returns [WakeOutcome.Success] / [SuccessNoKeyguardChange] /
* [KeyguardBlocked] depending on the dismiss attempt outcome.
*
* The wake lock auto-releases via the platform's 30s timeout — we
* don't release explicitly per call because the bridge command may
* take several gestures to complete and we want one continuous
* wake-up, not a stutter. [release] is provided for the master
* toggle off path.
* The caller must pair each successful acquire with [releaseAfterAction].
* The platform's 30s timeout remains a crash/stall backstop, not the normal
* lifetime. Nested or concurrent commands share the ref-counted lock.
*
* # Compatibility shim
*
@@ -300,6 +298,22 @@ object UnattendedAccessManager {
return requestDismiss()
}
/** Release one command's ownership without disturbing concurrent actions. */
fun releaseAfterAction() {
synchronized(countLock) {
if (lockCount <= 0) return
lockCount -= 1
if (lockCount == 0) {
val lock = wakeLock ?: return
try {
if (lock.isHeld) lock.release()
} catch (t: Throwable) {
Log.w(TAG, "wakeLock.release threw: ${t.message}")
}
}
}
}
/**
* Synchronous keyguard dismiss attempt. Returns:
* - [WakeOutcome.SuccessNoKeyguardChange] when there's no keyguard
@@ -0,0 +1,89 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.ui.theme.AppFont
import com.hermesandroid.relay.ui.theme.AppThemes
import com.hermesandroid.relay.ui.theme.AppearanceShape
import com.hermesandroid.relay.ui.theme.normalizeAccentHex
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
internal data class PersistedAppearance(
val themePreference: String = "auto",
val appThemeId: String = AppThemes.DEFAULT_ID,
val accentHex: String? = null,
val shapeId: String = AppearanceShape.DEFAULT.id,
val appFontId: String = AppFont.DEFAULT.id,
val fontScale: Float = 1.0f,
val customTheme: CustomThemePreset? = null,
)
internal object AppearancePreferences {
val themeKey = stringPreferencesKey("theme")
val appThemeKey = stringPreferencesKey("app_theme")
val accentKey = stringPreferencesKey("appearance_accent")
val shapeKey = stringPreferencesKey("appearance_shape")
val appFontKey = stringPreferencesKey("app_font")
val fontScaleKey = floatPreferencesKey("font_scale")
val customThemesKey = stringPreferencesKey("custom_theme_presets")
private val json = Json { ignoreUnknownKeys = true }
private val serializer = ListSerializer(CustomThemePreset.serializer())
fun state(context: Context): Flow<PersistedAppearance> = context.applicationContext.relayDataStore.data
.map { preferences ->
val customThemes = decodeCustomThemes(preferences[customThemesKey])
val requestedThemeId = preferences[appThemeKey]
val customTheme = CustomThemePreset.idFromAppTheme(requestedThemeId)
?.let { id -> customThemes.firstOrNull { it.id == id } }
PersistedAppearance(
themePreference = preferences[themeKey]
?.takeIf { it == "auto" || it == "light" || it == "dark" }
?: "auto",
appThemeId = customTheme?.appThemeId ?: AppThemes.byId(requestedThemeId).id,
accentHex = normalizeAccentHex(preferences[accentKey]),
shapeId = AppearanceShape.fromId(preferences[shapeKey]).id,
appFontId = AppFont.byId(preferences[appFontKey]).id,
fontScale = (preferences[fontScaleKey] ?: 1.0f).coerceIn(0.85f, 1.3f),
customTheme = customTheme,
)
}
fun shape(context: Context): Flow<String> = state(context).map { it.shapeId }
fun customThemes(context: Context): Flow<List<CustomThemePreset>> =
context.applicationContext.relayDataStore.data.map { decodeCustomThemes(it[customThemesKey]) }
fun decodeCustomThemes(raw: String?): List<CustomThemePreset> = raw
?.let { runCatching { json.decodeFromString(serializer, it) }.getOrNull() }
.orEmpty()
.mapNotNull { it.normalized() }
.distinctBy { it.id }
.take(CustomThemePreset.MAX_PRESETS)
fun encodeCustomThemes(themes: List<CustomThemePreset>): String = json.encodeToString(
serializer,
themes.mapNotNull { it.normalized() }
.distinctBy { it.id }
.take(CustomThemePreset.MAX_PRESETS),
)
fun upsertCustomTheme(
current: List<CustomThemePreset>,
preset: CustomThemePreset,
): List<CustomThemePreset>? {
val normalized = preset.normalized() ?: return null
val safeCurrent = current.mapNotNull { it.normalized() }
.distinctBy { it.id }
.take(CustomThemePreset.MAX_PRESETS)
val existingIndex = safeCurrent.indexOfFirst { it.id == normalized.id }
if (existingIndex < 0 && safeCurrent.size >= CustomThemePreset.MAX_PRESETS) return null
return safeCurrent.toMutableList().apply {
if (existingIndex >= 0) set(existingIndex, normalized) else add(normalized)
}
}
}
@@ -0,0 +1,182 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.util.UUID
/** Connection-scoped Bridge authority. Missing, malformed, or future schemas deny all. */
class BridgeCapabilityPolicyRepository(private val context: Context) {
companion object {
private val KEY_POLICIES = stringPreferencesKey("bridge_capability_policies_v1")
}
@Serializable
private data class StoredPolicies(
val schemaVersion: Int = BridgeCapabilityPolicy.CURRENT_SCHEMA_VERSION,
val installId: String = "",
val byConnection: Map<String, BridgeCapabilityPolicy> = emptyMap(),
)
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val installId: String = localInstallId(context)
fun policy(connectionId: String?): Flow<BridgeCapabilityPolicy> =
context.relayDataStore.data.map { prefs ->
readPolicies(prefs[KEY_POLICIES])[connectionId.normalizedPolicyKey()]
?.takeIf { it.schemaVersion == BridgeCapabilityPolicy.CURRENT_SCHEMA_VERSION }
?: BridgeCapabilityPolicy()
}
suspend fun snapshot(connectionId: String?): BridgeCapabilityPolicy =
policy(connectionId).first()
suspend fun setPermanent(connectionId: String?, capability: BridgeCapability, allowed: Boolean) {
require(!capability.timed) { "Timed capabilities require an expiry" }
update(connectionId) { current ->
current.copy(
permanentGrants = if (allowed) {
current.permanentGrants + capability
} else {
current.permanentGrants - capability
},
)
}
}
suspend fun replacePermanent(
connectionId: String?,
capabilities: Set<BridgeCapability>,
) {
require(capabilities.none { it.timed }) { "Timed capabilities require an expiry" }
update(connectionId) { current -> current.copy(permanentGrants = capabilities) }
}
suspend fun grantTimed(
connectionId: String?,
capability: BridgeCapability,
expiresAtMs: Long,
nowMs: Long = System.currentTimeMillis(),
) {
require(capability.timed) { "Permanent capabilities do not accept an expiry" }
update(connectionId) { current ->
current.copy(
timedExpiriesMs = (
current.timedExpiriesMs.filterValues { it > nowMs }.keys + capability
)
.associateWith { expiresAtMs },
)
}
}
suspend fun revoke(connectionId: String?, capability: BridgeCapability) {
update(connectionId) { current ->
current.copy(
permanentGrants = current.permanentGrants - capability,
timedExpiriesMs = current.timedExpiriesMs - capability,
)
}
}
suspend fun revokeTimed(connectionId: String?) {
update(connectionId) { it.copy(timedExpiriesMs = emptyMap()) }
}
suspend fun replaceTimed(
connectionId: String?,
capabilities: Set<BridgeCapability>,
expiresAtMs: Long,
) {
require(capabilities.all { it.timed }) { "Permanent capabilities cannot be timed" }
update(connectionId) { current ->
current.copy(timedExpiriesMs = capabilities.associateWith { expiresAtMs })
}
}
suspend fun refreshActiveTimed(connectionId: String?, expiresAtMs: Long) {
update(connectionId) { current ->
current.copy(
timedExpiriesMs = current.timedExpiriesMs.mapNotNull { (capability, currentExpiry) ->
when {
currentExpiry == BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS ->
capability to currentExpiry
currentExpiry > System.currentTimeMillis() -> capability to expiresAtMs
else -> null
}
}.toMap(),
)
}
}
suspend fun pruneExpired(connectionId: String?, nowMs: Long) {
update(connectionId) { current ->
current.copy(timedExpiriesMs = current.timedExpiriesMs.filterValues { it > nowMs })
}
}
suspend fun clearConnection(connectionId: String) {
val key = connectionId.normalizedPolicyKey()
context.relayDataStore.edit { prefs ->
val current = readPolicies(prefs[KEY_POLICIES]).toMutableMap()
current.remove(key)
prefs[KEY_POLICIES] = json.encodeToString(
StoredPolicies(installId = installId, byConnection = current),
)
}
}
private suspend fun update(
connectionId: String?,
transform: (BridgeCapabilityPolicy) -> BridgeCapabilityPolicy,
) {
val key = connectionId.normalizedPolicyKey()
context.relayDataStore.edit { prefs ->
val current = readPolicies(prefs[KEY_POLICIES]).toMutableMap()
current[key] = transform(current[key] ?: BridgeCapabilityPolicy())
prefs[KEY_POLICIES] = json.encodeToString(
StoredPolicies(installId = installId, byConnection = current),
)
}
}
private fun readPolicies(raw: String?): Map<String, BridgeCapabilityPolicy> {
if (raw.isNullOrBlank()) return emptyMap()
val stored = runCatching { json.decodeFromString<StoredPolicies>(raw) }.getOrNull()
?: return emptyMap()
if (stored.schemaVersion != BridgeCapabilityPolicy.CURRENT_SCHEMA_VERSION ||
stored.installId != installId
) return emptyMap()
return stored.byConnection
}
private fun String?.normalizedPolicyKey(): String =
this?.trim()?.takeIf { it.isNotEmpty() } ?: "__unbound__"
private fun localInstallId(context: Context): String {
val file = File(context.noBackupFilesDir, "bridge-policy-install-id")
return runCatching {
if (file.isFile) {
file.readText().trim().takeIf { it.isNotEmpty() }
} else {
null
} ?: UUID.randomUUID().toString().also { id ->
file.parentFile?.mkdirs()
file.writeText(id)
}
}.getOrElse {
// An unavailable no-backup fence must never make restored grants
// usable. This process-only value causes every persisted read to
// mismatch and therefore deny.
"unavailable-${UUID.randomUUID()}"
}
}
}
@@ -70,7 +70,11 @@ data class BridgeSettings(
class BridgePreferencesRepository(private val context: Context) {
companion object {
private val KEY_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
// v2 is deliberately separate. Older APKs know only the legacy key
// and therefore remain disabled after a downgrade instead of treating
// the new granular grants as blanket authority.
private val KEY_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled_v2")
private val KEY_LEGACY_MASTER_ENABLED = booleanPreferencesKey("bridge_master_enabled")
private val KEY_ACTIVITY_LOG = stringPreferencesKey("bridge_activity_log")
/** Hard cap on persisted entries. See file-level KDoc for rationale. */
@@ -99,7 +103,10 @@ class BridgePreferencesRepository(private val context: Context) {
}
suspend fun setMasterEnabled(enabled: Boolean) {
context.relayDataStore.edit { it[KEY_MASTER_ENABLED] = enabled }
context.relayDataStore.edit {
it[KEY_MASTER_ENABLED] = enabled
it[KEY_LEGACY_MASTER_ENABLED] = false
}
}
/**
@@ -29,10 +29,9 @@ import kotlinx.serialization.json.Json
* appear in `/tap_text` or `/type` payloads. Seeded with a set of verbs
* that carry irreversible or high-stakes consequences. Editable.
*
* - [autoDisableMinutes] — idle timeout after which the master toggle
* auto-flips to false. Rescheduled on every command so an active agent
* never triggers it; a runaway agent that stops sending commands for
* this long loses bridge access automatically.
* - [autoDisableMinutes] — idle timeout for timed screen inspection and
* control grants. Only accepted timed commands refresh it; permanent
* read/action grants neither expire nor keep screen authority alive.
*
* - [statusOverlayEnabled] — opt-in floating-dot indicator (like the
* screen-recording red dot) that's visible while bridge is active.
@@ -0,0 +1,24 @@
package com.hermesandroid.relay.data
import com.hermesandroid.relay.BuildConfig
/** Immutable provenance embedded into side-by-side review and RC builds. */
object CandidateBuild {
val isCandidate: Boolean get() = BuildConfig.CANDIDATE_BUILD
val kind: String get() = BuildConfig.CANDIDATE_KIND.ifBlank { "review" }
val label: String get() = BuildConfig.CANDIDATE_LABEL.ifBlank { "Local review" }
val sourceRef: String get() = BuildConfig.CANDIDATE_SOURCE_REF.ifBlank { "local" }
val sourceSha: String get() = BuildConfig.CANDIDATE_SOURCE_SHA.ifBlank { "unknown" }
val shortSha: String get() = sourceSha.take(12)
val heading: String
get() = when (kind.lowercase()) {
"rc", "release-candidate" -> "RELEASE CANDIDATE"
else -> "REVIEW CANDIDATE"
}
val provenance: String
get() = listOf(label, shortSha)
.filter { it.isNotBlank() && it != "unknown" }
.joinToString(" · ")
}
@@ -45,12 +45,13 @@ object ConnectionValidation {
kind = "API server URL",
)
/** Relay URL must be ws:// or wss:// with a host. */
fun validateRelayUrl(raw: String): String? = validateUrl(
raw = raw,
allowedSchemes = setOf("ws", "wss"),
kind = "relay URL",
)
/** Relay URL may identify its base, WebSocket route, or health route. */
fun validateRelayUrl(raw: String): String? {
if (raw.isBlank()) return "relay URL can't be blank"
return runCatching { RelayEndpointContract.parse(raw) }
.exceptionOrNull()
?.message
}
/** Dashboard/Gateway URL must be HTTP(S) when configured. */
fun validateDashboardUrl(raw: String): String? = validateOptionalUrl(
@@ -67,11 +68,8 @@ object ConnectionValidation {
)
/** A blank Relay URL means Relay-only power features are not configured. */
fun validateOptionalRelayUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("ws", "wss"),
kind = "relay URL",
)
fun validateOptionalRelayUrl(raw: String): String? =
if (raw.isBlank()) null else validateRelayUrl(raw)
/**
* Validate the independently optional connection surfaces. A connection
@@ -114,7 +112,7 @@ object ConnectionValidation {
val legacyExactMatch =
(apiServerUrl.isNotBlank() || relayUrl.isNotBlank()) &&
urlsEqual(c.apiServerUrl, apiServerUrl) &&
urlsEqual(c.relayUrl, relayUrl)
relayUrlsEqual(c.relayUrl, relayUrl)
val candidateDashboard = dashboardUrl
?.takeIf { it.isNotBlank() }
?: Connection.deriveDefaultDashboardUrl(apiServerUrl)
@@ -133,6 +131,12 @@ object ConnectionValidation {
private fun urlsEqual(first: String, second: String): Boolean =
first.trim().trimEnd('/').equals(second.trim().trimEnd('/'), ignoreCase = true)
private fun relayUrlsEqual(first: String, second: String): Boolean {
val left = RelayEndpointContract.parseOrNull(first)?.webSocketUrl ?: return urlsEqual(first, second)
val right = RelayEndpointContract.parseOrNull(second)?.webSocketUrl ?: return urlsEqual(first, second)
return left.equals(right, ignoreCase = true)
}
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "$kind can't be blank"
@@ -0,0 +1,52 @@
package com.hermesandroid.relay.data
import com.hermesandroid.relay.ui.theme.AppearanceShape
import com.hermesandroid.relay.ui.theme.normalizeAccentHex
import kotlinx.serialization.Serializable
@Serializable
data class CustomThemePreset(
val id: String,
val name: String,
val mode: String,
val backgroundHex: String,
val surfaceHex: String,
val accentHex: String,
val textHex: String,
val shapeId: String = AppearanceShape.DEFAULT.id,
) {
val appThemeId: String get() = "$APP_THEME_PREFIX$id"
val isDark: Boolean get() = mode != MODE_LIGHT
fun normalized(): CustomThemePreset? {
val normalizedId = id.trim().take(64).takeIf { it.matches(ID_PATTERN) } ?: return null
val normalizedName = name.trim().replace(WHITESPACE, " ").take(MAX_NAME_LENGTH)
.takeIf(String::isNotBlank) ?: return null
return copy(
id = normalizedId,
name = normalizedName,
mode = if (mode == MODE_LIGHT) MODE_LIGHT else MODE_DARK,
backgroundHex = normalizeAccentHex(backgroundHex) ?: return null,
surfaceHex = normalizeAccentHex(surfaceHex) ?: return null,
accentHex = normalizeAccentHex(accentHex) ?: return null,
textHex = normalizeAccentHex(textHex) ?: return null,
shapeId = AppearanceShape.fromId(shapeId).id,
)
}
companion object {
const val APP_THEME_PREFIX = "custom:"
const val MODE_LIGHT = "light"
const val MODE_DARK = "dark"
const val MAX_PRESETS = 20
const val MAX_NAME_LENGTH = 24
private val ID_PATTERN = Regex("[A-Za-z0-9_-]+")
private val WHITESPACE = Regex("\\s+")
fun idFromAppTheme(appThemeId: String?): String? = appThemeId
?.takeIf { it.startsWith(APP_THEME_PREFIX) }
?.removePrefix(APP_THEME_PREFIX)
?.takeIf(String::isNotBlank)
}
}
@@ -139,5 +139,5 @@ object BuildFlavor {
GOOGLE_PLAY -> "Google Play"
SIDELOAD -> "Sideload"
else -> current.ifBlank { "Unknown" }
}
} + if (CandidateBuild.isCandidate) " Candidate" else ""
}
@@ -0,0 +1,86 @@
package com.hermesandroid.relay.data
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URI
/** Canonical Relay routes derived from one operator- or pairing-supplied URL. */
data class RelayEndpoints(
val httpBaseUrl: String,
val webSocketBaseUrl: String,
val webSocketUrl: String,
val healthUrl: String,
)
/**
* Parses the accepted Relay URL forms and derives every route from one base.
*
* The input may identify the route base, its terminal `/ws` endpoint, or its
* terminal `/health` endpoint, using either HTTP(S) or WS(S). The final
* `ws`/`health` segment is removed before both canonical routes are rebuilt,
* which makes the operation idempotent and preserves reverse-proxy prefixes.
*/
object RelayEndpointContract {
private val encodedAmbiguousPathByte = Regex("%(?:2e|2f|5c)", RegexOption.IGNORE_CASE)
fun parseOrNull(raw: String?): RelayEndpoints? = runCatching { parse(raw) }.getOrNull()
fun parse(raw: String?): RelayEndpoints {
val input = raw?.trim()?.takeIf { it.isNotEmpty() }
?: throw IllegalArgumentException("Relay URL is empty")
val uri = runCatching { URI(input) }.getOrElse {
throw IllegalArgumentException("Relay URL is malformed")
}
val sourceScheme = uri.scheme?.lowercase()
val secure = when (sourceScheme) {
"https", "wss" -> true
"http", "ws" -> false
else -> throw IllegalArgumentException("Relay URL must use HTTP(S) or WS(S)")
}
if (uri.host.isNullOrBlank() || uri.rawAuthority.isNullOrBlank() || uri.isOpaque) {
throw IllegalArgumentException("Relay URL has no valid host")
}
if (uri.rawUserInfo != null) {
throw IllegalArgumentException("Relay URL must not contain user info")
}
if (uri.rawQuery != null || uri.rawFragment != null) {
throw IllegalArgumentException("Relay URL must not contain a query or fragment")
}
if (uri.port == 0 || uri.port > 65_535) {
throw IllegalArgumentException("Relay URL has an invalid port")
}
val rawPath = uri.rawPath.orEmpty()
if ('\\' in rawPath || "//" in rawPath || encodedAmbiguousPathByte.containsMatchIn(rawPath)) {
throw IllegalArgumentException("Relay URL contains an ambiguous path")
}
val trimmedPath = rawPath.trimEnd('/')
val pathSegments = trimmedPath.split('/').filter { it.isNotEmpty() }
if (pathSegments.any { it == "." || it == ".." }) {
throw IllegalArgumentException("Relay URL contains a relative path segment")
}
val baseSegments = if (pathSegments.lastOrNull() in setOf("ws", "health")) {
pathSegments.dropLast(1)
} else {
pathSegments
}
val basePath = baseSegments.joinToString(separator = "/", prefix = "/")
.takeUnless { it == "/" }
.orEmpty()
val httpScheme = if (secure) "https" else "http"
val webSocketScheme = if (secure) "wss" else "ws"
val httpBase = "$httpScheme://${uri.rawAuthority}$basePath"
val webSocketBase = "$webSocketScheme://${uri.rawAuthority}$basePath"
val webSocket = "$webSocketBase/ws"
val health = "$httpBase/health"
if (httpBase.toHttpUrlOrNull() == null || health.toHttpUrlOrNull() == null) {
throw IllegalArgumentException("Relay URL is malformed")
}
return RelayEndpoints(
httpBaseUrl = httpBase,
webSocketBaseUrl = webSocketBase,
webSocketUrl = webSocket,
healthUrl = health,
)
}
}
@@ -258,6 +258,7 @@ class BridgeCommandHandler(
private val pendingActivities =
java.util.concurrent.ConcurrentHashMap<String, PendingActivity>()
private val unattendedWakeRequests = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
// === END v0.4.1 polish ===
private val json = Json {
@@ -302,6 +303,8 @@ class BridgeCommandHandler(
put("error", t.message ?: "unknown executor error")
}
)
} finally {
releaseUnattendedWake(requestId)
}
}
}
@@ -396,6 +399,8 @@ class BridgeCommandHandler(
errorCode = "dispatch_exception",
resultJson = null,
)
} finally {
releaseUnattendedWake(requestId)
}
val resultJson = sink.get()
@@ -421,6 +426,54 @@ class BridgeCommandHandler(
method: String,
body: JsonObject,
) {
// Resolve path + method through the closed capability registry before
// any wake, confirmation, event read, executor, or run-tracker effect.
val registeredAuthority =
com.hermesandroid.relay.bridge.BridgeCommandRegistry.resolve(path, method)
val capabilityAuthorization = when {
registeredAuthority == null -> BridgeSafetyManager.CapabilityAuthorization(
allowed = false,
errorCode = "unknown_bridge_command",
)
!BuildFlavor.isSideload && registeredAuthority.grant !=
com.hermesandroid.relay.bridge.BridgeCapabilityGrant.EXEMPT ->
BridgeSafetyManager.CapabilityAuthorization(
allowed = false,
authority = registeredAuthority,
errorCode = "device_control_sideload_only",
)
registeredAuthority.grant ==
com.hermesandroid.relay.bridge.BridgeCapabilityGrant.EXEMPT ->
BridgeSafetyManager.CapabilityAuthorization(true, registeredAuthority)
else -> safetyManager?.authorizeCapability(path, method)
?: BridgeSafetyManager.CapabilityAuthorization(
allowed = false,
authority = registeredAuthority,
errorCode = "bridge_policy_unavailable",
)
}
if (!capabilityAuthorization.allowed) {
return respond(
requestId,
403,
buildJsonObject {
put(
"error",
if (capabilityAuthorization.errorCode == "device_control_sideload_only") {
"Device Control is not included in the Google Play build."
} else {
"Bridge capability is not granted for this connection."
},
)
put("error_code", capabilityAuthorization.errorCode ?: "bridge_capability_denied")
capabilityAuthorization.authority?.capability?.let {
put("capability", it.wireId)
}
put("required_action", "Review Bridge > Safety & capabilities on the phone")
},
)
}
// === v0.4.1 polish: keep auto-return idle timer alive ===
// Any non-polling bridge command during a run is evidence the
// agent is still working — reset BridgeRunTracker's idle timer
@@ -475,44 +528,6 @@ class BridgeCommandHandler(
return
}
// === PHASE3-event-stream: B1 android_events read-only polling ===
// /events is a read-only peek at the EventStore ring buffer. The
// buffer lives in our own process so there's no safety gate —
// the agent already opted into streaming via /events/stream
// which IS gated. This mirrors the /ping early-return path so
// polling works even when the service is transiently unbound.
if (path == "/events") {
val limitRaw = body["limit"]?.jsonPrimitive?.content?.toIntOrNull() ?: 50
val limit = limitRaw.coerceIn(1, EventStore.MAX_ENTRIES)
val since = body["since"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
val entries = EventStore.recent(limit = limit, since = since)
val arr: JsonArray = buildJsonArray {
for (e in entries) {
add(
buildJsonObject {
put("timestamp", e.timestamp)
put("event_type", e.eventType)
e.packageName?.let { put("package_name", it) }
e.className?.let { put("class_name", it) }
e.text?.let { put("text", it) }
e.contentDescription?.let { put("content_description", it) }
put("source", e.source)
}
)
}
}
respond(
requestId, 200,
buildJsonObject {
put("entries", arr)
put("count", entries.size)
put("streaming", EventStore.isStreaming)
}
)
return
}
// === END PHASE3-event-stream ===
// /setup exists on the relay as a legacy bridge HTTP route, but
// android_setup() in plugin/tools/android_tool.py is host-side
// only (it just writes ANDROID_BRIDGE_TOKEN to ~/.hermes/.env)
@@ -576,10 +591,7 @@ class BridgeCommandHandler(
}
)
if (!service.isMasterEnabled() &&
path != "/current_app" &&
path != "/return_to_hermes"
) {
if (!service.isMasterEnabled()) {
// Crystal-clear error text + structured error_code. Bailey hit
// 2026-04-15: when the phone was paired + a11y granted but
// master toggle flipped off, the agent read the shorter
@@ -649,9 +661,13 @@ class BridgeCommandHandler(
}
}
// Reschedule the idle auto-disable timer on every accepted
// command. Safe to call even when no timer is currently armed.
safetyManager?.rescheduleAutoDisable()
// Permanent capabilities never keep screen control armed. Only an
// accepted timed inspection/control command refreshes the timer.
if (capabilityAuthorization.authority?.grant ==
com.hermesandroid.relay.bridge.BridgeCapabilityGrant.TIMED
) {
safetyManager?.rescheduleAutoDisable()
}
// === END PHASE3-safety-rails ===
// === v0.4.1 unattended-access wake + keyguard dismiss ===
@@ -673,6 +689,9 @@ class BridgeCommandHandler(
if (!isReadOnlyRoute) {
val outcome = runCatching { UnattendedAccessManager.acquireForAction() }
.getOrDefault(UnattendedAccessManager.WakeOutcome.Disabled)
if (outcome != UnattendedAccessManager.WakeOutcome.Disabled) {
unattendedWakeRequests += requestId
}
if (outcome == UnattendedAccessManager.WakeOutcome.KeyguardBlocked) {
respond(
requestId, 423,
@@ -705,6 +724,30 @@ class BridgeCommandHandler(
val executor = service.actionExecutor
when (path) {
"/events" -> {
val limitRaw = body["limit"]?.jsonPrimitive?.content?.toIntOrNull() ?: 50
val limit = limitRaw.coerceIn(1, EventStore.MAX_ENTRIES)
val since = body["since"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
val entries = EventStore.recent(limit = limit, since = since)
val arr: JsonArray = buildJsonArray {
for (e in entries) {
add(buildJsonObject {
put("timestamp", e.timestamp)
put("event_type", e.eventType)
e.packageName?.let { put("package_name", it) }
e.className?.let { put("class_name", it) }
e.text?.let { put("text", it) }
e.contentDescription?.let { put("content_description", it) }
put("source", e.source)
})
}
}
respond(requestId, 200, buildJsonObject {
put("entries", arr)
put("count", entries.size)
put("streaming", EventStore.isStreaming)
})
}
"/current_app" -> respond(
requestId, 200,
buildJsonObject {
@@ -2417,6 +2460,12 @@ class BridgeCommandHandler(
}
multiplexer.send(envelope)
}
private fun releaseUnattendedWake(requestId: String) {
if (unattendedWakeRequests.remove(requestId)) {
UnattendedAccessManager.releaseAfterAction()
}
}
}
// LocalDispatchResult moved to network.shared (ADR 34 fence): it is a passive
@@ -10,6 +10,7 @@ import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpointContract
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.network.shared.pluginProxyRoutesOrNull
@@ -453,7 +454,22 @@ class ConnectionManager(
replaceReason: String = "Relay socket replaced",
preserveReconnectBackoff: Boolean = false,
) {
val isInsecure = url.startsWith("ws://") && !url.startsWith("wss://")
val endpoints = RelayEndpointContract.parseOrNull(url)
if (endpoints == null) {
Log.e(TAG, "Invalid Relay URL")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
detail = "Malformed or unsafe Relay URL",
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Use a Relay URL with no credentials, query, or fragment.",
)
return
}
val normalized = endpoints.webSocketUrl
val isInsecure = normalized.startsWith("ws://", ignoreCase = true)
if (isInsecure && !_insecureMode.value) {
Log.e(TAG, "Blocked ws:// connection — insecure mode is disabled. Use wss:// or enable insecure mode in Settings.")
DiagnosticsLog.record(
@@ -467,25 +483,6 @@ class ConnectionManager(
)
return
}
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
Log.e(TAG, "Invalid URL scheme — must start with ws:// or wss://")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
detail = "URL must start with ws:// or wss://",
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Edit or re-pair the Relay route with a ws:// or wss:// URL.",
)
return
}
// Normalize: append /ws if the user gave us a bare host:port with no
// path. The relay routes the WebSocket handler at /ws; a bare URL
// 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(),
@@ -888,21 +885,8 @@ class ConnectionManager(
}
}
private fun normalizeRelayUrl(url: String): String {
// Strip scheme to reason about the path portion cheaply.
val schemeEnd = url.indexOf("://")
if (schemeEnd < 0) return url
val afterScheme = url.substring(schemeEnd + 3)
val pathStart = afterScheme.indexOf('/')
return if (pathStart < 0) {
// No path at all — append /ws
"$url/ws"
} else {
val path = afterScheme.substring(pathStart)
// Empty or root path — append ws
if (path == "/" || path.isEmpty()) "${url.trimEnd('/')}/ws" else url
}
}
private fun normalizeRelayUrl(url: String): String =
RelayEndpointContract.parseOrNull(url)?.webSocketUrl ?: url
fun disconnect() {
shouldReconnect = false
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.data.RelayEndpointContract
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@@ -59,6 +60,9 @@ class RelayHttpClient(
private val context: Context? = null,
) {
private fun relayHttpBaseOrNull(url: String): String? =
RelayEndpointContract.parseOrNull(url)?.httpBaseUrl
companion object {
private const val TAG = "RelayHttpClient"
const val MAX_MODEL_CAPABILITY_ROWS = 64
@@ -180,10 +184,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
.addQueryParameter("profile", profile)
@@ -247,10 +249,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = "$httpBase/media/$token".toHttpUrlOrNull()
?: return@withContext Result.failure(
@@ -344,10 +344,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
// Build the URL via OkHttp's HttpUrl builder so query-param encoding
// handles paths with slashes, spaces, and non-ASCII characters
@@ -442,10 +440,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val profile = profileName?.trim()?.ifBlank { null } ?: "default"
val url = try {
"$httpBase/api/profiles".toHttpUrl().newBuilder()
@@ -543,10 +539,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/context/injected".toHttpUrl()
@@ -635,10 +629,8 @@ class RelayHttpClient(
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 httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/phone/threads".toHttpUrl()
} catch (e: IllegalArgumentException) {
@@ -754,10 +746,8 @@ class RelayHttpClient(
if (relayUrl.isEmpty() || token.isNullOrBlank()) {
return@withContext Result.failure(IllegalStateException("Relay is not configured and paired"))
}
val base = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val base = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try { "$base/relay/info".toHttpUrl() } catch (e: IllegalArgumentException) {
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
}
@@ -802,10 +792,8 @@ class RelayHttpClient(
val relayUrl = relayUrlProvider()?.trim().orEmpty()
val token = sessionTokenProvider()
if (relayUrl.isEmpty() || token.isNullOrBlank()) return@withContext Result.success(null)
val base = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val base = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.success(null)
val url = runCatching { "$base/relay/model-capabilities".toHttpUrl() }.getOrElse {
return@withContext Result.success(null)
}
@@ -858,10 +846,8 @@ class RelayHttpClient(
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 httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/relay/update-check".toHttpUrl()
} catch (e: IllegalArgumentException) {
@@ -944,10 +930,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = "$httpBase/sessions".toHttpUrlOrNull()
?: return@withContext Result.failure(
@@ -1027,10 +1011,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/sessions/".toHttpUrl().newBuilder()
@@ -1121,10 +1103,8 @@ class RelayHttpClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/sessions/".toHttpUrl().newBuilder()
@@ -1241,28 +1221,23 @@ class RelayHttpClient(
)
}
val httpBase = trimmed
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val startedAtMs = System.currentTimeMillis()
val url = try {
"$httpBase/health".toHttpUrl()
} catch (e: IllegalArgumentException) {
val endpoints = RelayEndpointContract.parseOrNull(trimmed)
if (endpoints == null) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
detail = e.message,
detail = "Malformed or unsafe Relay URL",
operation = operation,
configuredUrl = relayUrl,
suggestion = "Enter a Relay URL beginning with ws:// or wss://.",
suggestion = "Enter a Relay URL with no credentials, query, or fragment.",
)
return@withContext Result.failure(
IOException("Invalid relay URL: ${e.message}")
IOException("Invalid relay URL")
)
}
val url = endpoints.healthUrl.toHttpUrl()
// Fast-timeout client — we don't want Save & Test to hang the UI
// for 10 seconds on a dead URL.
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.data.ProfileSoulUpdateResponse
import com.hermesandroid.relay.data.ProfileMemoryUpdateResponse
import com.hermesandroid.relay.data.LegacyProfileInspectorClient
import com.hermesandroid.relay.data.RelaySkillToggleResult
import com.hermesandroid.relay.data.RelayEndpointContract
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
@@ -52,6 +53,9 @@ class RelayProfileInspectorClient(
private val sessionTokenProvider: suspend () -> String?,
) : LegacyProfileInspectorClient {
private fun relayHttpBaseOrNull(url: String): String? =
RelayEndpointContract.parseOrNull(url)?.httpBaseUrl
companion object {
private const val TAG = "RelayProfileInspector"
@@ -191,10 +195,8 @@ class RelayProfileInspectorClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val encodedName = URLEncoder.encode(profileName, "UTF-8").replace("+", "%20")
@@ -289,10 +291,8 @@ class RelayProfileInspectorClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/api/skills/toggle".toHttpUrl()
@@ -355,10 +355,8 @@ class RelayProfileInspectorClient(
if (relayUrl.isEmpty()) return@withContext false
val sessionToken = sessionTokenProvider() ?: return@withContext false
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext false
val url = try {
"$httpBase/api/skills/toggle".toHttpUrl()
@@ -445,10 +443,8 @@ class RelayProfileInspectorClient(
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
// Percent-encode the profile name for splicing into the path —
// profile names are typically ASCII identifiers but nothing
@@ -6,6 +6,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.RelayEndpointContract
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -2616,13 +2617,7 @@ class RelayVoiceClient(
private fun resolveHttpBase(): String? {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) return null
val normalized = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
// Reject a malformed base up front so callers' url("$base/…") can't throw
// IllegalArgumentException on the IO dispatcher (relay half of #131).
return if (normalized.toHttpUrlOrNull() != null) normalized else null
return RelayEndpointContract.parseOrNull(relayUrl)?.httpBaseUrl
}
private fun resolveWebSocketBase(): String? {
@@ -2633,10 +2628,7 @@ class RelayVoiceClient(
private fun toWebSocketBase(relayUrl: String?): String? {
val trimmed = relayUrl?.trim().orEmpty()
if (trimmed.isEmpty()) return null
return trimmed
.replace(Regex("^https://", RegexOption.IGNORE_CASE), "wss://")
.replace(Regex("^http://", RegexOption.IGNORE_CASE), "ws://")
.trimEnd('/')
return RelayEndpointContract.parseOrNull(trimmed)?.webSocketBaseUrl
}
private suspend fun resolveBearerToken(): String? {
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpointContract
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
@@ -548,20 +549,13 @@ class EndpointResolver(
private fun relayProbeTarget(candidate: EndpointCandidate): ProbeTarget? {
candidate.relay?.url
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?.let { relayUrl ->
val httpBase = when {
relayUrl.startsWith("ws://", ignoreCase = true) ->
"http://${relayUrl.substringAfter("://")}"
relayUrl.startsWith("wss://", ignoreCase = true) ->
"https://${relayUrl.substringAfter("://")}"
else -> return null
}
val endpoints = RelayEndpointContract.parseOrNull(relayUrl) ?: return null
return ProbeTarget(
baseUrl = relayUrl,
requestUrl = "$httpBase/health",
path = "/health",
baseUrl = endpoints.webSocketUrl,
requestUrl = endpoints.healthUrl,
path = endpoints.healthUrl.toHttpUrlOrNull()?.encodedPath ?: return null,
)
}
@@ -3345,6 +3345,7 @@ class ChatHandler {
.filterNot { msg ->
msg.matchesIdentity(messageId) &&
msg.role == MessageRole.ASSISTANT &&
msg.badges.isEmpty() &&
msg.toolCalls.isEmpty() &&
msg.backgroundTask == null &&
msg.thinkingContent.isBlank() &&
@@ -703,7 +703,9 @@ class GatewayChatClient(
if (!turn.cancelled) {
turn.disarmWatchdog()
turn.tracer.done("resume-rejected")
turn.callbacks.onError(e.message ?: "Hermes could not resume this session")
turn.callbacks.onResumeFailure(
e.message ?: "Hermes could not resume this session",
)
}
} catch (e: GatewayAttachmentPreflightException) {
if (activeTurn === turn) activeTurn = null
@@ -2477,20 +2479,29 @@ class GatewayChatClient(
}
val model = info.stringField("model")?.takeIf { it.isNotBlank() }
val provider = info.stringField("provider")?.takeIf { it.isNotBlank() }
model?.let { _serverModel.value = it }
provider?.let { _serverProvider.value = it }
if (model != null && provider != null) {
_serverModelIdentity.value = GatewayModelIdentity(model = model, provider = provider)
if (info.containsKey("model")) {
// session.info/session.resume is an identity snapshot. An absent
// provider must clear the prior session's provider instead of
// making a resumed turn look coherently bound to stale state.
_serverModel.value = model
_serverProvider.value = provider
_serverModelIdentity.value = if (model != null && provider != null) {
GatewayModelIdentity(model = model, provider = provider)
} else {
null
}
}
// reasoning effort: ignore "" (reasoning disabled) so it can't clobber
// the chip; display mode is config.get-only, not here.
val reasoningEffort = info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
reasoningEffort?.let { _serverReasoningEffort.value = it }
if (model != null && provider != null && reasoningEffort != null) {
_serverReasoningIdentity.value = GatewayReasoningIdentity(
if (info.containsKey("model")) {
_serverReasoningIdentity.value = if (
model != null && provider != null && reasoningEffort != null
) GatewayReasoningIdentity(
identity = GatewayModelIdentity(model = model, provider = provider),
effort = reasoningEffort,
)
) else null
}
// credential_warning: present only when the provider key is missing/
// invalid. ABSENT means healthy — clear to null so it self-resolves.
@@ -2643,14 +2654,15 @@ class GatewayChatClient(
)
val result = resumed.getOrNull()
val resumeError = resumed.exceptionOrNull()
if ((resumeError as? GatewayRpcException)?.code == 4130) {
throw GatewayAuthoritativeResumeException(
resumeError.message ?: "Session transcript exceeds the configured resume limit",
)
}
val live = result?.stringField("session_id")
if (live != null) {
requireConfirmedSessionProfile(result, requestedProfile)
try {
requireConfirmedSessionProfile(result, requestedProfile)
} catch (error: GatewayPreflightException) {
throw GatewayAuthoritativeResumeException(
error.message ?: "Hermes resumed this session in a different profile",
)
}
liveSessionId = live
storedSessionId = requestedStoredId
liveSessionProfile = requestedProfile
@@ -2658,10 +2670,8 @@ class GatewayChatClient(
applySessionResultInfo(result)
return
}
Log.w(
TAG,
"session.resume failed for $requestedStoredId — creating fresh " +
"(${resumed.exceptionOrNull()?.message})",
throw GatewayAuthoritativeResumeException(
resumeError?.message ?: "Hermes could not resume this session",
)
}
@@ -2940,6 +2950,18 @@ class GatewayChatClient(
// yolo / fast / usage) — shared with the session.resume result via
// applySessionInfo so both paths stay in lockstep.
payload?.let { applySessionInfo(it) }
val ownedTurn = activeTurn
if (!eventSessionId.isNullOrBlank() &&
eventSessionId == liveSessionId &&
ownedTurn?.settleFromAuthoritativeSessionState(
running = payload?.booleanField("running"),
source = "session.info",
) == true
) {
if (activeTurn === ownedTurn) activeTurn = null
if (!AppForegroundTracker.isForeground.value) scheduleBackgroundClose()
return
}
}
// Foreign-session events (another client's chat on the same gateway) are not ours.
@@ -3180,7 +3202,13 @@ class GatewayChatClient(
)
when {
activated.isSuccess -> {
activated.getOrNull()?.let(::applySessionResultInfo)
activated.getOrNull()?.let { result ->
applySessionResultInfo(result)
turn.settleFromAuthoritativeSessionState(
running = result.booleanField("running"),
source = "session.activate",
)
}
true
}
activated.exceptionOrNull().isMethodNotFound() -> {
@@ -3463,6 +3491,15 @@ class GatewayChatClient(
@Volatile
private var reconcileRequired = false
/**
* A replacement WebSocket does not replay a `message.complete` frame
* emitted while the old socket was detached. This is distinct from
* cancellation: the server finished the turn and authoritative history
* must settle it without routing through a transport error.
*/
@Volatile
private var settledWithoutTerminalFrame = false
/**
* True if this socket loss should be answered with a rejoin attempt.
* Mark reconciliation before reconnecting so a terminal event arriving
@@ -3488,7 +3525,7 @@ class GatewayChatClient(
private var watchdog: Job? = null
val ended: Boolean get() = mapper.turnEnded || cancelled
val ended: Boolean get() = mapper.turnEnded || cancelled || settledWithoutTerminalFrame
/**
* True once any turn-scoped event has arrived — proof the server
@@ -3517,6 +3554,7 @@ class GatewayChatClient(
}
private fun processEvent(type: String, payload: JsonObject?) {
if (settledWithoutTerminalFrame) return
if (type != "session.info") started = true
tracer.mark("ttfe")
if (type == "message.delta" || type == "reasoning.delta" || type == "thinking.delta") {
@@ -3540,6 +3578,33 @@ class GatewayChatClient(
}
}
/**
* Use upstream's session state as a terminal backstop only after this
* exact turn has proved it went live. A pre-start `running=false`
* heartbeat can race `prompt.submit` and is not a completion boundary.
*/
fun settleFromAuthoritativeSessionState(running: Boolean?, source: String): Boolean {
if (running != false || !started) return false
val settled = synchronized(deferredEventLock) {
if (ended) {
false
} else {
settledWithoutTerminalFrame = true
reconcileRequired = true
true
}
}
if (!settled) return false
disarmWatchdog()
Log.i(TAG, "Gateway turn settled from $source after missing terminal frame")
callbacks.onReconcileRequired()
callbacks.onComplete()
tracer.done("history-reconcile")
handoffQueuedSuccessor()
return true
}
/**
* Preserve a queued prompt reported beside an in-flight recovery as a
* distinct next turn. Its mapper starts deferred so events that race
@@ -3790,6 +3855,8 @@ class GatewayChatClient(
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
onResumeFailure = { v -> callbackDispatcher { callbacks.onResumeFailure(v) } },
onFailure = { v -> callbackDispatcher { callbacks.onFailure(v) } },
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
// onStatusUpdate a default no-op, so omitting it here silently swallows
// EVERY gateway status line — the ❌ terminal-error lifecycle update
@@ -260,6 +260,13 @@ class GatewayEventMapper(
}
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
if (failed) {
callbacks.onFailure(
GatewayTurnFailure(
error = error?.takeIf { it.isNotBlank() }
?: text.orEmpty().ifBlank { "Turn failed" },
recoverable = payload.boolean("recoverable") == true,
),
)
callbacks.onStatusUpdate(
ERROR_STATUS_KIND,
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
@@ -8,6 +8,7 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.content.res.Configuration
import android.os.Build
import android.os.IBinder
import android.util.Log
@@ -157,6 +158,15 @@ class GatewayKeepAliveService : Service() {
super.onDestroy()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// Per-app locale changes recreate MainActivity but intentionally keep
// this foreground service (and its Gateway socket) alive. Re-post the
// existing notification so its localized title/body follow the new
// application resources without restarting either owner.
startForegroundNotification()
}
private fun applyState(
persistent: Boolean,
turns: ActiveTurnKeepAliveRegistry.Snapshot,
@@ -550,6 +550,12 @@ data class GatewaySessionModel(
val fast: Boolean? = null,
)
/** Structured terminal failure carried by Gateway `message.complete`. */
data class GatewayTurnFailure(
val error: String,
val recoverable: Boolean,
)
/** Result of the gateway `config.get {key:"reasoning"}` RPC. */
data class GatewayReasoningSettings(
val effort: String,
@@ -618,6 +624,10 @@ class GatewayTurnCallbacks(
val onInteractionRequest: (GatewayAsk) -> Unit,
/** Server declared a pending interaction expired; clear only the matching card. */
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
/** Existing durable session could not be rebound; no prompt was submitted. */
val onResumeFailure: (String) -> Unit = { _ -> },
/** Terminal `message.complete {status:"error"}` without prose inspection. */
val onFailure: (GatewayTurnFailure) -> Unit = { _ -> },
/**
* Gateway `status.update` lifecycle line — model fallback, retries, and
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
@@ -68,6 +68,7 @@ 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.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.components.rememberAccessibleMotionState
sealed interface PluginInteraction {
@@ -340,7 +341,7 @@ private fun PluginProgress(
private fun DefaultPluginAsset(contentDescription: String, modifier: Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
@@ -142,8 +142,8 @@ internal class HermesRuntimeBinder(
voiceHandoffReporter = connection::recordVoiceHandoff,
bargeInPreferences = BargeInPreferencesRepository(application),
vadEngineFactory = { VadEngine(application) },
bargeInListenerFactory = { vad, audioSessionIdProvider ->
BargeInListener.create(application, vad, audioSessionIdProvider)
bargeInListenerFactory = { vad ->
BargeInListener.create(application, vad)
},
)
@@ -175,15 +175,26 @@ internal class HermesRuntimeBinder(
}
chat.setDisplayProfileProvider { connection.effectiveDisplayProfile.value }
chat.setDisplayAliasProvider { connection.profileDisplayAlias.value }
chat.setProfileSessionLister { connection.listProfileScopedSessions() }
chat.setLockedProfileNameProvider { connection.lockedProfileName.value }
chat.setProfileSelectionHandler { profile ->
if (!connection.isProfileSelectionAllowed(profile?.name)) {
false
} else {
connection.selectProfile(profile)
true
}
}
chat.setProfileSessionLister { profileName ->
connection.listProfileScopedSessions(profileName)
}
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
connection.loadProfileScopedMessages(profileName, sessionId, mode)
}
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
chat.profileSessionDeleter = connection::deleteProfileScopedSession
chat.profileSessionRenamer = connection::renameProfileScopedSession
chat.profileSessionPinner = connection::setProfileScopedSessionPinned
chat.profileSessionArchiver = connection::setProfileScopedSessionArchived
chat.profileSessionDeleter = connection::deleteSession
chat.profileSessionRenamer = connection::renameSession
chat.profileSessionPinner = connection::setSessionPinned
chat.profileSessionArchiver = connection::setSessionArchived
chat.onSessionChanged = connection::saveLastSessionId
chat.setDemoModeWiring(
isDemo = { connection.isDemoMode.value },
@@ -283,20 +294,29 @@ internal class HermesRuntimeBinder(
) { ready, connectionId, profileName, sessionId ->
ProfileContextInputs(ready, connectionId, profileName, sessionId)
}
combine(contextInputs, connection.profileSelectionSettled) { inputs, settled ->
inputs.copy(profileSelectionSettled = settled)
combine(
contextInputs,
connection.profileSelectionSettled,
connection.lockedProfileName,
) { inputs, settled, lockedProfileName ->
inputs.copy(
profileSelectionSettled = settled,
profileLocked = lockedProfileName != null,
)
}.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,
val contextKey = AgentDisplay.profileContextKey(
connectionId = inputs.connectionId,
profileName = inputs.profileName,
)
if (inputs.profileLocked) {
chat.switchProfileContext(contextKey, inputs.sessionId)
} else {
chat.reconcileProfileContext(contextKey, inputs.sessionId)
}
chat.refreshSessions()
profileContextReady.value = true
}
@@ -438,6 +458,7 @@ internal class HermesRuntimeBinder(
val profileName: String?,
val sessionId: String?,
val profileSelectionSettled: Boolean = false,
val profileLocked: Boolean = false,
)
private companion object {
@@ -49,6 +49,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.Lifecycle
@@ -75,6 +77,7 @@ import androidx.navigation.navArgument
import com.hermesandroid.relay.R
import com.hermesandroid.relay.HermesRelayApp
import com.hermesandroid.relay.ui.components.CrashReportGate
import com.hermesandroid.relay.ui.components.CandidateBuildBanner
import com.hermesandroid.relay.ui.components.DemoModeBanner
import com.hermesandroid.relay.ui.components.DemoUnavailableContent
import com.hermesandroid.relay.ui.components.MessageBannerHost
@@ -125,6 +128,7 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BridgePreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.CandidateBuild
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.FeatureFlags
@@ -141,6 +145,7 @@ import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
import com.hermesandroid.relay.ui.screens.AboutScreen
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
import com.hermesandroid.relay.ui.screens.CustomPetGuideScreen
import com.hermesandroid.relay.ui.screens.PetdexBrowseScreen
import com.hermesandroid.relay.ui.screens.BridgeCoreScreen
@@ -211,6 +216,30 @@ suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
internal fun hasConfiguredStartupChat(connection: Connection?): Boolean =
connection?.capabilities?.chatConfigured == true
/**
* A Pair route is allowed to start once its target connection is active and
* persisted. Duplicate Renew may authorize one explicit existing-connection
* handoff; arbitrary active-id mismatches remain blocked so restored state
* cannot bypass connection/auth hydration.
*/
internal fun resolvePairSetupReady(
storeHydrated: Boolean,
connectionId: String?,
authorizedHandoffId: String?,
activeConnectionId: String?,
connectionIds: Set<String>,
): Boolean = connectionId == null || storeHydrated && activeConnectionId != null &&
activeConnectionId in connectionIds &&
(activeConnectionId == connectionId || activeConnectionId == authorizedHandoffId)
/** A user retry replaces even a still-active preparation attempt. */
internal fun shouldStartPairPreparation(hasActiveJob: Boolean, retryRequested: Boolean): Boolean =
retryRequested || !hasActiveJob
/** A replaced/canceled attempt must not evict the newer job from the route map. */
internal fun isCurrentPairPreparation(mappedJob: Any?, completingJob: Any): Boolean =
mappedJob === completingJob
/**
* App-root chat health derived only from the two transports that can carry a
* conversation. Optional Relay state is deliberately absent.
@@ -477,6 +506,7 @@ sealed class Screen(
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
data object CustomTheme : Screen("settings/appearance/custom-theme", "Custom", Icons.Filled.Settings)
data object PetdexBrowse : Screen("settings/appearance/petdex", "Petdex", Icons.Filled.Settings)
data object CustomPetGuide : Screen("settings/appearance/custom-pet", "Create a pet", Icons.Filled.Settings)
data object Analytics : Screen("settings/analytics", "Analytics", Icons.Filled.Settings)
@@ -563,6 +593,26 @@ fun RelayApp() {
val pendingAddConnectionJobs = remember {
mutableMapOf<String, kotlinx.coroutines.Job>()
}
val prepareAddConnection: (String, Boolean) -> Unit = { id, retryRequested ->
val existingJob = pendingAddConnectionJobs[id]
if (shouldStartPairPreparation(existingJob?.isActive == true, retryRequested)) {
if (retryRequested) {
pendingAddConnectionJobs.remove(id)?.cancel()
}
val job = connectionSwitchScope.launch(
start = kotlinx.coroutines.CoroutineStart.LAZY,
) {
connectionViewModel.beginAddConnection(preAllocatedId = id)
}
job.invokeOnCompletion {
if (isCurrentPairPreparation(pendingAddConnectionJobs[id], job)) {
pendingAddConnectionJobs.remove(id)
}
}
pendingAddConnectionJobs[id] = job
job.start()
}
}
// One-time init: the terminal channel ViewModel registers with the shared
// multiplexer and observes the relay connection state so it can attach/
@@ -704,6 +754,7 @@ fun RelayApp() {
val appFontId by connectionViewModel.appFont.collectAsState()
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
// publish it + the full available set so every MorphingSphere picks it up
@@ -864,6 +915,7 @@ fun RelayApp() {
appFontId = appFontId,
accentHex = appearanceAccent,
shapeId = appearanceShape,
customTheme = activeCustomTheme,
) {
// Surface a crash report from a previous session, if any. Renders a
// platform Dialog (own window) so tree position is z-order-agnostic;
@@ -1090,19 +1142,44 @@ fun RelayApp() {
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val appReady by connectionViewModel.isReady.collectAsState()
val initialChatSettled by chatViewModel.initialChatSettled.collectAsState()
val shareConnectionId by rememberUpdatedState(
activeConnection?.id?.takeIf(String::isNotBlank) ?: "offline"
)
val shareProfileId by rememberUpdatedState(
selectedProfile?.name?.takeIf(String::isNotBlank)
?: com.hermesandroid.relay.data.ChatComposerDraftKey.DEFAULT_PROFILE_ID
)
// Android sharesheet handoff: wait until the configured chat context is
// settled, then ask ChatViewModel to own the new draft and composer
// prefill. Navigation is presentation-only; no composable writes chat
// stores or sends the shared text.
// settled, then create a fresh draft. The request remains identity-fenced
// until ChatScreen restores that exact draft and ingests its text/files.
LaunchedEffect(navController, onboardingCompleted, initialChatSettled) {
if (!onboardingCompleted || !initialChatSettled) return@LaunchedEffect
com.hermesandroid.relay.util.SharedTextRequest.pending.collect { request ->
com.hermesandroid.relay.util.SharedContentRequest.pending.collect { request ->
request ?: return@collect
if (chatViewModel.openSharedTextDraft(request.text)) {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
launchSingleTop = true
val targetConnectionId = shareConnectionId
val targetProfileId = shareProfileId
if (!request.ready && !request.preparing && !request.failed) {
com.hermesandroid.relay.util.SharedContentRequest.markPreparing(request.id)
val opened = chatViewModel.openSharedContentDraft(
onReady = { sessionId ->
com.hermesandroid.relay.util.SharedContentRequest.markReady(
id = request.id,
targetConnectionId = targetConnectionId,
targetProfileId = targetProfileId,
targetSessionId = sessionId,
)
},
onFailure = {
com.hermesandroid.relay.util.SharedContentRequest.markFailed(request.id)
},
)
if (opened) {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
launchSingleTop = true
}
} else {
com.hermesandroid.relay.util.SharedContentRequest.markFailed(request.id)
}
com.hermesandroid.relay.util.SharedTextRequest.consume(request.id)
}
}
}
@@ -1387,15 +1464,28 @@ fun RelayApp() {
}
val masterEnabled by masterEnabledFlow.collectAsState(initial = false)
val unattendedEnabled by unattendedEnabledFlow.collectAsState(initial = false)
val activeBridgePolicy by (connectionViewModel.bridgeSafety?.activeCapabilityPolicy
?: remember { kotlinx.coroutines.flow.MutableStateFlow(
com.hermesandroid.relay.bridge.BridgeCapabilityPolicy(),
) })
.collectAsState()
val timedScreenControlActive = activeBridgePolicy.allows(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
System.currentTimeMillis(),
)
// Sideload-only: googlePlay has no wake lock and the unattended
// flag never gets written there — gating here is defence in depth
// and makes the check cheap via R8 in release builds.
val showUnattendedBanner = BuildFlavor.isSideload &&
masterEnabled &&
unattendedEnabled &&
timedScreenControlActive &&
!suppressGlobalChrome &&
!showStartupSphere &&
!voiceUiState.voiceMode
val showCandidateBanner = CandidateBuild.isCandidate &&
!voiceUiState.voiceMode &&
!showStartupSphere
// Persistent Demo-mode strip — visible on every demo surface so the
// user always knows the chat is sample data with no live server, and
// can exit into the real Connect flow with one tap.
@@ -1584,7 +1674,8 @@ fun RelayApp() {
// The connection-status toast is now a floating overlay and
// doesn't occupy space above the Scaffold, so it no longer
// participates in the top-inset accounting.
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure || connectionChipVisible ||
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure ||
connectionChipVisible ||
showMessageBanner
) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
@@ -1628,7 +1719,7 @@ fun RelayApp() {
?: AgentDisplay.displayModelName(serverModelName)
?: stringResource(R.string.status_model_pending)
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
if (unattendedEnabled) stringResource(R.string.status_safety_unattended)
if (unattendedEnabled && timedScreenControlActive) stringResource(R.string.status_safety_unattended)
else stringResource(R.string.status_safety_on)
} else {
stringResource(R.string.status_profile_format, profileLabel)
@@ -1814,12 +1905,18 @@ fun RelayApp() {
.InteractionRequestNotifier.DEFAULT_PROFILE_ROUTE_VALUE
}
if (!profileSelectionSettled) return@LaunchedEffect
if (!connectionViewModel.isProfileSelectionAllowed(targetProfile)) {
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
return@LaunchedEffect
}
if (effectiveSessionProfileName != targetProfile) {
val selection = targetProfile?.let { name ->
agentProfiles.firstOrNull { it.name == name }
}
if (targetProfile == null || selection != null) {
connectionViewModel.selectProfile(selection)
chatViewModel.activateGatewayProfile(selection)
}
return@LaunchedEffect
}
@@ -2316,6 +2413,7 @@ fun RelayApp() {
composable(Screen.BridgeSafetySettings.route) {
if (BuildFlavor.isSideload) {
BridgeSafetySettingsScreen(
connectionId = activeConnectionId,
onBack = { navController.popBackStack() }
)
} else {
@@ -2425,14 +2523,7 @@ fun RelayApp() {
// underneath the discovery UI instead of blocking
// navigation on encrypted-store/client setup.
navController.navigate(Screen.Pair.route(connectionId = id))
val job = connectionSwitchScope.launch {
try {
connectionViewModel.beginAddConnection(preAllocatedId = id)
} finally {
pendingAddConnectionJobs.remove(id)
}
}
pendingAddConnectionJobs[id] = job
prepareAddConnection(id, false)
},
onBack = { navController.popBackStack() },
// Pass the VM so the list cards can read live status
@@ -2526,12 +2617,44 @@ fun RelayApp() {
?.getString(Screen.Pair.ARG_AUTO_START)
val pairConnections by connectionViewModel.connections.collectAsState()
val pairActiveId by connectionViewModel.activeConnectionId.collectAsState()
val pairSetupReady = connectionIdArg == null ||
(pairActiveId == connectionIdArg && pairConnections.any { it.id == connectionIdArg })
val pairStoreHydrated by connectionViewModel.connectionStore.isHydrated.collectAsState()
// Duplicate Renew authorizes one explicit route handoff
// before switching away from the placeholder. Persist the
// identity, not a bare readiness boolean, so Activity
// recreation remains safe and process restore still has
// to hydrate a real matching connection row.
var authorizedPairHandoffId by rememberSaveable(connectionIdArg) {
mutableStateOf<String?>(null)
}
val pairSetupReady = resolvePairSetupReady(
storeHydrated = pairStoreHydrated,
connectionId = connectionIdArg,
authorizedHandoffId = authorizedPairHandoffId,
activeConnectionId = pairActiveId,
connectionIds = pairConnections.mapTo(mutableSetOf()) { it.id },
)
com.hermesandroid.relay.ui.screens.PairScreen(
connectionViewModel = connectionViewModel,
autoStart = autoStartArg,
setupReady = pairSetupReady,
onSetupTimeout = if (connectionIdArg == null) null else ({
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = "Connection setup did not become ready",
detail = "targetPresent=${pairConnections.any { it.id == connectionIdArg }} " +
"activeMatches=${pairActiveId == connectionIdArg} " +
"activePresent=${pairActiveId != null}",
operation = "Prepare connection-scoped local storage",
suggestion = "Retry setup or cancel and add the connection again.",
)
}),
onSetupRetry = if (connectionIdArg == null) null else ({
prepareAddConnection(connectionIdArg, true)
}),
onConnectionTargetChanged = { existingId ->
authorizedPairHandoffId = existingId
},
// Offer demo only on the bare "Connect" entry (the
// "No Hermes connection" path) — not on add-connection /
// re-pair flows, which have a placeholder connection in
@@ -2593,6 +2716,13 @@ fun RelayApp() {
onBack = { navController.popBackStack() },
onBrowsePetdex = { navController.navigate(Screen.PetdexBrowse.route) },
onCreatePet = { navController.navigate(Screen.CustomPetGuide.route) },
onOpenCustomTheme = { navController.navigate(Screen.CustomTheme.route) },
)
}
composable(Screen.CustomTheme.route) {
CustomThemeScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
)
}
composable(Screen.PetdexBrowse.route) {
@@ -2834,6 +2964,13 @@ fun RelayApp() {
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars),
) {
AnimatedVisibility(
visible = showCandidateBanner,
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
) {
CandidateBuildBanner()
}
AnimatedVisibility(
visible = availableUpdateStatus != null && !suppressGlobalChrome &&
!showStartupSphere && !voiceUiState.voiceMode,
@@ -79,6 +79,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
@@ -407,7 +408,7 @@ fun ActiveCardFeaturesSection(
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
@@ -449,7 +450,7 @@ fun ActiveCardFeaturesSection(
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
@@ -657,7 +658,7 @@ fun ActiveCardAdvancedSection(
if (apiEditorOpen) {
Surface(
color = Color.Transparent,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(
@@ -675,7 +676,7 @@ fun ActiveCardAdvancedSection(
} else {
Surface(
color = Color.Transparent,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.fillMaxWidth(),
) {
@@ -710,7 +711,7 @@ fun ActiveCardAdvancedSection(
if (apiHelpOpen) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(8.dp),
shape = appearanceRoundedCornerShape(8.dp),
) {
Text(
text = stringResource(R.string.active_section_api_key_explainer),
@@ -749,7 +750,7 @@ fun ActiveCardAdvancedSection(
if (relayEditorOpen) {
Surface(
color = Color.Transparent,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(
@@ -791,7 +792,7 @@ fun ActiveCardAdvancedSection(
if (pairingOpen) {
Surface(
color = Color.Transparent,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(modifier = Modifier.padding(14.dp)) {
@@ -1349,7 +1350,7 @@ private fun ManualPairingCodeSubsection(
ManualPairStep(number = 2, title = step2RunCommand) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(6.dp),
shape = appearanceRoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
@@ -1491,7 +1492,7 @@ fun ActiveCardSecurityPosture(
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(14.dp)) {
@@ -1580,7 +1581,7 @@ fun ActiveCardSecurityPosture(
Text(text = stringResource(R.string.active_section_access), style = MaterialTheme.typography.titleSmall)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column {
@@ -1816,7 +1817,7 @@ fun ActiveCardRoutesSection(
} else {
MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
},
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
@@ -1861,7 +1862,7 @@ fun ActiveCardRoutesSection(
} else {
MaterialTheme.colorScheme.surfaceVariant
},
shape = RoundedCornerShape(6.dp),
shape = appearanceRoundedCornerShape(6.dp),
) {
Text(
text = if (dashboardReachable) {
@@ -1955,7 +1956,7 @@ fun ActiveCardRoutesSection(
if (showTailscaleUnavailableHint) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp),
shape = appearanceRoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
@@ -2004,7 +2005,7 @@ fun ActiveCardRoutesSection(
// up. Offer the route editor directly.
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp),
shape = appearanceRoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
@@ -2066,7 +2067,7 @@ fun ActiveCardRoutesSection(
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
@@ -2103,7 +2104,7 @@ fun ActiveCardRoutesSection(
}
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
@@ -0,0 +1,633 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.navigationBarsPadding
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.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
@Composable
fun BridgeAgentAccessCard(
policy: BridgeCapabilityPolicy,
nowMs: Long,
onSetUp: () -> Unit,
onManage: () -> Unit,
onAllowScreen: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasGrant = policy.hasAnyGrant(nowMs)
val preset = policy.displayPreset()
val timed = policy.activeTimedCapabilities(nowMs)
val screenUnlimited = timed.any(policy::isUnlimited)
val nextExpiry = policy.timedExpiriesMs.filterValues {
it > nowMs && it != BridgeCapabilityPolicy.NEVER_EXPIRES_AT_MS
}.values.maxOrNull()
val screenActive = timed.isNotEmpty()
Card(
modifier = modifier.fillMaxWidth(),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.bridge_access_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp).weight(1f),
)
AccessStatePill(
text = when (preset) {
BridgeAccessPreset.READ_ONLY -> stringResource(R.string.bridge_access_preset_read_only)
BridgeAccessPreset.READ_CONFIRMED -> stringResource(R.string.bridge_access_preset_confirmed_short)
BridgeAccessPreset.CUSTOM -> stringResource(R.string.bridge_access_preset_custom)
null -> stringResource(R.string.bridge_access_not_set_up)
},
)
}
Text(
text = stringResource(R.string.bridge_access_summary_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!hasGrant) {
Button(onClick = onSetUp, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.bridge_access_set_up))
}
} else {
AccessSummaryRow(
title = stringResource(R.string.bridge_access_always),
subtitle = stringResource(
R.string.bridge_access_enabled_count,
policy.permanentGrants.size,
BridgeCapability.entries.count { !it.timed },
),
trailing = policy.permanentGrants.size.toString(),
onClick = onManage,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
AccessSummaryRow(
title = stringResource(R.string.bridge_access_screen),
subtitle = if (screenActive) {
if (screenUnlimited) {
stringResource(R.string.bridge_access_screen_unlimited)
} else {
stringResource(R.string.bridge_access_screen_active)
}
} else {
stringResource(R.string.bridge_access_screen_off)
},
trailing = if (screenUnlimited) {
stringResource(R.string.bridge_access_until_off_short)
} else {
nextExpiry?.let { formatRemaining(it - nowMs) }
?: stringResource(R.string.bridge_access_allow_duration)
},
onClick = onAllowScreen,
)
}
}
}
}
@Composable
fun BridgeAndroidAccessSummaryCard(
summary: BridgeAndroidAccessSummary,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onToggle),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = if (summary.allReady) Icons.Filled.CheckCircle else Icons.Filled.Security,
contentDescription = null,
tint = if (summary.allReady) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.bridge_android_access_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = when {
summary.required.isEmpty() -> stringResource(R.string.bridge_android_access_none)
summary.allReady -> stringResource(R.string.bridge_android_access_ready)
else -> stringResource(
R.string.bridge_android_access_missing,
summary.ready.size,
summary.required.size,
summary.missing.size,
)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = if (expanded) {
stringResource(R.string.bridge_android_access_hide)
} else {
stringResource(R.string.bridge_android_access_review)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
}
@Composable
fun BridgeSelectedAndroidAccessCard(
summary: BridgeAndroidAccessSummary,
onOpenAccessibility: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenOverlay: () -> Unit,
modifier: Modifier = Modifier,
) {
if (summary.missing.isEmpty()) return
Card(
modifier = modifier.fillMaxWidth(),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
stringResource(R.string.bridge_android_selected_needs),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
stringResource(R.string.bridge_android_selected_needs_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
summary.missing.forEach { requirement ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(requirement.labelResource()),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = when (requirement) {
BridgeAndroidRequirement.ACCESSIBILITY -> onOpenAccessibility
BridgeAndroidRequirement.OVERLAY -> onOpenOverlay
else -> onOpenAppSettings
},
) {
Text(stringResource(R.string.bridge_android_open_settings))
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BridgeAccessSetupSheet(
selected: BridgeAccessPreset,
onSelected: (BridgeAccessPreset) -> Unit,
onDismiss: () -> Unit,
onContinue: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(600.dp)
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.bridge_access_choose_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.bridge_access_choose_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PresetChoice(
title = stringResource(R.string.bridge_access_preset_read_only),
description = stringResource(R.string.bridge_access_preset_read_only_desc),
selected = selected == BridgeAccessPreset.READ_ONLY,
recommended = true,
onClick = { onSelected(BridgeAccessPreset.READ_ONLY) },
)
PresetChoice(
title = stringResource(R.string.bridge_access_preset_confirmed),
description = stringResource(R.string.bridge_access_preset_confirmed_desc),
selected = selected == BridgeAccessPreset.READ_CONFIRMED,
onClick = { onSelected(BridgeAccessPreset.READ_CONFIRMED) },
)
PresetChoice(
title = stringResource(R.string.bridge_access_preset_custom),
description = stringResource(R.string.bridge_access_preset_custom_desc),
selected = selected == BridgeAccessPreset.CUSTOM,
onClick = { onSelected(BridgeAccessPreset.CUSTOM) },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.bridge_cancel)) }
Button(onClick = onContinue) { Text(stringResource(R.string.bridge_access_continue)) }
}
Spacer(Modifier.size(4.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BridgeTimedAccessSheet(
inspectEnabled: Boolean,
controlEnabled: Boolean,
durationMinutes: Int,
unlimited: Boolean,
accessibilityReady: Boolean,
overlayReady: Boolean,
currentlyActive: Boolean,
onInspectChanged: (Boolean) -> Unit,
onControlChanged: (Boolean) -> Unit,
onDurationChanged: (Int) -> Unit,
onUnlimitedChanged: (Boolean) -> Unit,
onOpenAccessibility: () -> Unit,
onOpenOverlay: () -> Unit,
onDismiss: () -> Unit,
onAllow: () -> Unit,
onEndNow: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(740.dp)
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.bridge_timed_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.bridge_timed_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.bridge_timed_lifetime_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(5, 30, 120).forEach { minutes ->
FilterChip(
selected = !unlimited && durationMinutes == minutes,
onClick = {
onUnlimitedChanged(false)
onDurationChanged(minutes)
},
label = { Text(formatIdleDuration(minutes)) },
)
}
}
FilterChip(
selected = unlimited,
onClick = { onUnlimitedChanged(true) },
label = { Text(stringResource(R.string.bridge_timed_until_off)) },
)
Text(
text = if (unlimited) {
stringResource(R.string.bridge_timed_unlimited_warning)
} else {
stringResource(R.string.bridge_timed_idle_explainer, formatDuration(durationMinutes))
},
style = MaterialTheme.typography.bodySmall,
color = if (unlimited) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Text(
stringResource(R.string.bridge_timed_scope_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
TimedChoice(
title = stringResource(R.string.bss_capability_screen_inspection),
description = stringResource(R.string.bridge_timed_inspection_desc),
checked = inspectEnabled,
onCheckedChange = onInspectChanged,
)
TimedChoice(
title = stringResource(R.string.bss_capability_screen_control),
description = stringResource(R.string.bridge_timed_control_desc),
checked = controlEnabled,
onCheckedChange = onControlChanged,
)
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
stringResource(R.string.bridge_timed_prerequisites),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
PrerequisiteRow(
stringResource(R.string.bpc_accessibility),
accessibilityReady,
onOpenAccessibility,
)
if (controlEnabled) {
PrerequisiteRow(
stringResource(R.string.bpc_overlay),
overlayReady,
onOpenOverlay,
)
}
Text(
stringResource(R.string.bridge_timed_capture_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (currentlyActive) {
TextButton(onClick = onEndNow) {
Text(stringResource(R.string.bridge_timed_end_now))
}
} else {
Spacer(Modifier.size(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.bridge_cancel)) }
Button(
onClick = onAllow,
enabled = (inspectEnabled || controlEnabled) &&
accessibilityReady && (!controlEnabled || overlayReady),
) {
Text(stringResource(R.string.bridge_timed_allow))
}
}
}
Spacer(Modifier.size(4.dp))
}
Spacer(Modifier.size(12.dp))
}
}
}
@Composable
private fun AccessSummaryRow(
title: String,
subtitle: String,
trailing: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(trailing, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
@Composable
private fun PresetChoice(
title: String,
description: String,
selected: Boolean,
recommended: Boolean = false,
onClick: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
RadioButton(selected = selected, onClick = null)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (recommended) {
Text(
stringResource(R.string.bridge_access_recommended),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
@Composable
private fun TimedChoice(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun PrerequisiteRow(label: String, ready: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (ready) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
Text(label, modifier = Modifier.padding(start = 8.dp).weight(1f))
Text(
if (ready) stringResource(R.string.bridge_android_ready_short)
else stringResource(R.string.bridge_android_missing_short),
style = MaterialTheme.typography.labelLarge,
color = if (ready) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = stringResource(R.string.bridge_android_open_settings),
modifier = Modifier.padding(start = 4.dp),
)
}
}
@Composable
private fun AccessStatePill(text: String) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
private fun formatRemaining(remainingMs: Long): String {
val totalSeconds = (remainingMs.coerceAtLeast(0L) / 1_000L).toInt()
return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
}
private fun formatDuration(minutes: Int): String =
if (minutes == 120) "2 hr" else "$minutes min"
private fun formatIdleDuration(minutes: Int): String =
if (minutes == 120) "2 hr idle" else "$minutes min idle"
private fun BridgeAndroidRequirement.labelResource(): Int = when (this) {
BridgeAndroidRequirement.ACCESSIBILITY -> R.string.bpc_accessibility
BridgeAndroidRequirement.CONTACTS -> R.string.bpc_contacts
BridgeAndroidRequirement.LOCATION -> R.string.bpc_location
BridgeAndroidRequirement.SMS -> R.string.bpc_sms
BridgeAndroidRequirement.PHONE -> R.string.bpc_phone
BridgeAndroidRequirement.OVERLAY -> R.string.bpc_overlay
}
@@ -0,0 +1,97 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
enum class BridgeAccessPreset {
READ_ONLY,
READ_CONFIRMED,
CUSTOM,
}
val READ_ONLY_BRIDGE_CAPABILITIES: Set<BridgeCapability> = setOf(
BridgeCapability.DEVICE_INFO,
BridgeCapability.CONTACTS_READ,
BridgeCapability.LOCATION_READ,
BridgeCapability.CLIPBOARD_READ,
)
val READ_CONFIRMED_BRIDGE_CAPABILITIES: Set<BridgeCapability> =
READ_ONLY_BRIDGE_CAPABILITIES + setOf(
BridgeCapability.COMMUNICATIONS,
BridgeCapability.OUTBOUND_SHARING,
)
enum class BridgeAndroidRequirement {
ACCESSIBILITY,
CONTACTS,
LOCATION,
SMS,
PHONE,
OVERLAY,
}
data class BridgeAndroidAccessSummary(
val required: Set<BridgeAndroidRequirement>,
val ready: Set<BridgeAndroidRequirement>,
) {
val missing: Set<BridgeAndroidRequirement> get() = required - ready
val allReady: Boolean get() = missing.isEmpty()
}
fun BridgeCapabilityPolicy.hasAnyGrant(nowMs: Long): Boolean =
permanentGrants.isNotEmpty() || timedExpiriesMs.any { (capability, expiry) ->
capability.timed && expiry > nowMs
}
fun BridgeCapabilityPolicy.activeTimedCapabilities(nowMs: Long): Set<BridgeCapability> =
timedExpiriesMs.filterValues { it > nowMs }.keys
fun BridgeCapabilityPolicy.displayPreset(): BridgeAccessPreset? = when (permanentGrants) {
READ_ONLY_BRIDGE_CAPABILITIES -> BridgeAccessPreset.READ_ONLY
READ_CONFIRMED_BRIDGE_CAPABILITIES -> BridgeAccessPreset.READ_CONFIRMED
else -> if (permanentGrants.isEmpty()) null else BridgeAccessPreset.CUSTOM
}
fun bridgeAndroidAccessSummary(
policy: BridgeCapabilityPolicy,
status: BridgePermissionStatus,
nowMs: Long,
): BridgeAndroidAccessSummary {
val timed = policy.activeTimedCapabilities(nowMs)
val required = buildSet {
// Current BridgeCommandHandler is service-owned even for passive
// commands. Keep this visible until non-screen executors are split.
if (policy.permanentGrants.isNotEmpty() || timed.isNotEmpty()) {
add(BridgeAndroidRequirement.ACCESSIBILITY)
}
if (BridgeCapability.CONTACTS_READ in policy.permanentGrants) {
add(BridgeAndroidRequirement.CONTACTS)
}
if (BridgeCapability.LOCATION_READ in policy.permanentGrants) {
add(BridgeAndroidRequirement.LOCATION)
}
if (BridgeCapability.COMMUNICATIONS in policy.permanentGrants) {
add(BridgeAndroidRequirement.SMS)
add(BridgeAndroidRequirement.PHONE)
}
if (BridgeCapability.SCREEN_CONTROL in timed ||
BridgeCapability.COMMUNICATIONS in policy.permanentGrants ||
BridgeCapability.OUTBOUND_SHARING in policy.permanentGrants
) {
add(BridgeAndroidRequirement.OVERLAY)
}
}
val ready = required.filterTo(linkedSetOf()) { requirement ->
when (requirement) {
BridgeAndroidRequirement.ACCESSIBILITY -> status.accessibilityServiceEnabled
BridgeAndroidRequirement.CONTACTS -> status.contactsPermitted
BridgeAndroidRequirement.LOCATION -> status.locationPermitted
BridgeAndroidRequirement.SMS -> status.smsPermitted
BridgeAndroidRequirement.PHONE -> status.phonePermitted
BridgeAndroidRequirement.OVERLAY -> status.overlayPermitted
}
}
return BridgeAndroidAccessSummary(required = required, ready = ready)
}
@@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.viewmodel.BridgeStatus
/**
@@ -98,7 +99,7 @@ fun BridgeMasterToggle(
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
/**
@@ -109,7 +110,7 @@ fun BridgePermissionChecklist(
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Security
@@ -26,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.BridgeSafetySettings
@@ -40,8 +40,8 @@ import com.hermesandroid.relay.data.DEFAULT_DESTRUCTIVE_VERBS
*
* - Blocklist count ("12 apps blocked")
* - Destructive-verb count ("12 verbs need confirmation")
* - Auto-disable window ("Auto-off after 30 min idle")
* - Auto-disable countdown when a timer is active
* - Timed screen-access window
* - Timed screen-access countdown when active
*
* Tap → navigate to [BridgeSafetySettingsScreen].
*
@@ -53,6 +53,8 @@ import com.hermesandroid.relay.data.DEFAULT_DESTRUCTIVE_VERBS
fun BridgeSafetySummaryCard(
settings: BridgeSafetySettings,
autoDisableAtMs: Long? = null,
screenAccessActive: Boolean = false,
screenAccessUnlimited: Boolean = false,
onManage: () -> Unit,
) {
// Tick a local clock every second when a countdown is active so the
@@ -72,7 +74,7 @@ fun BridgeSafetySummaryCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onManage),
shape = RoundedCornerShape(14.dp),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
@@ -114,14 +116,18 @@ fun BridgeSafetySummaryCard(
value = "${settings.destructiveVerbs.size}",
)
SafetySummaryRow(
label = stringResource(R.string.bssc_auto_disable),
value = if (autoDisableAtMs != null) {
label = stringResource(R.string.bridge_access_screen),
value = if (screenAccessUnlimited) {
stringResource(R.string.bridge_access_until_off_short)
} else if (!screenAccessActive) {
stringResource(R.string.bmt_off)
} else if (autoDisableAtMs != null) {
val remainMs = (autoDisableAtMs - nowMs).coerceAtLeast(0L)
val remainMin = (remainMs / 60_000L).toInt()
val remainSec = ((remainMs % 60_000L) / 1000L).toInt()
"in ${remainMin}:${remainSec.toString().padStart(2, '0')}"
} else {
"${settings.autoDisableMinutes} min"
stringResource(R.string.bridge_access_screen_active)
},
)
@@ -183,6 +189,7 @@ private fun BridgeSafetySummaryCardPreview_Countdown() {
destructiveVerbs = DEFAULT_DESTRUCTIVE_VERBS,
),
autoDisableAtMs = System.currentTimeMillis() + 12 * 60_000L + 34_000L,
screenAccessActive = true,
onManage = {},
)
}
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
@@ -19,6 +18,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.viewmodel.BridgeStatus
/**
@@ -40,7 +40,7 @@ fun BridgeStatusCard(
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
@@ -0,0 +1,117 @@
package com.hermesandroid.relay.ui.components
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.HorizontalDivider
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.CandidateBuild
/** Persistent, non-dismissible identity strip for side-by-side candidate builds. */
@Composable
@SuppressLint("HardcodedText")
fun CandidateBuildBanner(modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(30.dp)
.clickable { expanded = !expanded }
.semantics { role = Role.Button }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = CandidateBuild.heading,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Text(
text = CandidateBuild.label,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Hide candidate details" else "Show candidate details",
modifier = Modifier
.padding(start = 8.dp)
.size(18.dp),
)
}
}
AnimatedVisibility(visible = expanded) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 6.dp,
shadowElevation = 4.dp,
) {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
CandidateDetailRow(label = "Commit", value = CandidateBuild.shortSha)
CandidateDetailRow(label = "Install", value = "Candidate slot · stable untouched")
}
}
}
}
}
@Composable
@SuppressLint("HardcodedText")
private fun CandidateDetailRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 5.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(0.22f),
)
Text(
text = value,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(0.78f),
)
}
}
@@ -0,0 +1,147 @@
package com.hermesandroid.relay.ui.components
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.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.ChatFailureNotice
@Composable
fun ChatFailurePanel(
failure: ChatFailureNotice,
routeLabel: String,
onDetails: () -> Unit,
onRetry: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 8.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.Top) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(top = 2.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.chat_failure_title),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
failureIdentity(routeLabel, failure.model, failure.provider)
.takeIf { it.isNotBlank() }
?.let { identity ->
Text(
text = identity,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.78f),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
}
if (failure.recoverable) {
TextButton(onClick = onRetry) {
Text(stringResource(R.string.chat_retry))
}
}
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.chat_dismiss))
}
}
}
}
}
@Composable
fun ChatFailureDetailsDialog(
failure: ChatFailureNotice,
routeLabel: String,
onCopy: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_failure_details_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
failureIdentity(routeLabel, failure.model, failure.provider)
.takeIf { it.isNotBlank() }
?.let { identity ->
Text(text = identity, style = MaterialTheme.typography.labelLarge)
}
Text(
text = stringResource(R.string.chat_failure_details_guidance),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
) {
SelectionContainer {
Text(
text = failure.rawError,
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onCopy) {
Text(stringResource(R.string.chat_failure_copy_details))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.common_close))
}
},
)
}
internal fun failureIdentity(route: String, model: String?, provider: String?): String =
listOfNotNull(
route.takeIf { it.isNotBlank() },
provider?.trim()?.takeIf { it.isNotEmpty() },
model?.trim()?.takeIf { it.isNotEmpty() },
).distinct().joinToString(" · ")
@@ -64,6 +64,7 @@ import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
@@ -75,6 +76,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.appearanceComposerShape
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.purpleGlow
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import kotlinx.coroutines.delay
@@ -93,8 +96,6 @@ import kotlinx.coroutines.delay
*/
enum class ChatInputTrailing { SEND, VOICE, STOP, STEER, QUEUE }
private val ChatComposerShape = RoundedCornerShape(26.dp)
private val ChatInputChipShape = RoundedCornerShape(12.dp)
internal const val CHAT_INPUT_FIELD_TEST_TAG = "chat-input-field"
data class ChatInputPickerOption(
@@ -197,6 +198,13 @@ fun ChatInputBar(
ChatInputTrailing.QUEUE,
)
// Enter only means "send" when a physical keyboard is attached (see
// the key handler below). Read the configuration here, in the composable
// scope, and capture it for the non-composable onPreviewKeyEvent lambda.
val keyboardAttached =
LocalConfiguration.current.keyboard !=
android.content.res.Configuration.KEYBOARD_NOKEYS
// Keep the last caption around so the AnimatedVisibility exit doesn't
// flash an empty line while collapsing.
var lastCaption by remember { mutableStateOf<String?>(null) }
@@ -312,7 +320,7 @@ fun ChatInputBar(
}
Surface(
shape = ChatComposerShape,
shape = appearanceComposerShape(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier
@@ -375,10 +383,18 @@ fun ChatInputBar(
val native = event.nativeKeyEvent
val isEnter = native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
// Enter only means "send" when a physical keyboard is
// attached. IME-dispatched Enter (commitText or a
// synthesized KEYCODE_ENTER) must always fall through
// so the soft keyboard's return key inserts a newline
// instead of sending (issue #367). Key events alone
// cannot distinguish physical vs IME origin — deviceId
// is 0 or -1 depending on the IME — so gate on the
// hardware keyboard configuration (read above).
val isSubmitShortcut = native.isCtrlPressed || native.isMetaPressed
if (native.action != android.view.KeyEvent.ACTION_DOWN || !isEnter) {
false
} else if (isSubmitShortcut || (physicalEnterSends && !native.isShiftPressed)) {
} else if (isSubmitShortcut || (keyboardAttached && physicalEnterSends && !native.isShiftPressed)) {
if (canSubmit) onSend()
true
} else {
@@ -683,12 +699,12 @@ private fun ChatInputPickerChip(
Box(modifier = modifier) {
Surface(
shape = ChatInputChipShape,
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.32f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)),
modifier = Modifier
.heightIn(min = 32.dp)
.clip(ChatInputChipShape)
.clip(appearanceRoundedCornerShape(12.dp))
.clickable(enabled = enabled) {
if (onClickOverride != null) onClickOverride() else expanded = true
},
@@ -47,6 +47,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.appearanceTopRoundedCornerShape
/**
* A slash command entry — built-in, personality, server skill, or (on the
* gateway transport) a server-catalog command from `commands.catalog`.
@@ -128,7 +130,7 @@ fun CommandPalette(
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
shape = appearanceTopRoundedCornerShape(20.dp)
) {
Column(
modifier = Modifier
@@ -184,7 +186,7 @@ fun CommandPalette(
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp)
shape = appearanceRoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -362,7 +364,7 @@ fun InlineAutocomplete(
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
shadowElevation = 4.dp,
tonalElevation = 2.dp
) {
@@ -119,6 +119,7 @@ import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.theme.LocalBrand
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
@@ -164,7 +165,7 @@ private fun StatusChip(text: String, background: Color, contentColor: Color) {
fontWeight = FontWeight.Medium,
color = contentColor,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.background(background)
.padding(horizontal = 10.dp, vertical = 4.dp)
)
@@ -603,7 +604,7 @@ fun RelayInfoSheet(
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -983,7 +984,7 @@ fun AgentInfoSheet(
if (selectedTab == 0) {
Surface(
shape = RoundedCornerShape(22.dp),
shape = appearanceRoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -1037,7 +1038,7 @@ fun AgentInfoSheet(
// visibly separate from the session-only model/effort card so
// it cannot be mistaken for an ephemeral override.
Surface(
shape = RoundedCornerShape(22.dp),
shape = appearanceRoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -1088,7 +1089,7 @@ fun AgentInfoSheet(
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.clip(appearanceRoundedCornerShape(18.dp))
.background(
Brush.horizontalGradient(
listOf(brand.purple, brand.relay),
@@ -1405,7 +1406,7 @@ private fun AgentPassportIdentityEditor(
}
Surface(
shape = RoundedCornerShape(22.dp),
shape = appearanceRoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -1482,7 +1483,7 @@ private fun AgentPassportHeader(
stringResource(R.string.conn_info_skills_count, skillCount).takeIf { skillCount > 0 },
).joinToString(" · ")
Surface(
shape = RoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -1564,7 +1565,7 @@ private fun AgentPassportHeader(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onProfileClick),
shape = RoundedCornerShape(14.dp),
shape = appearanceRoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -1821,7 +1822,7 @@ internal fun AgentPassportSafetyCard(
),
)
Surface(
shape = RoundedCornerShape(22.dp),
shape = appearanceRoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -1993,7 +1994,7 @@ internal fun PassportSegmentedControl(
modifier = Modifier
.fillMaxWidth()
.selectableGroup()
.clip(RoundedCornerShape(14.dp))
.clip(appearanceRoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(3.dp),
) {
@@ -2012,7 +2013,7 @@ internal fun PassportSegmentedControl(
.semantics {
contentDescription = "${option.label}. ${option.description}"
},
shape = RoundedCornerShape(11.dp),
shape = appearanceRoundedCornerShape(11.dp),
color = when {
selected != index -> Color.Transparent
enabled -> MaterialTheme.colorScheme.primary
@@ -2101,7 +2102,7 @@ private fun AgentPassportSessionTab(
}
}
Surface(
shape = RoundedCornerShape(22.dp),
shape = appearanceRoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -2153,7 +2154,7 @@ private fun AgentPassportSessionTab(
}
}
Surface(
shape = RoundedCornerShape(22.dp),
shape = appearanceRoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
@@ -2206,7 +2207,7 @@ private fun AgentPassportSessionTab(
OutlinedButton(
onClick = onManageConnections,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
shape = appearanceRoundedCornerShape(18.dp),
) {
Icon(Icons.Filled.Tune, contentDescription = null)
Spacer(Modifier.size(8.dp))
@@ -3285,7 +3286,7 @@ private fun LegacyAgentInfoSheet(
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -3731,7 +3732,7 @@ private fun CollapsiblePickerSection(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clip(appearanceRoundedCornerShape(10.dp))
.clickable { expanded = !expanded }
.padding(vertical = 6.dp),
) {
@@ -3896,7 +3897,7 @@ private fun ProfileRadioRow(
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.selectable(
selected = selected,
enabled = enabled,
@@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -100,6 +99,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionValidation
@@ -191,6 +191,7 @@ fun ConnectionWizard(
*/
autoStart: String? = null,
setupReady: Boolean = true,
onConnectionTargetChanged: (String) -> Unit = {},
/**
* Optional "Try the demo" affordance shown atop the Method step. When
* non-null, the wizard surfaces an offline Demo / Explore entry point so a
@@ -923,6 +924,10 @@ fun ConnectionWizard(
onUpdate = {
val prompt = existing
duplicatePrompt = null
// Authorize the route's exact target handoff before the
// active-id emission changes. This keeps the wizard composed
// without turning readiness into an unscoped boolean latch.
onConnectionTargetChanged(prompt.id)
wizardScope.launch {
// Snapshot the placeholder id before we switch away —
// after switchConnection returns, activeConnectionId
@@ -1347,7 +1352,7 @@ private fun NewNearbyHermesStep(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
shape = RoundedCornerShape(18.dp),
shape = appearanceRoundedCornerShape(18.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 20.dp),
@@ -1397,7 +1402,7 @@ private fun NewNearbyHermesStep(
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
shape = RoundedCornerShape(16.dp),
shape = appearanceRoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -1503,7 +1508,7 @@ private fun ConnectionChooserRow(
) {
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
) {
Icon(
imageVector = icon,
@@ -1713,7 +1718,7 @@ private fun DashboardFoundStep(
)
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(16.dp),
shape = appearanceRoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -1874,7 +1879,7 @@ private fun RelayChoiceStep(
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
@@ -2215,11 +2220,10 @@ private fun optionalHttpUrlError(
private fun relayUrlSchemeError(url: String, context: android.content.Context): String? {
val trimmed = url.trim()
if (trimmed.isEmpty()) return null
return when {
trimmed.startsWith("http://", ignoreCase = true) ||
trimmed.startsWith("https://", ignoreCase = true) ->
context.getString(R.string.cw_relay_url_scheme_error)
else -> null
return if (ConnectionValidation.validateOptionalRelayUrl(trimmed) == null) {
null
} else {
context.getString(R.string.cw_relay_url_scheme_error)
}
}
@@ -3078,7 +3082,7 @@ private fun ShowCodeStep(
)
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(6.dp),
shape = appearanceRoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
@@ -3814,7 +3818,7 @@ private fun SecureLinkPairingSummary(
}.joinToString(" · ")
Surface(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
@@ -3874,7 +3878,7 @@ private fun SoftPill(
fg: Color,
) {
Surface(
shape = RoundedCornerShape(8.dp),
shape = appearanceRoundedCornerShape(8.dp),
color = fg.copy(alpha = 0.14f),
) {
Text(
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -46,6 +45,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.util.CrashReporter
import com.hermesandroid.relay.util.IssueReport
@@ -106,7 +106,7 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = RoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
@@ -146,7 +146,7 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
@@ -37,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
/**
* Phase 3 — safety-rails `bridge-safety-rails`
@@ -86,7 +87,7 @@ fun DestructiveVerbConfirmDialog(
.widthIn(max = 360.dp)
.fillMaxWidth(0.92f)
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(20.dp),
shape = appearanceRoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
@@ -126,7 +127,7 @@ fun DestructiveVerbConfirmDialog(
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(10.dp)
appearanceRoundedCornerShape(10.dp)
)
.padding(12.dp)
) {
@@ -245,7 +246,7 @@ fun BridgeStatusOverlayChip(unattended: Boolean = false) {
modifier = Modifier
.background(
color = Color(0xFF1A1A2E).copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
)
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
@@ -42,6 +42,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.BuildConfig
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.util.DiagnosticIssuePrefill
@@ -79,7 +80,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = RoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
@@ -137,7 +138,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
RoundedCornerShape(12.dp),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
@@ -12,7 +12,6 @@ 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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.ExpandLess
@@ -53,6 +52,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.SurfaceSecurityKind
@@ -337,7 +337,7 @@ private fun EndpointRow(
Row(
modifier = Modifier
.padding(top = 4.dp)
.clip(RoundedCornerShape(6.dp))
.clip(appearanceRoundedCornerShape(6.dp))
.clickable { detailsExpanded = !detailsExpanded }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -545,7 +545,7 @@ private fun RouteSurfaceMap(
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
modifier = modifier.fillMaxWidth(),
) {
Column(
@@ -635,7 +635,7 @@ private fun displayPort(url: String): String {
private fun ActiveChip(label: String) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f))
.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -659,7 +659,7 @@ private fun ActiveChip(label: String) {
private fun PreferredChip(label: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(Color(0xFFFFA726).copy(alpha = 0.18f))
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
@@ -681,12 +681,12 @@ private fun PreferredChip(label: String) {
private fun FallbackChip(label: String) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp),
shape = appearanceRoundedCornerShape(8.dp),
)
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.viewmodel.TerminalViewModel.SpecialKey
/**
@@ -154,7 +154,7 @@ private fun ToolbarKey(
minWidth: Dp = 36.dp,
) {
val haptic = LocalHapticFeedback.current
val shape = RoundedCornerShape(6.dp)
val shape = appearanceRoundedCornerShape(6.dp)
val scheme = MaterialTheme.colorScheme
val bg = if (active) scheme.primary.copy(alpha = 0.22f) else scheme.surface
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -78,6 +77,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.HermesCardDispatch
@@ -170,7 +170,7 @@ fun HermesCardBubble(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
),
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
@@ -370,7 +370,7 @@ private fun ChoseRow(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f))
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
@@ -497,7 +497,7 @@ private fun CardInputSlot(
)
}
},
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
@@ -595,7 +595,7 @@ private fun InlineAnswerField(
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(16.dp)
val shape = appearanceRoundedCornerShape(16.dp)
Box(
modifier = modifier
.clip(shape)
@@ -641,7 +641,7 @@ private fun HoldToConfirmButton(
) {
val fill = remember { Animatable(0f) }
val errorColor = MaterialTheme.colorScheme.error
val shape = RoundedCornerShape(20.dp)
val shape = appearanceRoundedCornerShape(20.dp)
Box(
modifier = modifier
@@ -8,6 +8,7 @@ import android.net.Uri
import android.widget.Toast
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
@@ -130,7 +131,7 @@ private fun LoadingCard(
) {
val isManualCta = attachment.errorMessage == ChatViewModel.MEDIA_TAP_TO_DOWNLOAD
Surface(
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier
.widthIn(max = maxWidth)
@@ -206,7 +207,7 @@ private fun FailedCard(
maxWidth: Dp
) {
Surface(
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = modifier
.widthIn(max = maxWidth)
@@ -316,7 +317,7 @@ private fun ImageRender(
if (bmp == null) {
// Brief placeholder while the bitmap decodes off-thread.
Surface(
shape = RoundedCornerShape(8.dp),
shape = appearanceRoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier
.widthIn(max = maxWidth)
@@ -400,7 +401,7 @@ private fun FileCardRender(
}
Surface(
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier
.widthIn(max = maxWidth)
@@ -17,6 +17,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import com.hermesandroid.relay.ui.theme.LocalBrand
import com.hermesandroid.relay.ui.theme.LocalAppearanceShapeScale
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -211,10 +213,18 @@ fun MessageBubble(
val bottomStart = if (isUser) 16.dp else 4.dp // tail side always small
val bottomEnd = if (isUser) 4.dp else 16.dp // tail side always small
val shapeScale = LocalAppearanceShapeScale.current
val bubbleShape = when (message.role) {
MessageRole.USER -> RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
MessageRole.ASSISTANT -> RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
MessageRole.SYSTEM -> RoundedCornerShape(12.dp)
// The 4dp tail remains a semantic direction cue; the other corners
// follow the selected appearance scale.
MessageRole.USER,
MessageRole.ASSISTANT -> RoundedCornerShape(
topStart = shapeScale.radius(topStart),
topEnd = shapeScale.radius(topEnd),
bottomEnd = if (bottomEnd == 4.dp) bottomEnd else shapeScale.radius(bottomEnd),
bottomStart = if (bottomStart == 4.dp) bottomStart else shapeScale.radius(bottomStart),
)
MessageRole.SYSTEM -> shapeScale.rounded(12.dp)
}
val alignment = if (isUser) Alignment.End else Alignment.Start
@@ -454,7 +464,7 @@ fun MessageBubble(
DropdownMenu(
expanded = showMessageActions,
onDismissRequest = { showMessageActions = false },
shape = RoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 3.dp,
shadowElevation = 8.dp,
@@ -926,7 +936,7 @@ private fun MessageReactionBadge(
) {
val description = "Reactions: ${reactions.joinToString(" ")}"
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
tonalElevation = 2.dp,
shadowElevation = 2.dp,
@@ -965,7 +975,7 @@ private fun MessageInlineActions(
onEdit: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 2.dp,
modifier = Modifier.padding(top = 2.dp),
@@ -1108,7 +1118,7 @@ internal fun shouldShowMessageGroupAvatar(
@Composable
private fun MessagePathBadge(text: String, leadingIcon: ImageVector? = null) {
Surface(
shape = RoundedCornerShape(6.dp),
shape = appearanceRoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f),
) {
Row(
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@@ -41,6 +40,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.appearanceTopRoundedCornerShape
/**
* Searchable model picker as a bottom sheet — a cleaner surface than the inline
@@ -96,7 +97,7 @@ fun ModelPickerSheet(
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
shape = appearanceTopRoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier
@@ -170,7 +171,7 @@ fun ModelPickerSheet(
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(8.dp))
@@ -254,7 +255,7 @@ internal fun OptionPickerSheet(
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
shape = appearanceTopRoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier
@@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import kotlinx.coroutines.delay
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
@@ -55,9 +54,6 @@ private const val SPHERE_TIME_UNITS_PER_SEC = 1f
private const val SPHERE_TWO_PI = 6.2832f
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
// Idle cadence: this delay plus the next frame wait nets a ~33ms period (~30fps).
private const val SPHERE_IDLE_FRAME_INTERVAL_MS = 25L
@Composable
fun MorphingSphere(
modifier: Modifier = Modifier,
@@ -108,19 +104,14 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// Continuous motion is driven by a manual frame loop rather than
// rememberInfiniteTransition so the redraw rate can follow the orb's
// activity. An infinite transition pins the Canvas at the display refresh
// (120Hz) forever — even when Idle — which needlessly drains battery and,
// on Android 15, makes the platform log `setRequestedFrameRate` on every
// frame. Here we advance every frame while ACTIVE (full-smoothness
// thinking/streaming/voice pulse) and throttle to ~30fps while Idle, where
// the slower cadence is imperceptible for the chunky ASCII glyphs.
// dt-based accumulation keeps the animation speed identical at either rate.
// Continuous motion runs only for active agent/voice states. Idle is a
// stable frame: the 58x34 text grid is expensive enough that even a
// throttled cosmetic drift dominated measured screen-on CPU. Active states
// retain full display-rate motion and dt-based timing.
val animatedTime = remember { mutableFloatStateOf(0f) }
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
val driveAnimation = fixedTime == null || fixedColorPhase == null
val fullFrameRate = state != SphereState.Idle || effVoiceMode
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
if (driveAnimation) {
LaunchedEffect(fullFrameRate) {
var lastNanos = withFrameNanos { it }
@@ -133,7 +124,6 @@ fun MorphingSphere(
animatedColorPhase.floatValue =
(animatedColorPhase.floatValue + dtSec * SPHERE_COLOR_RADIANS_PER_SEC) %
SPHERE_TWO_PI
if (!fullFrameRate) delay(SPHERE_IDLE_FRAME_INTERVAL_MS)
}
}
}
@@ -146,6 +136,7 @@ fun MorphingSphere(
// Cache covers the ~25 distinct glyphs across charSets/dataChars/debrisChars.
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
val glyphStrings = remember { HashMap<Char, String>(32) }
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
val canvasW = size.width
@@ -176,7 +167,8 @@ fun MorphingSphere(
)
forEachSphereCell(frame) { cell ->
val layout = textMeasurer.measure(cell.char.toString(), style)
val glyph = glyphStrings.getOrPut(cell.char) { cell.char.toString() }
val layout = textMeasurer.measure(glyph, style)
// Legacy Paint used y as baseline (`row*cellH + cellH*0.8f`).
// Compose `drawText` uses top-left — offset by firstBaseline to match.
val px = cell.col * cellW
@@ -50,6 +50,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
import com.hermesandroid.relay.data.AttachmentState
@@ -139,7 +140,7 @@ private fun PendingAttachmentItem(
modifier = Modifier
.width(216.dp)
.semantics { contentDescription = summary }
.clip(RoundedCornerShape(14.dp))
.clip(appearanceRoundedCornerShape(14.dp))
.clickable(
enabled = previewEnabled,
onClickLabel = stringResource(R.string.pending_attachment_preview_named, name),
@@ -147,7 +148,7 @@ private fun PendingAttachmentItem(
onClick = onPreview,
)
.testTag("pending-attachment-$index"),
shape = RoundedCornerShape(14.dp),
shape = appearanceRoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(modifier = Modifier.padding(8.dp)) {
@@ -9,7 +9,6 @@ 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ManageSearch
@@ -27,6 +26,7 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
/**
* Settings card that opens the Profile Inspector full-screen viewer for
@@ -56,7 +56,7 @@ fun ProfileInspectorCard(
modifier = modifier
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme,
)
.alpha(if (enabled) 1f else 0.5f),
@@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -27,6 +26,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import com.hermesandroid.relay.ui.theme.relayPanel
import kotlin.math.abs
@@ -56,10 +56,10 @@ fun RelayStatusStrip(
),
)
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
.clip(RoundedCornerShape(999.dp))
.clip(appearanceRoundedCornerShape(16.dp))
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.relayPanel(
shape = RoundedCornerShape(999.dp),
shape = appearanceRoundedCornerShape(16.dp),
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
borderColor = RelayRefresh.Line,
),
@@ -67,8 +67,8 @@ fun RelayStatusStrip(
Row(
modifier = Modifier
.fillMaxWidth()
.height(22.dp)
.padding(horizontal = 14.dp),
.heightIn(min = 22.dp)
.padding(horizontal = 14.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -78,6 +78,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -104,6 +105,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import com.hermesandroid.relay.ui.theme.accentColor
import com.hermesandroid.relay.ui.theme.normalizeAccentHex
@@ -247,7 +249,10 @@ fun SessionDrawerContent(
var query by remember { mutableStateOf("") }
var searchExpanded by remember { mutableStateOf(false) }
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
var showAllProfiles by remember { mutableStateOf(false) }
// App-language changes recreate the Activity. Keep the drawer's namespace
// mode so an open All Profiles browser does not silently become the normal
// selected-profile list during that recreation.
var showAllProfiles by rememberSaveable { mutableStateOf(false) }
var customizeOpen by remember { mutableStateOf(false) }
var viewOptions by remember { mutableStateOf(SessionDrawerViewOptions()) }
val listState = rememberLazyListState()
@@ -737,7 +742,7 @@ fun SessionDrawerContent(
actionsEnabled = !provisional,
isActive = !showAllProfiles && session.sessionId == currentSessionId,
activityState = activityState,
animationEnabled = animationEnabled,
animationEnabled = animationEnabled && isOpen,
pinned = session.pinned,
archived = session.archived,
archiveSupported = archiveSupported,
@@ -1233,7 +1238,7 @@ private fun ProjectGroupHeader(
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(40.dp),
) {
@@ -1390,7 +1395,7 @@ private fun SessionItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.clip(appearanceRoundedCornerShape(6.dp))
.background(RelayRefresh.Relay.copy(alpha = 0.16f))
.padding(horizontal = 6.dp, vertical = 1.dp),
) {
@@ -1567,7 +1572,7 @@ private fun SessionWorkBadgeChip(badge: SessionWorkBadge) {
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.clip(appearanceRoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 6.dp, vertical = 1.dp)
.semantics { contentDescription = "$kindLabel: ${badge.label}" },
@@ -1600,7 +1605,7 @@ private fun ProfileBadge(
val isDefault = accent == null
val foreground = accent ?: MaterialTheme.colorScheme.onSurfaceVariant
Surface(
shape = RoundedCornerShape(7.dp),
shape = appearanceRoundedCornerShape(7.dp),
color = if (accent == null) {
MaterialTheme.colorScheme.surfaceVariant
} else {
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
@@ -21,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
/**
* Reusable expandable card used across the Settings sub-screens. Header row
@@ -42,7 +42,7 @@ fun SettingsExpandableCard(
modifier = modifier
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme
),
colors = CardDefaults.cardColors(
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.reliability.SupportBundleBuilder
import com.hermesandroid.relay.util.IssueReport
@@ -63,7 +63,7 @@ fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = RoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
@@ -81,7 +81,7 @@ fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
.heightIn(min = 160.dp, max = 420.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
RoundedCornerShape(12.dp),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -45,6 +44,7 @@ import java.text.DateFormat
import java.util.Date
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
/**
* Bottom-sheet dialog showing full metadata for a single terminal tab plus
@@ -329,7 +329,7 @@ private fun StatusChip(label: String, isPositive: Boolean) {
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(bg)
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
@@ -373,7 +373,7 @@ private fun GrantChipLocal(channel: String, expiresAt: Long?) {
val neverText = stringResource(R.string.term_info_never)
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.viewmodel.TerminalViewModel
/**
@@ -121,7 +122,7 @@ private fun TerminalTabChip(
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val shape = RoundedCornerShape(8.dp)
val shape = appearanceRoundedCornerShape(8.dp)
Row(
modifier = Modifier
.height(32.dp)
@@ -8,7 +8,6 @@ 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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Warning
@@ -33,6 +32,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
/**
* v0.4.1 — sideload-only "unattended access" toggle row + one-time
@@ -70,6 +70,8 @@ fun UnattendedAccessRow(
// should reflect that reality — otherwise users flip it and see no
// observable change, which reads as a broken control.
masterEnabled: Boolean = true,
screenControlAvailable: Boolean = true,
screenAccessUnlimited: Boolean = false,
) {
var showWarning by remember { mutableStateOf(false) }
var pendingEnableAfterWarning by remember { mutableStateOf(false) }
@@ -77,11 +79,11 @@ fun UnattendedAccessRow(
// "Effectively on" — the persisted preference AND the master gate.
// Drives the keyguard warning (no point showing it when master is
// off — the feature isn't active regardless of lock state).
val effectivelyOn = enabled && masterEnabled
val effectivelyOn = enabled && masterEnabled && screenControlAvailable
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
shape = appearanceRoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
@@ -104,6 +106,7 @@ fun UnattendedAccessRow(
Text(
text = when {
!masterEnabled -> stringResource(R.string.unattended_requires_master)
!screenControlAvailable -> stringResource(R.string.unattended_requires_timed_control)
enabled -> stringResource(R.string.unattended_on)
else -> stringResource(R.string.unattended_off)
},
@@ -113,7 +116,7 @@ fun UnattendedAccessRow(
}
Switch(
checked = enabled,
enabled = masterEnabled,
enabled = masterEnabled && screenControlAvailable,
onCheckedChange = { wantsOn ->
if (wantsOn && !warningSeen) {
// First enable → show the scary dialog and
@@ -129,7 +132,13 @@ fun UnattendedAccessRow(
}
Text(
text = stringResource(R.string.unattended_description),
text = stringResource(
if (screenAccessUnlimited) {
R.string.unattended_description_unlimited
} else {
R.string.unattended_description
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -184,7 +193,7 @@ fun UnattendedAccessRow(
private fun KeyguardDetectedAlert() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
@@ -101,6 +101,7 @@ import kotlinx.coroutines.flow.SharedFlow
import java.io.File
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
/**
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
@@ -505,7 +506,7 @@ fun VoiceModeOverlay(
.padding(top = 64.dp, start = 16.dp, end = 16.dp),
) {
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.errorContainer,
tonalElevation = 2.dp,
) {
@@ -1014,7 +1015,7 @@ private fun VoiceSessionPill(
val profileText = profileName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_default_profile)
Surface(
modifier = modifier,
shape = RoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp),
// Fully opaque panel — the overlay floats over live chat/sphere, so a
// translucent surface let the background bleed through and made the
// dropdown text hard to read.
@@ -1339,7 +1340,7 @@ private fun VoiceRouteSummary(
val providerLine = provider.replace(" / ", " · ")
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
@@ -1396,7 +1397,7 @@ private fun VoiceHandoffStrip(
}
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.background(containerColor)
.padding(horizontal = 10.dp, vertical = if (compact) 6.dp else 8.dp),
verticalArrangement = Arrangement.spacedBy(if (compact) 2.dp else 4.dp),
@@ -1665,7 +1666,7 @@ private fun VoiceRichResultAffordance(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onViewConversation),
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.48f),
) {
Row(
@@ -1680,7 +1681,7 @@ private fun VoiceRichResultAffordance(
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp)),
.clip(appearanceRoundedCornerShape(8.dp)),
)
} else {
Icon(
@@ -1756,7 +1757,7 @@ private fun VoiceToolStatusRow(toolCall: ToolCall) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) {
@@ -1820,7 +1821,7 @@ private fun HermesConfirmationCard(
) {
confirmation?.let { state ->
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
tonalElevation = 2.dp,
) {
@@ -2004,7 +2005,7 @@ private fun BackgroundRunChip(
}.joinToString(" · ")
Surface(
shape = RoundedCornerShape(16.dp),
shape = appearanceRoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
// Tap on a settled chip = respeak the delivered answer (the VM
// no-ops the tap for live phases).
@@ -2091,7 +2092,7 @@ private fun PermissionDeniedChip(
val current = callout ?: lastShown
if (current != null) {
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier
@@ -190,17 +190,23 @@ fun VoiceWaveform(
// Three phase accumulators driven by a single per-frame ticker. Phase
// velocity scales with the current amplitude so the wave visibly surges
// when the user speaks instead of running on its own fixed clock.
val phases = rememberAmplitudeDrivenPhases(phaseDurationsMs, displayAmplitude)
val waitingForOutputAudio = state == VoiceState.Speaking && !outputAudioActive
val processing = state == VoiceState.Transcribing ||
state == VoiceState.Thinking ||
waitingForOutputAudio
val waveformMotionActive = compactBars || state == VoiceState.Listening ||
(state == VoiceState.Speaking && outputAudioActive)
val phases = rememberAmplitudeDrivenPhases(
phaseDurationsMs,
displayAmplitude,
active = waveformMotionActive,
)
val waveformUnfold by animateFloatAsState(
targetValue = if (processing) 0f else 1f,
animationSpec = tween(durationMillis = 360),
label = "waveformUnfold",
)
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
val spinnerPhase = rememberProcessingSpinnerPhase(active = processing)
val canvasModifier = if (compactBars) {
modifier.height(height)
@@ -361,10 +367,12 @@ fun VoiceWaveform(
private fun rememberAmplitudeDrivenPhases(
baseDurationsMs: IntArray,
amplitude: Float,
active: Boolean,
): FloatArray {
val ampRef = rememberUpdatedState(amplitude)
var phases by remember { mutableStateOf(FloatArray(baseDurationsMs.size)) }
LaunchedEffect(Unit) {
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
val twoPi = (2f * PI).toFloat()
// Precompute base angular velocities (rad/s) so we don't divide on
// every frame. Each wave's full cycle at silence = durationMs.
@@ -400,9 +408,9 @@ private fun rememberAmplitudeDrivenPhases(
@Composable
private fun rememberProcessingSpinnerPhase(active: Boolean): Float {
val activeRef = rememberUpdatedState(active)
var phase by remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
var prevNanos = 0L
while (true) {
withFrameNanos { nanos ->
@@ -412,9 +420,7 @@ private fun rememberProcessingSpinnerPhase(active: Boolean): Float {
}
val dtSec = (nanos - prevNanos) / 1_000_000_000f
prevNanos = nanos
if (activeRef.value) {
phase = (phase + dtSec * 220f) % 360f
}
phase = (phase + dtSec * 220f) % 360f
}
}
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
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.filled.Close
@@ -32,6 +31,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.serialization.SerialName
@@ -77,7 +77,7 @@ fun WhatsNewDialog(
.padding(horizontal = 20.dp, vertical = 32.dp)
.widthIn(max = 560.dp)
.heightIn(max = 680.dp),
shape = RoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -118,7 +118,7 @@ fun WhatsNewDialog(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(16.dp),
shape = appearanceRoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Tune
@@ -48,7 +49,6 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
@@ -100,6 +100,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.AppLanguage
import com.hermesandroid.relay.data.CustomThemePreset
import com.hermesandroid.relay.data.MAX_PET_SIZE_SCALE
import com.hermesandroid.relay.data.MIN_PET_SIZE_SCALE
import com.hermesandroid.relay.ui.components.LocalAvailableSphereSkins
@@ -125,13 +126,19 @@ import com.hermesandroid.relay.ui.theme.AppTheme
import com.hermesandroid.relay.ui.theme.AppThemes
import com.hermesandroid.relay.ui.theme.AccentSwatches
import com.hermesandroid.relay.ui.theme.AppearanceShape
import com.hermesandroid.relay.ui.theme.AppearanceShapeScale
import com.hermesandroid.relay.ui.theme.BrandPalette
import com.hermesandroid.relay.ui.theme.LocalAppearanceShapeScale
import com.hermesandroid.relay.ui.theme.ThemeMode
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.ui.theme.contrastRatio
import com.hermesandroid.relay.ui.theme.toColorScheme
import com.hermesandroid.relay.ui.theme.toAppTheme
import com.hermesandroid.relay.ui.theme.toBrandPalette
import com.hermesandroid.relay.ui.theme.withAccent
import com.hermesandroid.relay.ui.theme.appearanceShapes
import com.hermesandroid.relay.ui.theme.appearanceComposerShape
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.appearanceShapeScale
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -165,25 +172,20 @@ fun AppearanceSettingsScreen(
onBack: () -> Unit,
onBrowsePetdex: () -> Unit = {},
onCreatePet: () -> Unit = {},
onOpenCustomTheme: () -> Unit = {},
initialCustomizerExpanded: Boolean = false,
) {
val theme by connectionViewModel.theme.collectAsState()
val appThemeId by connectionViewModel.appTheme.collectAsState()
val selectedTheme = AppThemes.byId(appThemeId)
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
val customThemes by connectionViewModel.customThemes.collectAsState()
val selectedTheme = activeCustomTheme?.toAppTheme() ?: AppThemes.byId(appThemeId)
val isDarkTheme = LocalBrand.current.isDark
val appliedAccent by connectionViewModel.appearanceAccent.collectAsState()
val appliedShape by connectionViewModel.appearanceShape.collectAsState()
var customizeExpanded by remember { mutableStateOf(initialCustomizerExpanded) }
var draftAccent by remember { mutableStateOf(appliedAccent) }
var draftShape by remember { mutableStateOf(appliedShape) }
LaunchedEffect(appliedAccent, appliedShape) {
if (!customizeExpanded) {
draftAccent = appliedAccent
draftShape = appliedShape
}
}
val previewPalette = selectedTheme.paletteFor(isDarkTheme)
.withAccent(if (customizeExpanded) draftAccent else appliedAccent)
val previewPalette = activeCustomTheme?.toBrandPalette()
?: selectedTheme.paletteFor(isDarkTheme).withAccent(appliedAccent)
val snackbarHostState = remember { SnackbarHostState() }
var pendingDelete by remember { mutableStateOf<AgentAvatar?>(null) }
@@ -259,14 +261,7 @@ fun AppearanceSettingsScreen(
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = {
connectionViewModel.setAppTheme(AppThemes.DEFAULT_ID)
connectionViewModel.setTheme("auto")
connectionViewModel.setAppearanceAccent(null)
connectionViewModel.setAppearanceShape(AppearanceShape.DEFAULT.id)
draftAccent = null
draftShape = AppearanceShape.DEFAULT.id
}) {
TextButton(onClick = connectionViewModel::resetAppearanceTheme) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.appearance_reset), modifier = Modifier.padding(start = 6.dp))
}
@@ -274,50 +269,6 @@ fun AppearanceSettingsScreen(
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (customizeExpanded) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 4.dp,
shadowElevation = 8.dp,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
stringResource(R.string.appearance_accent_preview_only_short),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
maxLines = 1,
)
TextButton(onClick = {
draftAccent = appliedAccent
draftShape = appliedShape
customizeExpanded = false
}) {
Text(stringResource(R.string.appearance_cancel), style = MaterialTheme.typography.labelLarge)
}
Button(
onClick = {
connectionViewModel.setAppearanceAccent(draftAccent)
connectionViewModel.setAppearanceShape(draftShape)
customizeExpanded = false
},
) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Text(
stringResource(R.string.appearance_apply_changes),
modifier = Modifier.padding(start = 6.dp),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
},
) { innerPadding ->
Column(
modifier = Modifier
@@ -333,7 +284,7 @@ fun AppearanceSettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AppearanceLivePreview(previewPalette, appearanceShapes(if (customizeExpanded) draftShape else appliedShape))
AppearanceLivePreview(previewPalette, appearanceShapeScale(appliedShape))
// Preset-first gallery, matching the live preview above.
Text(
@@ -346,41 +297,94 @@ fun AppearanceSettingsScreen(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val customSwatch = activeCustomTheme?.toAppTheme() ?: AppTheme(
id = CustomThemePreset.APP_THEME_PREFIX,
label = stringResource(R.string.custom_theme_title),
description = stringResource(R.string.custom_theme_entry_summary),
mode = ThemeMode.BOTH,
darkPalette = LocalBrand.current,
lightPalette = LocalBrand.current,
swatch = listOf(
LocalBrand.current.background,
LocalBrand.current.electric,
LocalBrand.current.ink,
),
)
ThemeSwatchChip(
appTheme = customSwatch,
selected = activeCustomTheme != null,
onClick = onOpenCustomTheme,
)
AppThemes.ALL.forEach { appTheme ->
ThemeSwatchChip(
appTheme = appTheme,
selected = appTheme.id == selectedTheme.id,
selected = activeCustomTheme == null && appTheme.id == selectedTheme.id,
onClick = { connectionViewModel.setAppTheme(appTheme.id) },
)
}
}
AppearanceModeControl(
theme = theme,
theme = activeCustomTheme?.mode ?: theme,
selectedTheme = selectedTheme,
isDarkTheme = isDarkTheme,
onThemeModeSelected = connectionViewModel::setTheme,
enabledModes = activeCustomTheme?.let { setOf("light", "dark") },
description = activeCustomTheme?.let {
stringResource(R.string.custom_theme_mode_summary)
},
onThemeModeSelected = { mode ->
val customTheme = activeCustomTheme
if (customTheme == null) {
connectionViewModel.setTheme(mode)
} else {
connectionViewModel.saveCustomTheme(customTheme.copy(mode = mode))
}
},
)
AccentCustomizer(
selectedTheme = selectedTheme,
expanded = customizeExpanded,
draftAccent = draftAccent,
draftShape = draftShape,
onToggle = {
if (!customizeExpanded) {
draftAccent = appliedAccent
draftShape = appliedShape
if (activeCustomTheme == null) {
AccentCustomizer(
selectedTheme = selectedTheme,
expanded = customizeExpanded,
selectedAccent = appliedAccent,
selectedShape = appliedShape,
onToggle = { customizeExpanded = !customizeExpanded },
onAccentSelected = connectionViewModel::setAppearanceAccent,
onShapeSelected = connectionViewModel::setAppearanceShape,
onReset = {
connectionViewModel.setAppearanceAccent(null)
connectionViewModel.setAppearanceShape(AppearanceShape.DEFAULT.id)
},
)
} else {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpenCustomTheme),
shape = appearanceRoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Tune, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(activeCustomTheme?.name.orEmpty(), style = MaterialTheme.typography.titleSmall)
Text(
stringResource(R.string.custom_theme_saved_count, customThemes.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
customizeExpanded = !customizeExpanded
},
onDraftSelected = { draftAccent = it },
onShapeSelected = { draftShape = it },
onReset = {
draftAccent = null
draftShape = AppearanceShape.DEFAULT.id
},
)
}
}
// Language section — AppCompat keeps this synchronized with the
// Android 13+ per-app language setting and persists it on older OSes.
@@ -395,7 +399,7 @@ fun AppearanceSettingsScreen(
.appearancePetSurface("language")
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme,
),
colors = CardDefaults.cardColors(
@@ -468,7 +472,7 @@ fun AppearanceSettingsScreen(
.appearancePetSurface("display")
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme
),
colors = CardDefaults.cardColors(
@@ -551,7 +555,7 @@ fun AppearanceSettingsScreen(
.appearancePetSurface("font")
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme
),
colors = CardDefaults.cardColors(
@@ -593,7 +597,7 @@ fun AppearanceSettingsScreen(
.appearancePetSurface("animation")
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme
),
colors = CardDefaults.cardColors(
@@ -725,7 +729,7 @@ fun AppearanceSettingsScreen(
.appearancePetSurface("background")
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme,
),
colors = CardDefaults.cardColors(
@@ -879,7 +883,7 @@ fun AppearanceSettingsScreen(
.appearancePetSurface("floating-pet")
.fillMaxWidth()
.gradientBorder(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme
),
colors = CardDefaults.cardColors(
@@ -1161,7 +1165,7 @@ fun AppearanceSettingsScreen(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
@@ -1238,6 +1242,8 @@ private fun AppearanceModeControl(
theme: String,
selectedTheme: AppTheme,
isDarkTheme: Boolean,
enabledModes: Set<String>? = null,
description: String? = null,
onThemeModeSelected: (String) -> Unit,
) {
val options = listOf("auto", "light", "dark")
@@ -1247,15 +1253,17 @@ private fun AppearanceModeControl(
stringResource(R.string.appearance_theme_dark),
)
val modeApplies = selectedTheme.mode == ThemeMode.BOTH
val displayedMode = resolvedAppearanceModeSelection(theme, selectedTheme.mode)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Surface(
modifier = Modifier.fillMaxWidth().height(40.dp),
shape = RoundedCornerShape(22.dp),
shape = appearanceRoundedCornerShape(22.dp),
color = Color.Transparent,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
options.forEachIndexed { index, option ->
val optionEnabled = enabledModes?.contains(option) ?: modeApplies
if (index > 0) {
VerticalDivider(Modifier.fillMaxHeight().width(1.dp))
}
@@ -1264,25 +1272,29 @@ private fun AppearanceModeControl(
.weight(1f)
.fillMaxHeight()
.background(
if (option == theme) MaterialTheme.colorScheme.surfaceContainerHigh
if (option == displayedMode) MaterialTheme.colorScheme.surfaceContainerHigh
else Color.Transparent,
)
.clickable(enabled = modeApplies) { onThemeModeSelected(option) },
.alpha(if (optionEnabled || option == displayedMode) 1f else 0.38f)
.clickable(enabled = optionEnabled) { onThemeModeSelected(option) },
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (option == theme) {
if (option == displayedMode) {
Icon(Icons.Filled.Check, null, Modifier.size(15.dp))
Spacer(Modifier.width(5.dp))
} else if (!optionEnabled) {
Icon(Icons.Filled.Lock, null, Modifier.size(14.dp))
Spacer(Modifier.width(5.dp))
}
Text(labels[index], style = MaterialTheme.typography.labelLarge)
}
}
}
}
if (!modeApplies) {
if (description != null || !modeApplies) {
Text(
text = if (selectedTheme.mode == ThemeMode.LIGHT_ONLY) {
text = description ?: if (selectedTheme.mode == ThemeMode.LIGHT_ONLY) {
stringResource(R.string.appearance_fixed_light, selectedTheme.label)
} else {
stringResource(R.string.appearance_fixed_dark, selectedTheme.label)
@@ -1294,27 +1306,34 @@ private fun AppearanceModeControl(
}
}
internal fun resolvedAppearanceModeSelection(themePreference: String, themeMode: ThemeMode): String =
when (themeMode) {
ThemeMode.BOTH -> themePreference
ThemeMode.LIGHT_ONLY -> "light"
ThemeMode.DARK_ONLY -> "dark"
}
@Composable
private fun AccentCustomizer(
selectedTheme: AppTheme,
expanded: Boolean,
draftAccent: String?,
draftShape: String,
selectedAccent: String?,
selectedShape: String,
onToggle: () -> Unit,
onDraftSelected: (String?) -> Unit,
onAccentSelected: (String?) -> Unit,
onShapeSelected: (String) -> Unit,
onReset: () -> Unit,
) {
val draftPalette = selectedTheme.paletteFor(LocalBrand.current.isDark).withAccent(draftAccent)
val scheme = draftPalette.toColorScheme()
val selectedPalette = selectedTheme.paletteFor(LocalBrand.current.isDark).withAccent(selectedAccent)
val scheme = selectedPalette.toColorScheme()
val ratio = contrastRatio(scheme.onPrimary, scheme.primary)
Card(
modifier = Modifier.fillMaxWidth().border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(12.dp),
appearanceRoundedCornerShape(12.dp),
),
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column {
@@ -1370,15 +1389,15 @@ private fun AccentCustomizer(
.clip(CircleShape)
.background(color)
.border(
width = if (draftAccent == accent) 3.dp else 1.dp,
color = if (draftAccent == accent) MaterialTheme.colorScheme.onSurface
width = if (selectedAccent == accent) 3.dp else 1.dp,
color = if (selectedAccent == accent) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.outline,
shape = CircleShape,
)
.clickable { onDraftSelected(accent) },
.clickable { onAccentSelected(accent) },
contentAlignment = Alignment.Center,
) {
if (draftAccent == accent) {
if (selectedAccent == accent) {
Icon(Icons.Filled.Check, null, tint = com.hermesandroid.relay.ui.theme.readableContentColor(color))
}
}
@@ -1394,9 +1413,9 @@ private fun AccentCustomizer(
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index, AppearanceShape.entries.size),
onClick = { onShapeSelected(shape.id) },
selected = shape.id == draftShape,
selected = shape.id == selectedShape,
icon = {
if (shape.id == draftShape) Icon(Icons.Filled.Check, null, Modifier.size(14.dp))
if (shape.id == selectedShape) Icon(Icons.Filled.Check, null, Modifier.size(14.dp))
},
) {
Text(
@@ -1470,10 +1489,13 @@ private fun AppearanceSummaryRow(
@Composable
private fun AppearanceLivePreview(
palette: BrandPalette,
shapes: androidx.compose.material3.Shapes,
shapeScale: AppearanceShapeScale,
) {
CompositionLocalProvider(LocalBrand provides palette) {
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapes) {
CompositionLocalProvider(
LocalBrand provides palette,
LocalAppearanceShapeScale provides shapeScale,
) {
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
AppearanceLivePreviewContent()
}
}
@@ -1502,7 +1524,7 @@ private fun AppearanceLivePreviewContent() {
modifier = Modifier.padding(start = 6.dp).weight(1f),
)
Surface(
shape = RoundedCornerShape(10.dp),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
@@ -1514,7 +1536,7 @@ private fun AppearanceLivePreviewContent() {
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
shape = RoundedCornerShape(9.dp),
shape = MaterialTheme.shapes.extraSmall,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
@@ -1590,7 +1612,7 @@ private fun AppearanceLivePreviewContent() {
modifier = Modifier.padding(start = 8.dp),
)
Surface(
shape = RoundedCornerShape(7.dp),
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.padding(start = 8.dp),
) {
@@ -1646,7 +1668,7 @@ private fun AppearanceLivePreviewContent() {
}
}
Surface(
shape = MaterialTheme.shapes.medium,
shape = appearanceComposerShape(),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
color = Color.Transparent,
) {
@@ -1669,16 +1691,23 @@ private fun AppearanceLivePreviewContent() {
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
}
}
Row(
Surface(
modifier = Modifier.align(Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
shape = appearanceRoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Icon(Icons.Filled.Bolt, null, Modifier.size(14.dp), tint = LocalBrand.current.amber)
Text(
text = stringResource(R.string.appearance_preview_gateway),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
)
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Bolt, null, Modifier.size(14.dp), tint = LocalBrand.current.amber)
Text(
text = stringResource(R.string.appearance_preview_gateway),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
)
}
}
}
}
@@ -1700,7 +1729,7 @@ private fun FontOptionRow(
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clip(appearanceRoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.border(
width = if (selected) 2.dp else 1.dp,
@@ -1709,7 +1738,7 @@ private fun FontOptionRow(
} else {
MaterialTheme.colorScheme.outlineVariant
},
shape = RoundedCornerShape(10.dp),
shape = appearanceRoundedCornerShape(10.dp),
)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -1766,7 +1795,7 @@ private fun ThemeSwatchChip(
Column(
modifier = Modifier
.width(77.dp)
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.border(
width = if (selected) 2.dp else 1.dp,
@@ -1775,7 +1804,7 @@ private fun ThemeSwatchChip(
} else {
MaterialTheme.colorScheme.outlineVariant
},
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
)
.padding(5.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -1785,7 +1814,7 @@ private fun ThemeSwatchChip(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(background),
contentAlignment = Alignment.TopStart,
) {
@@ -1864,7 +1893,7 @@ private fun SphereSkinChip(
Column(
modifier = Modifier
.width(110.dp)
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.border(
width = if (selected) 2.dp else 1.dp,
@@ -1873,7 +1902,7 @@ private fun SphereSkinChip(
} else {
MaterialTheme.colorScheme.outlineVariant
},
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
)
.padding(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
@@ -1882,7 +1911,7 @@ private fun SphereSkinChip(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(Brush.horizontalGradient(listOf(poleA, poleB))),
contentAlignment = Alignment.TopEnd,
) {
@@ -1936,7 +1965,7 @@ private fun AgentAvatarChip(
Column(
modifier = Modifier
.width(110.dp)
.clip(RoundedCornerShape(12.dp))
.clip(appearanceRoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.border(
width = if (selected) 2.dp else 1.dp,
@@ -1945,7 +1974,7 @@ private fun AgentAvatarChip(
} else {
MaterialTheme.colorScheme.outlineVariant
},
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
)
.padding(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
@@ -1954,7 +1983,7 @@ private fun AgentAvatarChip(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(8.dp))
.clip(appearanceRoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
@@ -61,6 +61,9 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetySettings
import com.hermesandroid.relay.bridge.BridgeCapability
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.bridge.BridgeSafetyManager
import com.hermesandroid.relay.data.DEFAULT_BLOCKLIST
import com.hermesandroid.relay.data.MAX_AUTO_DISABLE_MINUTES
import com.hermesandroid.relay.data.MAX_CONFIRMATION_TIMEOUT_SECONDS
@@ -85,11 +88,18 @@ import kotlinx.coroutines.launch
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
fun BridgeSafetySettingsScreen(
connectionId: String? = null,
onBack: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val repo = remember { BridgeSafetyPreferencesRepository(context) }
val settings by repo.settings.collectAsState(initial = BridgeSafetySettings())
val safetyManager = BridgeSafetyManager.peek()
val capabilityPolicy by (safetyManager?.capabilityPolicy(connectionId)
?: remember { kotlinx.coroutines.flow.MutableStateFlow(BridgeCapabilityPolicy()) })
.collectAsState(initial = BridgeCapabilityPolicy())
// Overlay-permission live check — recompute on resume so returning
// from Settings flips the switch's availability without nav churn.
@@ -140,6 +150,22 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CapabilityGrantCards(
policy = capabilityPolicy,
timerMinutes = settings.autoDisableMinutes,
enabled = safetyManager != null && connectionId != null,
onPermanentChanged = { capability, allowed ->
scope.launch {
safetyManager?.setPermanentCapability(connectionId, capability, allowed)
}
},
onTimedChanged = { capability, allowed ->
scope.launch {
safetyManager?.setTimedCapability(connectionId, capability, allowed)
}
},
)
// ── Blocklist ───────────────────────────────────────────────
SectionCard(title = stringResource(R.string.bss_blocked_apps)) {
Text(
@@ -359,6 +385,152 @@ fun BridgeSafetySettingsScreen(onBack: () -> Unit) {
}
}
@Composable
internal fun CapabilityGrantCards(
policy: BridgeCapabilityPolicy,
timerMinutes: Int,
enabled: Boolean,
onPermanentChanged: (BridgeCapability, Boolean) -> Unit,
onTimedChanged: (BridgeCapability, Boolean) -> Unit,
) {
SectionCard(title = stringResource(R.string.bridge_access_read_group)) {
Text(
text = stringResource(R.string.bridge_access_read_group_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!enabled) {
Text(
text = stringResource(R.string.bss_capabilities_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
Spacer(Modifier.size(8.dp))
listOf(
BridgeCapability.DEVICE_INFO,
BridgeCapability.CONTACTS_READ,
BridgeCapability.LOCATION_READ,
BridgeCapability.CLIPBOARD_READ,
).forEach { capability ->
val allowed = capability in policy.permanentGrants
CapabilityRow(
capability = capability,
checked = allowed,
stateLabel = stringResource(
if (allowed) R.string.bss_capability_always else R.string.bss_capability_never,
),
enabled = enabled,
onCheckedChange = { onPermanentChanged(capability, it) },
)
}
}
SectionCard(title = stringResource(R.string.bridge_access_actions_group)) {
Text(
text = stringResource(R.string.bridge_access_actions_group_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
listOf(
BridgeCapability.CLIPBOARD_WRITE,
BridgeCapability.MEDIA_CONTROL,
BridgeCapability.COMMUNICATIONS,
BridgeCapability.OUTBOUND_SHARING,
).forEach { capability ->
val allowed = capability in policy.permanentGrants
CapabilityRow(
capability = capability,
checked = allowed,
stateLabel = stringResource(
if (allowed) R.string.bss_capability_always else R.string.bss_capability_never,
),
enabled = enabled,
onCheckedChange = { onPermanentChanged(capability, it) },
)
}
}
SectionCard(title = stringResource(R.string.bss_timed_capabilities_title)) {
val now = System.currentTimeMillis()
val activeScreenCapabilities = BridgeCapability.entries
.filter { it.timed && policy.allows(it, now) }
val hasUnlimited = activeScreenCapabilities.any(policy::isUnlimited)
Text(
text = when {
hasUnlimited -> stringResource(R.string.bss_screen_access_unlimited_desc)
activeScreenCapabilities.isNotEmpty() ->
stringResource(R.string.bss_timed_capabilities_desc, timerMinutes)
else -> stringResource(R.string.bss_screen_access_off_desc, timerMinutes)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
BridgeCapability.entries.filter { it.timed }.forEach { capability ->
val active = (policy.expiryFor(capability) ?: 0L) > now
CapabilityRow(
capability = capability,
checked = active,
stateLabel = when {
policy.isUnlimited(capability) ->
stringResource(R.string.bridge_timed_until_off)
active -> stringResource(R.string.bss_capability_timed_on)
else -> stringResource(R.string.bss_capability_timed_off)
},
enabled = enabled,
onCheckedChange = { onTimedChanged(capability, it) },
)
}
}
}
@Composable
private fun CapabilityRow(
capability: BridgeCapability,
checked: Boolean,
stateLabel: String,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(vertical = 6.dp)) {
Text(
text = stringResource(capability.titleResource()),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stateLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
)
}
}
private fun BridgeCapability.titleResource(): Int = when (this) {
BridgeCapability.DEVICE_INFO -> R.string.bss_capability_device_info
BridgeCapability.CONTACTS_READ -> R.string.bss_capability_contacts
BridgeCapability.LOCATION_READ -> R.string.bss_capability_location
BridgeCapability.CLIPBOARD_READ -> R.string.bss_capability_clipboard_read
BridgeCapability.CLIPBOARD_WRITE -> R.string.bss_capability_clipboard_write
BridgeCapability.MEDIA_CONTROL -> R.string.bss_capability_media
BridgeCapability.COMMUNICATIONS -> R.string.bss_capability_communications
BridgeCapability.OUTBOUND_SHARING -> R.string.bss_capability_sharing
BridgeCapability.SCREEN_INSPECTION -> R.string.bss_capability_screen_inspection
BridgeCapability.SCREEN_CONTROL -> R.string.bss_capability_screen_control
}
@Composable
private fun SectionCard(title: String, content: @Composable () -> Unit) {
Card(
@@ -44,6 +44,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -61,12 +62,23 @@ import androidx.lifecycle.viewmodel.compose.viewModel
// === PHASE3-safety-rails: safety summary card ===
import com.hermesandroid.relay.bridge.BridgeSafetyManager
import com.hermesandroid.relay.data.BridgeSafetySettings
import com.hermesandroid.relay.bridge.BridgeCapabilityPolicy
import com.hermesandroid.relay.ui.components.BridgeSafetySummaryCard
// === END PHASE3-safety-rails ===
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.components.BridgeActivityLog
import com.hermesandroid.relay.ui.components.BridgeMasterToggle
import com.hermesandroid.relay.ui.components.BridgePermissionChecklist
import com.hermesandroid.relay.ui.components.BridgeAccessPreset
import com.hermesandroid.relay.ui.components.BridgeAccessSetupSheet
import com.hermesandroid.relay.ui.components.BridgeAgentAccessCard
import com.hermesandroid.relay.ui.components.BridgeAndroidAccessSummaryCard
import com.hermesandroid.relay.ui.components.BridgeTimedAccessSheet
import com.hermesandroid.relay.ui.components.BridgeSelectedAndroidAccessCard
import com.hermesandroid.relay.ui.components.READ_CONFIRMED_BRIDGE_CAPABILITIES
import com.hermesandroid.relay.ui.components.READ_ONLY_BRIDGE_CAPABILITIES
import com.hermesandroid.relay.ui.components.activeTimedCapabilities
import com.hermesandroid.relay.ui.components.bridgeAndroidAccessSummary
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayHeroPanel
import com.hermesandroid.relay.ui.components.RelayReturnStrip
@@ -84,13 +96,15 @@ import kotlinx.coroutines.launch
* Bridge tab — phase 3 Wave 1 rewrite (Agent bridge-ui, `bridge-screen-ui`).
*
* Replaces the Phase 0 "Coming Soon" placeholder with the real control
* surface described in `Plans/Phase 3 — Bridge Channel.md` §5. Four stacked
* cards in a verticalScroll column:
* surface described in `Plans/Phase 3 — Bridge Channel.md` §5. The main page
* is a summary-first cockpit with complete drill-downs:
*
* 1. [BridgeMasterToggle] — "Allow Agent Control" + live status
* 2. [BridgePermissionChecklist] — accessibility / capture / overlay / notif
* 3. [BridgeActivityLog] — scrollable recent-command history
* 4. Safety placeholder — stub owned by Agent safety-rails in Wave 2
* 1. [BridgeMasterToggle] — global kill switch + live device status
* 2. [BridgeAgentAccessCard] — permanent and screen-access policy posture
* 3. Unattended access — single authoritative sideload control
* 4. Android readiness summary — selected requirements + expandable full matrix
* 5. Advanced safety controls — complete power-user controls
* 6. [BridgeActivityLog] — scrollable recent-command history
*
* State comes from [BridgeViewModel] which in turn reads from the
* [com.hermesandroid.relay.data.BridgePreferencesRepository] DataStore for
@@ -151,6 +165,9 @@ fun BridgeScreen(
// === END PHASE3-safety-rails-followup ===
val context = LocalContext.current
val accessScope = androidx.compose.runtime.rememberCoroutineScope()
val accessSavedMessage = stringResource(R.string.bridge_access_saved_review_android)
val timedAccessEndedMessage = stringResource(R.string.bridge_timed_ended_snackbar)
// Result callback used by every runtime-permission launcher on this screen.
// If the user has permanently denied the permission (two declines on
@@ -197,6 +214,63 @@ fun BridgeScreen(
val trustedVerbs by (safetyManager?.trustedDestructiveVerbs
?: remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) })
.collectAsState()
val activeConnectionId by (connectionViewModel?.activeConnectionId
?: remember { kotlinx.coroutines.flow.MutableStateFlow<String?>(null) })
.collectAsState()
val activeCapabilityPolicy by (safetyManager?.activeCapabilityPolicy
?: remember { kotlinx.coroutines.flow.MutableStateFlow(BridgeCapabilityPolicy()) })
.collectAsState()
val screenControlAvailable = activeCapabilityPolicy.allows(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
System.currentTimeMillis(),
)
val screenControlUnlimited = activeCapabilityPolicy.isUnlimited(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
)
val screenAccessActive = activeCapabilityPolicy.activeTimedCapabilities(
System.currentTimeMillis(),
).isNotEmpty()
val anyScreenAccessUnlimited = activeCapabilityPolicy.activeTimedCapabilities(
System.currentTimeMillis(),
).any(activeCapabilityPolicy::isUnlimited)
val overlayRequired = screenControlAvailable ||
com.hermesandroid.relay.bridge.BridgeCapability.COMMUNICATIONS in
activeCapabilityPolicy.permanentGrants ||
com.hermesandroid.relay.bridge.BridgeCapability.OUTBOUND_SHARING in
activeCapabilityPolicy.permanentGrants
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
if (autoDisableAtMs != null) {
LaunchedEffect(autoDisableAtMs) {
while (true) {
nowMs = System.currentTimeMillis()
kotlinx.coroutines.delay(1_000L)
}
}
}
val androidAccessSummary = bridgeAndroidAccessSummary(
policy = activeCapabilityPolicy,
status = permissionStatus,
nowMs = nowMs,
)
var permissionsExpanded by remember { mutableStateOf(false) }
var showSetupSheet by remember { mutableStateOf(false) }
var selectedPreset by remember { mutableStateOf(BridgeAccessPreset.READ_ONLY) }
var showTimedSheet by remember { mutableStateOf(false) }
var timedInspect by remember { mutableStateOf(true) }
var timedControl by remember { mutableStateOf(true) }
var timedMinutes by remember { mutableStateOf(safetySettings.autoDisableMinutes) }
var timedUnlimited by remember { mutableStateOf(false) }
val timedCurrentlyActive = activeCapabilityPolicy.activeTimedCapabilities(nowMs).isNotEmpty()
fun openTimedSheet() {
val current = activeCapabilityPolicy.activeTimedCapabilities(nowMs)
timedInspect = if (current.isEmpty()) true else
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_INSPECTION in current
timedControl = if (current.isEmpty()) true else
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL in current
timedMinutes = safetySettings.autoDisableMinutes
timedUnlimited = current.any(activeCapabilityPolicy::isUnlimited)
showTimedSheet = true
}
// === END PHASE3-safety-rails ===
// Re-run permission + system-status probes whenever the screen resumes.
@@ -328,6 +402,7 @@ fun BridgeScreen(
// needed and the nag would confuse users + reviewers.
if (BuildFlavor.isSideload &&
masterToggle &&
overlayRequired &&
!permissionStatus.overlayPermitted
) {
OverlayPermissionNagCard(
@@ -397,58 +472,20 @@ fun BridgeScreen(
stringResource(R.string.bridge_enable_bridge_mode),
)
// 2. Permissions — prerequisites come before advanced features.
BridgePermissionChecklist(
status = permissionStatus,
// === PHASE3-safety-rails-followup: in-app permission Test handlers ===
onTestAccessibility = { viewModel.testAccessibilityService() },
onTestScreenCapture = { viewModel.testScreenCapture() },
onTestOverlay = { viewModel.testOverlayPermission() },
// === END PHASE3-safety-rails-followup ===
// === PHASE3-bridge-ui-followup: extended interactions ===
onRequestScreenCapture = { viewModel.requestScreenCapture() },
onTestNotificationListener = { viewModel.testNotificationListener() },
// === END PHASE3-bridge-ui-followup ===
onRequestNotifications = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Same "tap always goes to Settings" treatment as the
// other runtime-permission rows. The master-toggle
// auto-request path (BridgeMasterToggle onToggle) still
// uses the launcher directly since that's a programmatic
// flow where a dialog is appropriate.
{ openAppDetailsSettings(context) }
} else null,
// === v0.4.1 polish: runtime-permission row taps go to Settings ====
// Earlier iteration tried launcher.launch(permission) with a
// post-denial fallback to app-details Settings. That
// fallback depended on (context as? Activity) succeeding
// which quietly returned null in the Compose context chain
// — so taps after a permanent denial were a silent no-op.
// Simpler + matches user expectation: tap ALWAYS opens the
// app-details Settings page. Parity with Accessibility /
// Notification Listener / Overlay rows which also open
// Settings unconditionally. First-time grant is one extra
// tap vs. a system dialog — acceptable trade-off for
// "tap always does something visible".
onRequestMicrophone = { openAppDetailsSettings(context) },
onRequestCamera = { openAppDetailsSettings(context) },
onRequestContacts = { openAppDetailsSettings(context) },
onRequestSms = { openAppDetailsSettings(context) },
onRequestPhone = { openAppDetailsSettings(context) },
onRequestLocation = { openAppDetailsSettings(context) },
// === END v0.4.1 polish ====
// 2. Capability policy stays visible directly under the master.
// Detailed toggles remain one tap away on the full safety screen.
BridgeAgentAccessCard(
policy = activeCapabilityPolicy,
nowMs = nowMs,
onSetUp = { showSetupSheet = true },
onManage = onNavigateToBridgeSafety,
onAllowScreen = { openTimedSheet() },
)
// 3. Advanced section — unattended access + safety. Sideload
// only — these features don't exist on googlePlay (no wake
// lock, no destructive-verb routes).
// 3. One authoritative unattended control, kept beside the
// access policy it extends. Do not duplicate this state inside
// Agent access or Advanced: one switch owns one mode.
if (BuildFlavor.isSideload) {
AdvancedSectionHeader()
// 4. Unattended access — a SUB-FEATURE of the master
// toggle. Gated: Switch is non-interactive when the
// master toggle is off so users can't flip it and
// observe nothing happening (the acquire path
// short-circuits when master is off anyway).
UnattendedAccessRow(
enabled = unattendedEnabled,
warningSeen = unattendedWarningSeen,
@@ -456,15 +493,76 @@ fun BridgeScreen(
onToggle = { viewModel.setUnattendedAccessEnabled(it) },
onWarningSeen = { viewModel.markUnattendedWarningSeen() },
masterEnabled = masterToggle,
screenControlAvailable = screenControlAvailable,
screenAccessUnlimited = screenControlUnlimited,
)
}
// 5. Safety summary — auto-disable, destructive verbs,
// blocklist. Belongs adjacent to unattended because
// they share the "advanced / opt-in / sideload-only"
// mental model.
// 4. Android permission state is summarized against the selected
// policy. Expanding preserves the complete existing matrix and
// every Settings/Test action—no power-user surface is removed.
BridgeAndroidAccessSummaryCard(
summary = androidAccessSummary,
expanded = permissionsExpanded,
onToggle = { permissionsExpanded = !permissionsExpanded },
)
if (permissionsExpanded) {
BridgeSelectedAndroidAccessCard(
summary = androidAccessSummary,
onOpenAccessibility = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
},
onOpenAppSettings = { openAppDetailsSettings(context) },
onOpenOverlay = {
runCatching {
context.startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}"),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
)
}
},
)
BridgePermissionChecklist(
status = permissionStatus,
onTestAccessibility = { viewModel.testAccessibilityService() },
onTestScreenCapture = { viewModel.testScreenCapture() },
onTestOverlay = { viewModel.testOverlayPermission() },
onRequestScreenCapture = { viewModel.requestScreenCapture() },
onTestNotificationListener = { viewModel.testNotificationListener() },
onRequestNotifications = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
) {
{ openAppDetailsSettings(context) }
} else null,
onRequestMicrophone = { openAppDetailsSettings(context) },
onRequestCamera = { openAppDetailsSettings(context) },
onRequestContacts = { openAppDetailsSettings(context) },
onRequestSms = { openAppDetailsSettings(context) },
onRequestPhone = { openAppDetailsSettings(context) },
onRequestLocation = { openAppDetailsSettings(context) },
)
}
// 5. Advanced safety controls. Sideload only — these features
// don't exist on googlePlay (no destructive-verb routes).
if (BuildFlavor.isSideload) {
AdvancedSectionHeader()
// Safety summary — auto-disable, destructive verbs,
// blocklist, and the complete granular editor.
BridgeSafetySummaryCard(
settings = safetySettings,
autoDisableAtMs = autoDisableAtMs,
screenAccessActive = screenAccessActive,
screenAccessUnlimited = anyScreenAccessUnlimited,
onManage = onNavigateToBridgeSafety,
)
@@ -492,6 +590,102 @@ fun BridgeScreen(
Spacer(modifier = Modifier.height(16.dp))
}
}
if (showSetupSheet) {
BridgeAccessSetupSheet(
selected = selectedPreset,
onSelected = { selectedPreset = it },
onDismiss = { showSetupSheet = false },
onContinue = {
when (selectedPreset) {
BridgeAccessPreset.CUSTOM -> {
showSetupSheet = false
onNavigateToBridgeSafety()
}
BridgeAccessPreset.READ_ONLY,
BridgeAccessPreset.READ_CONFIRMED -> accessScope.launch {
val grants = if (selectedPreset == BridgeAccessPreset.READ_ONLY) {
READ_ONLY_BRIDGE_CAPABILITIES
} else {
READ_CONFIRMED_BRIDGE_CAPABILITIES
}
safetyManager?.replacePermanentCapabilities(activeConnectionId, grants)
showSetupSheet = false
permissionsExpanded = true
snackbarHost.showSnackbar(accessSavedMessage)
}
}
},
)
}
if (showTimedSheet) {
BridgeTimedAccessSheet(
inspectEnabled = timedInspect,
controlEnabled = timedControl,
durationMinutes = timedMinutes,
unlimited = timedUnlimited,
accessibilityReady = permissionStatus.accessibilityServiceEnabled,
overlayReady = permissionStatus.overlayPermitted,
currentlyActive = timedCurrentlyActive,
onInspectChanged = { timedInspect = it },
onControlChanged = { timedControl = it },
onDurationChanged = { timedMinutes = it },
onUnlimitedChanged = { timedUnlimited = it },
onOpenAccessibility = {
runCatching {
context.startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
},
onOpenOverlay = {
runCatching {
context.startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}"),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
)
}
},
onDismiss = { showTimedSheet = false },
onAllow = {
accessScope.launch {
val capabilities = buildSet {
if (timedInspect) add(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_INSPECTION,
)
if (timedControl) add(
com.hermesandroid.relay.bridge.BridgeCapability.SCREEN_CONTROL,
)
}
viewModel.setTimedAccessMinutes(timedMinutes)
safetyManager?.replaceTimedCapabilities(
activeConnectionId,
capabilities,
timedMinutes,
unlimited = timedUnlimited,
)
showTimedSheet = false
}
},
onEndNow = {
accessScope.launch {
safetyManager?.replaceTimedCapabilities(
activeConnectionId,
emptySet(),
timedMinutes,
)
viewModel.setUnattendedAccessEnabled(false)
showTimedSheet = false
snackbarHost.showSnackbar(timedAccessEndedMessage)
}
},
)
}
}
/**
@@ -187,6 +187,11 @@ import com.hermesandroid.relay.ui.components.BackgroundTaskCard
import com.hermesandroid.relay.ui.components.LocalRelayServerImageResolver
import com.hermesandroid.relay.ui.components.RelayServerImageResolver
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatFailureDetailsDialog
import com.hermesandroid.relay.ui.components.ChatFailurePanel
import com.hermesandroid.relay.viewmodel.ChatFailureRoute
import com.hermesandroid.relay.viewmodel.ChatFailureNotice
import com.hermesandroid.relay.viewmodel.scopedChatFailure
import com.hermesandroid.relay.ui.components.ConversationVoiceDock
import com.hermesandroid.relay.ui.components.CleanChatMode
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
@@ -257,6 +262,7 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.HumanErrorAction
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import kotlin.math.abs
import com.hermesandroid.relay.ui.theme.relayGridTexture
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
@@ -848,6 +854,21 @@ fun ChatScreen(
val serverAutoTitles by chatViewModel.serverAutoTitles.collectAsState()
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val structuredChatFailure by chatViewModel.chatFailure.collectAsState()
val visibleChatFailure = scopedChatFailure(
structuredChatFailure,
currentSessionId,
) ?: error?.let { rawError ->
ChatFailureNotice(
sessionId = currentSessionId,
turnId = "transport-error",
rawError = rawError,
route = null,
)
}
var showChatFailureDetails by rememberSaveable(visibleChatFailure?.turnId) {
mutableStateOf(false)
}
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
@@ -885,8 +906,16 @@ fun ChatScreen(
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
val openedSessionProfileName by chatViewModel.openedSessionProfileName.collectAsState()
val conversationProfile = openedSessionProfileName?.let { owner ->
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
val explicitBindingProfileName = conversationBinding.profileName
.takeIf { conversationBinding.hasExplicitOwner }
val explicitBindingProfileIconPath by remember(
connectionViewModel,
explicitBindingProfileName,
) {
connectionViewModel.profileIconFlow(explicitBindingProfileName)
}.collectAsState(initial = null)
val conversationProfile = explicitBindingProfileName?.let { owner ->
agentProfiles.firstOrNull { it.name.equals(owner, ignoreCase = true) }
?: allProfileSessions.firstOrNull {
it.profile.equals(owner, ignoreCase = true) &&
@@ -928,7 +957,7 @@ fun ChatScreen(
gatewayProvider = gatewayCurrentProvider,
persistedSessionModel = currentSession?.model,
profileDefaultModel = conversationProfile?.model,
serverDefaultModel = serverModelName.takeIf { openedSessionProfileName == null },
serverDefaultModel = serverModelName.takeIf { explicitBindingProfileName == null },
)
val sessionPickerProvider = sessionModelState.pickerProvider
?: sessionModelState.pickerModel?.let { model ->
@@ -1092,12 +1121,12 @@ fun ChatScreen(
val composerDraftKey = remember(
activeConnection?.id,
selectedProfile?.name,
openedSessionProfileName,
explicitBindingProfileName,
currentSessionId,
) {
ChatComposerDraftKey(
connectionId = activeConnection?.id?.takeIf(String::isNotBlank) ?: "offline",
profileId = (openedSessionProfileName ?: selectedProfile?.name)
profileId = (explicitBindingProfileName ?: selectedProfile?.name)
?.takeIf(String::isNotBlank)
?: ChatComposerDraftKey.DEFAULT_PROFILE_ID,
sessionId = currentSessionId?.takeIf(String::isNotBlank) ?: "new-session",
@@ -1136,6 +1165,52 @@ fun ChatScreen(
activeComposerDraftKey = composerDraftKey
restoringComposerDraft = false
}
val sharedContentRequest by com.hermesandroid.relay.util.SharedContentRequest.pending.collectAsState()
LaunchedEffect(
sharedContentRequest,
composerDraftKey,
activeComposerDraftKey,
maxAttachmentMb,
charLimit,
) {
val request = sharedContentRequest ?: return@LaunchedEffect
if (!com.hermesandroid.relay.util.canApplySharedContent(
request = request,
composerConnectionId = composerDraftKey.connectionId,
composerProfileId = composerDraftKey.profileId,
composerSessionId = composerDraftKey.sessionId,
draftRestored = activeComposerDraftKey == composerDraftKey,
)
) return@LaunchedEffect
editingMessage = null
quotedMessage = null
inputText = request.payload.text.orEmpty().take(charLimit)
chatViewModel.replacePendingAttachments(emptyList())
request.payload.uriStrings.forEach { uriString ->
if (!com.hermesandroid.relay.util.isAllowedSharedContentUri(uriString)) {
return@forEach
}
runCatching { Uri.parse(uriString) }
.getOrNull()
?.let { uri ->
ingestAttachmentFromUri(context, uri, maxAttachmentMb) {
chatViewModel.addAttachment(it)
}
}
}
if (request.payload.omittedUriCount > 0) {
Toast.makeText(
context,
context.getString(
R.string.chat_shared_files_limited,
com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS,
),
Toast.LENGTH_LONG,
).show()
}
com.hermesandroid.relay.util.SharedContentRequest.consume(request.id)
}
LaunchedEffect(
inputText,
editingMessage?.id,
@@ -2171,7 +2246,7 @@ fun ChatScreen(
selectedPersonality,
defaultPersonality,
profileDisplayAlias,
openedSessionProfileName,
explicitBindingProfileName,
activeConnection?.label,
) {
derivedStateOf {
@@ -2181,7 +2256,7 @@ fun ChatScreen(
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = activeConnection?.label,
localDisplayAlias = profileDisplayAlias.takeIf { openedSessionProfileName == null },
localDisplayAlias = profileDisplayAlias.takeIf { explicitBindingProfileName == null },
)
}
}
@@ -2215,8 +2290,9 @@ fun ChatScreen(
// voice overlay already owns input while voice mode is visible.
gesturesEnabled = true,
drawerContent = {
val drawerTitle = if (effectiveProfile != null) {
stringResource(R.string.chat_profile_sessions, globalSelectedAgentDisplayName)
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
val drawerTitle = if (drawerProfileName != null) {
stringResource(R.string.chat_profile_sessions, agentDisplayName)
} else {
stringResource(R.string.chat_server_default_sessions)
}
@@ -2260,7 +2336,7 @@ fun ChatScreen(
currentSessionId = currentSessionId,
scopeTitle = drawerTitle,
scopeSubtitle = drawerSubtitle,
activeProfileName = effectiveProfile?.name ?: "default",
activeProfileName = drawerProfileName ?: "default",
isLoading = isLoadingSessions,
isOpen = drawerState.isOpen,
activityStates = sessionActivityStates,
@@ -2273,6 +2349,7 @@ fun ChatScreen(
scope.launch { drawerState.close() }
},
onNewDefaultChat = {
if (isProfileLocked) return@SessionDrawerContent
val defaultProfile = agentProfiles.firstOrNull {
it.name.equals("default", ignoreCase = true)
} ?: com.hermesandroid.relay.data.Profile(
@@ -2280,7 +2357,7 @@ fun ChatScreen(
model = "",
description = "Default",
)
chatViewModel.createProfileChat(
val opened = chatViewModel.createProfileChat(
profileName = "default",
profile = defaultProfile,
contextKey = AgentDisplay.profileContextKey(
@@ -2288,7 +2365,7 @@ fun ChatScreen(
profileName = "default",
),
)
scope.launch { drawerState.close() }
if (opened) scope.launch { drawerState.close() }
},
onSelectSession = { sessionId ->
chatViewModel.switchSession(sessionId)
@@ -2296,7 +2373,7 @@ fun ChatScreen(
},
onDeleteSession = { sessionId ->
val connectionId = activeConnection?.id
val profileId = openedSessionProfileName ?: selectedProfile?.name
val profileId = explicitBindingProfileName ?: selectedProfile?.name
chatViewModel.deleteSession(sessionId) {
if (!connectionId.isNullOrBlank() && !profileId.isNullOrBlank()) {
chatViewModel.removeComposerDraftSession(
@@ -2341,13 +2418,14 @@ fun ChatScreen(
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
},
allProfilesSupported = !activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
allProfilesSupported = !isProfileLocked &&
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
allProfileSessions = allProfileSessions,
allProfileSessionsLoading = allProfileSessionsLoading,
profileColors = profilePresentation.colors,
onProfileColorChange = connectionViewModel::setProfileColor,
onRefreshAllProfiles = {
if (!allProfileSessionsLoading) scope.launch {
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
@@ -2393,6 +2471,9 @@ fun ChatScreen(
}
},
onSelectProfileSession = { profileName, sessionId ->
if (!connectionViewModel.isProfileSelectionAllowed(profileName)) {
return@SessionDrawerContent
}
val target = agentProfiles.firstOrNull {
it.name.equals(profileName, ignoreCase = true)
}
@@ -2411,7 +2492,7 @@ fun ChatScreen(
model = "",
description = profileName,
)
chatViewModel.openProfileSession(
val opened = chatViewModel.openProfileSession(
profileName = profileName,
profile = ownerProfile,
contextKey = AgentDisplay.profileContextKey(
@@ -2420,7 +2501,7 @@ fun ChatScreen(
),
sessionId = sessionId,
)
scope.launch { drawerState.close() }
if (opened) scope.launch { drawerState.close() }
} else {
scope.launch {
snackbarHostState.showSnackbar("Profile $profileName is not available.")
@@ -2634,7 +2715,14 @@ fun ChatScreen(
if (isChatConnecting) {
ChatConnectingAvatarGlyph()
} else {
val agentIconPath = LocalAgentIconPath.current
// While row selection and persistence
// converge, the header already belongs to
// the explicit binding owner, including its icon.
val agentIconPath = if (explicitBindingProfileName != null) {
explicitBindingProfileIconPath
} else {
LocalAgentIconPath.current
}
if (!agentIconPath.isNullOrBlank()) {
AsyncImage(
model = File(agentIconPath),
@@ -2934,32 +3022,6 @@ fun ChatScreen(
// are reached from Settings (Settings → Hermes management / Bridge);
// Terminal + Settings remain quick icons in the top app bar above.
// Error banner with retry
AnimatedVisibility(visible = error != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
TextButton(onClick = { chatViewModel.retryLastMessage() }) {
Text(stringResource(R.string.chat_retry))
}
TextButton(onClick = { chatViewModel.clearError() }) {
Text(stringResource(R.string.chat_dismiss))
}
}
}
// Loading history indicator — only when there's nothing already on
// screen. During a profile/session switch the previous transcript is
// held visible while the new history loads (see
@@ -4135,6 +4197,48 @@ fun ChatScreen(
null
}
visibleChatFailure?.let { failure ->
val failureRouteLabel = when (failure.route) {
ChatFailureRoute.GATEWAY ->
stringResource(R.string.chat_failure_route_gateway)
ChatFailureRoute.API_FALLBACK ->
stringResource(R.string.chat_failure_route_api)
null -> ""
}
ChatFailurePanel(
failure = failure,
routeLabel = failureRouteLabel,
onDetails = { showChatFailureDetails = true },
onRetry = { chatViewModel.retryLastMessage() },
onDismiss = chatViewModel::dismissChatFailure,
)
if (showChatFailureDetails) {
ChatFailureDetailsDialog(
failure = failure,
routeLabel = failureRouteLabel,
onCopy = {
val details = buildString {
append(failureRouteLabel)
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
append("\n\n")
append(failure.rawError)
}
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Hermes response failure", details)),
)
snackbarHostState.showSnackbar(
message = context.getString(R.string.chat_copied_to_clipboard),
duration = SnackbarDuration.Short,
)
}
},
onDismiss = { showChatFailureDetails = false },
)
}
}
ChatInputBar(
value = inputText,
onValueChange = { inputText = it },
@@ -4384,7 +4488,7 @@ fun ChatScreen(
.padding(top = 80.dp, start = 16.dp, end = 16.dp),
) {
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.errorContainer,
tonalElevation = 2.dp,
) {
@@ -4869,7 +4973,7 @@ private fun ChatLoadingCommandPanel(
.fillMaxWidth()
.widthIn(max = 420.dp)
.animateContentSize(animationSpec = tween(durationMillis = 240)),
shape = RoundedCornerShape(18.dp),
shape = appearanceRoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
tonalElevation = 1.dp,
) {
@@ -4952,7 +5056,7 @@ private fun ChatLoadingCommandRow(command: ChatLoadingCommand) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clip(appearanceRoundedCornerShape(10.dp))
.background(RelayRefresh.Amber.copy(alpha = activeHighlightAlpha))
.animateContentSize(animationSpec = tween(durationMillis = 220))
.alpha(rowAlpha)
@@ -5021,7 +5125,7 @@ private fun ChatSkeletonBubble(
) {
Surface(
modifier = Modifier.fillMaxWidth(widthFraction),
shape = RoundedCornerShape(18.dp),
shape = appearanceRoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.32f),
) {
Column(
@@ -5083,6 +5187,8 @@ private suspend fun ingestAttachmentFromUri(
fileSize = source.sizeBytes,
)
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: AttachmentTooLargeException) {
Toast.makeText(
context,
@@ -5254,7 +5360,7 @@ private fun DateSeparator(timestamp: Long) {
horizontalArrangement = Arrangement.Center
) {
Surface(
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
Text(

Some files were not shown because too many files have changed in this diff Show More