Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f642dceb | ||
|
|
3ed64ba251 | ||
|
|
a6467e84cb | ||
|
|
e69ca817e4 | ||
|
|
733ece9523 | ||
|
|
5cd18f8607 | ||
|
|
46faddbad6 | ||
|
|
8683b84653 | ||
|
|
0d35d549f9 | ||
|
|
a6d86b0110 | ||
|
|
d8f343bf50 | ||
|
|
5408ec8d30 | ||
|
|
ba12c8354e | ||
|
|
28b4bd9d8d | ||
|
|
7bd493816f | ||
|
|
aa2c719aea | ||
|
|
a8830a37b3 | ||
|
|
66b15524f0 | ||
|
|
50afb4eed9 | ||
|
|
11787f0eab | ||
|
|
9b88ea2680 | ||
|
|
708d27e4ea | ||
|
|
cc197b1598 | ||
|
|
356f370a4f | ||
|
|
25a206ca04 | ||
|
|
92a06478cb | ||
|
|
4f25ab02e3 | ||
|
|
6324ee4fff | ||
|
|
ec7f33d337 | ||
|
|
f6ee586bc2 | ||
|
|
f1106112b8 | ||
|
|
26841fb002 | ||
|
|
926ffedee9 | ||
|
|
889c2fb316 | ||
|
|
260c21737c | ||
|
|
7036219f90 | ||
|
|
d741acab27 | ||
|
|
8d9970449c | ||
|
|
be50f9a726 | ||
|
|
f21923d39b | ||
|
|
27970d4020 | ||
|
|
94992ff37f | ||
|
|
a25de7fe31 | ||
|
|
2006d552e2 | ||
|
|
ab532d0696 | ||
|
|
66971cb0e2 | ||
|
|
9ae8de2c9d | ||
|
|
b5174d6279 | ||
|
|
7ac5a48e18 | ||
|
|
ea6cb5a6a7 | ||
|
|
d5cccd664a | ||
|
|
a48349e3cf | ||
|
|
d476704c3f | ||
|
|
f7a070ecfe | ||
|
|
37084566a0 | ||
|
|
c43f22f18d | ||
|
|
6244ab3781 | ||
|
|
9b1852a986 | ||
|
|
99f853c98e | ||
|
|
39782a5ff1 | ||
|
|
0d660c0e41 | ||
|
|
6b14ac0e0e | ||
|
|
59b5424c49 | ||
|
|
0f83af76f6 | ||
|
|
6a39e8dc1a | ||
|
|
e8473e14c8 | ||
|
|
e05018bd0b |
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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__
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
+187
@@ -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."
|
||||
}
|
||||
}
|
||||
+356
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 地址也会正确规范化。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -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()
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+12
-16
@@ -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" },
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+17
-16
@@ -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
|
||||
)
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
+14
-7
@@ -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 {
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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),
|
||||
|
||||
+168
-139
@@ -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,
|
||||
) {
|
||||
|
||||
+173
-1
@@ -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
Reference in New Issue
Block a user