Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fad937c23 | ||
|
|
693ac4ed64 | ||
|
|
f94d663ac4 | ||
|
|
d1a21bd42e | ||
|
|
7ee2d73010 | ||
|
|
682bde84fe | ||
|
|
16bdbe5f44 | ||
|
|
f43fba9fed | ||
|
|
d1745413fd | ||
|
|
1b7a8025c3 | ||
|
|
d92a87483e | ||
|
|
0bea626ed8 | ||
|
|
f453dd27f3 | ||
|
|
36546c2712 | ||
|
|
fbe3fc3e05 | ||
|
|
a77cebec43 | ||
|
|
8167f93705 | ||
|
|
52dc46072e |
@@ -14,6 +14,8 @@ function classifyCiPaths(paths) {
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
|
||||
'scripts/android-release-artifacts.py', 'scripts/test_android_release_artifacts.py',
|
||||
'scripts/release-android.ps1',
|
||||
'.github/workflows/play-preflight-android.yml',
|
||||
'.github/workflows/approve-release-android.yml',
|
||||
'.github/workflows/release-android.yml',
|
||||
|
||||
@@ -15,6 +15,9 @@ const none = {
|
||||
assert.deepEqual(classifyCiPaths(['README.md']), none);
|
||||
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
|
||||
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/android-release-artifacts.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/test_android_release_artifacts.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/release-android.ps1']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
|
||||
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# stable tag triggers Play submission first, then GitHub publication.
|
||||
|
||||
name: Approve Android Release
|
||||
run-name: Approve Android ${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -24,8 +25,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: Verify preflight and create release tag
|
||||
name: Verify, tag, and observe public release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -56,13 +58,25 @@ jobs:
|
||||
RELEASE_TREE: ${{ steps.metadata.outputs.tree }}
|
||||
run: |
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
|
||||
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | length')
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
|
||||
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified Play preflight proof: $ARTIFACT_NAME"
|
||||
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
|
||||
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
|
||||
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
|
||||
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
|
||||
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONCLUSION" != "success" ]; then
|
||||
echo "::error::Preflight workflow run ${RUN_ID} concluded ${CONCLUSION}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified successful Play preflight artifact: $ARTIFACT_NAME (run $RUN_ID)"
|
||||
|
||||
- name: Ensure release tag does not already exist
|
||||
env:
|
||||
@@ -83,18 +97,41 @@ jobs:
|
||||
-f ref="refs/tags/android-v${VERSION}" \
|
||||
-f sha="$GITHUB_SHA"
|
||||
|
||||
- name: Start the tag release workflow
|
||||
- name: Start and observe the tag release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
DISPATCHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
gh workflow run release-android.yml \
|
||||
--ref="android-v${VERSION}" \
|
||||
--ref=main \
|
||||
-f version="$VERSION"
|
||||
|
||||
RUN_ID=""
|
||||
for _ in $(seq 1 30); do
|
||||
RUN_ID=$(gh run list \
|
||||
--workflow=release-android.yml \
|
||||
--event=workflow_dispatch \
|
||||
--limit=20 \
|
||||
--json databaseId,headSha,createdAt \
|
||||
--jq ".[] | select(.headSha == \"$GITHUB_SHA\" and .createdAt >= \"$DISPATCHED_AT\") | .databaseId" \
|
||||
| head -n 1)
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::Release workflow dispatch succeeded but its run could not be located"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Observing Release Android run ${RUN_ID}"
|
||||
gh run watch "$RUN_ID" --exit-status --interval 15
|
||||
|
||||
- name: Approval summary
|
||||
run: |
|
||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## Android release complete" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The immutable release workflow completed successfully: it promoted the preflighted Play draft and published the exact signed preflight artifacts." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -33,6 +33,9 @@ on:
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- "scripts/android-release-artifacts.py"
|
||||
- "scripts/test_android_release_artifacts.py"
|
||||
- "scripts/release-android.ps1"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
- ".github/workflows/play-preflight-android.yml"
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
@@ -73,6 +76,9 @@ jobs:
|
||||
- name: Reject unsafe Android collection APIs
|
||||
run: python3 scripts/check-android-collection-apis.py
|
||||
|
||||
- name: Validate immutable release artifact contract
|
||||
run: python3 -m unittest scripts/test_android_release_artifacts.py
|
||||
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lint --console=plain
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -30,6 +31,7 @@ jobs:
|
||||
dashboard: ${{ steps.filter.outputs.dashboard }}
|
||||
contract: ${{ steps.filter.outputs.contract }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
release_pr: ${{ steps.release.outputs.release_pr }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
@@ -39,6 +41,21 @@ jobs:
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
|
||||
- name: Detect canonical release PR
|
||||
id: release
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
run: |
|
||||
if [ "$BASE_REF" = "main" ] &&
|
||||
[ "$HEAD_REF" = "dev" ] &&
|
||||
[ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then
|
||||
echo "release_pr=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release_pr=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Classify changed files
|
||||
id: filter
|
||||
uses: actions/github-script@v8
|
||||
@@ -62,9 +79,66 @@ jobs:
|
||||
|
||||
android:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
if: needs.changes.outputs.android == 'true' && needs.changes.outputs.release_pr != 'true'
|
||||
uses: ./.github/workflows/ci-android.yml
|
||||
|
||||
release-proof:
|
||||
name: Verify preflighted release tree
|
||||
needs: changes
|
||||
if: needs.changes.outputs.release_pr == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout simulated release merge
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require unchanged successful Play preflight
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EXPECTED_DEV_COMMIT: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MERGE_TREE=$(git rev-parse 'HEAD^{tree}')
|
||||
DEV_COMMIT="$EXPECTED_DEV_COMMIT"
|
||||
DEV_TREE=$(git rev-parse "${DEV_COMMIT}^{tree}")
|
||||
if [ "$MERGE_TREE" != "$DEV_TREE" ]; then
|
||||
echo "::error::The dev-to-main merge changes the preflighted tree ($DEV_TREE -> $MERGE_TREE)"
|
||||
echo "Rerun Play Preflight from the simulated merge result before approval."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${DEV_TREE}"
|
||||
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
|
||||
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::No Play preflight artifact exists for dev tree $DEV_TREE and version $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
|
||||
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
|
||||
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
|
||||
PREFLIGHT_COMMIT=$(jq -r '.head_sha' <<<"$RUN")
|
||||
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
|
||||
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PREFLIGHT_COMMIT" != "$DEV_COMMIT" ]; then
|
||||
echo "::error::Preflight commit ${PREFLIGHT_COMMIT} does not match release dev commit ${DEV_COMMIT}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONCLUSION" != "success" ]; then
|
||||
echo "::error::Play preflight run ${RUN_ID} concluded ${CONCLUSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified release PR:"
|
||||
echo " dev commit: $DEV_COMMIT"
|
||||
echo " unchanged tree: $DEV_TREE"
|
||||
echo " successful preflight run: $RUN_ID"
|
||||
|
||||
desktop:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.desktop == 'true'
|
||||
@@ -110,11 +184,12 @@ jobs:
|
||||
guard:
|
||||
name: Required checks
|
||||
if: always()
|
||||
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
|
||||
needs: [changes, android, release-proof, desktop, plugin, dashboard, contract, docs]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
ANDROID_RESULT: ${{ needs.android.result }}
|
||||
RELEASE_PROOF_RESULT: ${{ needs.release-proof.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
PLUGIN_RESULT: ${{ needs.plugin.result }}
|
||||
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
|
||||
@@ -125,7 +200,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
failed=0
|
||||
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
for check in CHANGES ANDROID RELEASE_PROOF DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
result_var="${check}_RESULT"
|
||||
result="${!result_var}"
|
||||
echo "$check: $result"
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
#
|
||||
# Run manually from the final dev or untagged main tree before creating
|
||||
# android-v*. The job
|
||||
# builds the same signed release artifacts, scans final DEX, and uploads the
|
||||
# Google Play bundle as a production DRAFT. A successful upload is the automated
|
||||
# Play gate while no public GitHub Release or sideload APK exists. Console-only
|
||||
# pre-review and pre-launch reports are informational and do not block release.
|
||||
# builds the signed release artifacts once, scans final DEX, and uploads the
|
||||
# Google Play bundle as a production DRAFT. The signed APK/AAB and their manifest
|
||||
# remain private Actions artifacts until approval publishes those exact bytes.
|
||||
# Console-only pre-review and pre-launch reports are informational.
|
||||
|
||||
name: Play Preflight — Android
|
||||
|
||||
@@ -25,6 +25,51 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Validate release quality
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Validate translation catalogs and collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m unittest scripts/test_android_release_artifacts.py
|
||||
|
||||
- name: Run strict Android lint and focused tests
|
||||
run: |
|
||||
./gradlew lint :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--tests com.hermesandroid.relay.util.ServerAddressTest \
|
||||
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.data.AppLanguageTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
|
||||
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
|
||||
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
|
||||
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
|
||||
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
|
||||
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
|
||||
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
|
||||
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
|
||||
--console=plain
|
||||
|
||||
preflight:
|
||||
name: Build and upload private Play draft
|
||||
runs-on: ubuntu-latest
|
||||
@@ -121,25 +166,27 @@ jobs:
|
||||
--resolution-strategy=ignore \
|
||||
--release-name="Hermes-Relay ${{ steps.metadata.outputs.version }}"
|
||||
|
||||
- name: Record successful preflight for the exact commit
|
||||
- name: Package immutable preflight artifacts
|
||||
run: |
|
||||
mkdir -p app/build/reports
|
||||
cat > app/build/reports/play-preflight.json <<EOF
|
||||
{
|
||||
"version": "${{ steps.metadata.outputs.version }}",
|
||||
"versionCode": "${{ steps.metadata.outputs.version_code }}",
|
||||
"commit": "$GITHUB_SHA",
|
||||
"tree": "${{ steps.metadata.outputs.tree }}",
|
||||
"track": "production",
|
||||
"status": "draft"
|
||||
}
|
||||
EOF
|
||||
python3 scripts/android-release-artifacts.py package \
|
||||
--version "${{ steps.metadata.outputs.version }}" \
|
||||
--version-code "${{ steps.metadata.outputs.version_code }}" \
|
||||
--commit "$GITHUB_SHA" \
|
||||
--tree "${{ steps.metadata.outputs.tree }}" \
|
||||
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
|
||||
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
|
||||
--output app/build/preflight-release
|
||||
python3 scripts/android-release-artifacts.py verify \
|
||||
--version "${{ steps.metadata.outputs.version }}" \
|
||||
--version-code "${{ steps.metadata.outputs.version_code }}" \
|
||||
--tree "${{ steps.metadata.outputs.tree }}" \
|
||||
--directory app/build/preflight-release
|
||||
|
||||
- name: Upload preflight proof
|
||||
- name: Upload private signed artifacts and proof
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: play-preflight-${{ steps.metadata.outputs.version }}-${{ steps.metadata.outputs.tree }}
|
||||
path: app/build/reports/play-preflight.json
|
||||
path: app/build/preflight-release/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
@@ -152,4 +199,4 @@ jobs:
|
||||
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Approve Android Release** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The signed build, DEX scan, strict quality gate, and Play draft upload passed. The private artifact contains the exact APK/AAB bytes that approval will publish. Ensure this release tree is on main, then run **Approve Android Release** from main." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
#
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
# reuses the exact private APK/AAB that passed stable Play preflight, and creates
|
||||
# a GitHub Release only after Play accepts the production promotion. Prerelease
|
||||
# tags still build fresh artifacts because they do not use the Play preflight.
|
||||
|
||||
name: Release Android
|
||||
run-name: Release Android ${{ inputs.version != '' && inputs.version || github.ref_name }}
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "android-v*"
|
||||
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
||||
# does not recursively start workflows. It explicitly dispatches this file
|
||||
# at that tag instead. Manual tag pushes continue to use the push trigger.
|
||||
# does not recursively start workflows. It dispatches the current workflow
|
||||
# definition from main, while every job checks out the immutable tag. Manual
|
||||
# tag pushes continue to use the push trigger.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -33,27 +36,33 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
version_code: ${{ steps.version.outputs.version_code }}
|
||||
release_tree: ${{ steps.version.outputs.release_tree }}
|
||||
preflight_artifact_id: ${{ steps.preflight.outputs.artifact_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
env:
|
||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
|
||||
if [ -n "$DISPATCHED_VERSION" ]; then
|
||||
REF_VERSION="$DISPATCHED_VERSION"
|
||||
fi
|
||||
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then
|
||||
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION"
|
||||
exit 1
|
||||
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
|
||||
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
fi
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
@@ -67,8 +76,8 @@ jobs:
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -88,29 +97,47 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Require successful Play preflight for this exact release tree
|
||||
id: preflight
|
||||
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
RELEASE_TREE: ${{ steps.version.outputs.release_tree }}
|
||||
run: |
|
||||
RELEASE_TREE=$(git rev-parse 'HEAD^{tree}')
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
|
||||
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | length')
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
|
||||
ARTIFACT_ID=$(jq -r '.id // empty' <<<"$ARTIFACT")
|
||||
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
|
||||
if [ -z "$ARTIFACT_ID" ] || [ -z "$RUN_ID" ]; then
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
echo "Run Play Preflight from the final dev tree, merge that unchanged tree to main, then approve the release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Play preflight proof found: $ARTIFACT_NAME"
|
||||
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
|
||||
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
|
||||
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
|
||||
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
|
||||
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONCLUSION" != "success" ]; then
|
||||
echo "::error::Preflight workflow run ${RUN_ID} concluded ${CONCLUSION}"
|
||||
exit 1
|
||||
fi
|
||||
echo "artifact_id=$ARTIFACT_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Play preflight artifact verified: $ARTIFACT_NAME (run $RUN_ID, artifact $ARTIFACT_ID)"
|
||||
|
||||
ci:
|
||||
name: CI Checks
|
||||
name: CI Checks (prerelease only)
|
||||
needs: validate
|
||||
if: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -143,10 +170,13 @@ jobs:
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
needs: [validate, ci]
|
||||
if: ${{ always() && needs.validate.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -159,54 +189,61 @@ jobs:
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Decode release keystore
|
||||
- name: Download exact stable preflight artifacts
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ARTIFACT_ID: ${{ needs.validate.outputs.preflight_artifact_id }}
|
||||
run: |
|
||||
mkdir -p app/build/release-artifacts
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/preflight.zip"
|
||||
python3 scripts/android-release-artifacts.py extract \
|
||||
--archive "$RUNNER_TEMP/preflight.zip" \
|
||||
--output app/build/release-artifacts
|
||||
|
||||
- name: Verify exact stable preflight artifacts
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
python3 scripts/android-release-artifacts.py verify \
|
||||
--version "${{ needs.validate.outputs.version }}" \
|
||||
--version-code "${{ needs.validate.outputs.version_code }}" \
|
||||
--tree "${{ needs.validate.outputs.release_tree }}" \
|
||||
--directory app/build/release-artifacts
|
||||
|
||||
- name: Decode release keystore for prerelease build
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
if: env.HERMES_KEYSTORE_BASE64 != ''
|
||||
if: ${{ contains(needs.validate.outputs.version, '-') && env.HERMES_KEYSTORE_BASE64 != '' }}
|
||||
run: |
|
||||
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 prerelease artifacts
|
||||
if: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
# `assembleRelease` and `bundleRelease` are flavor-wide task aliases
|
||||
# (added by `flavorDimensions += "track"` in app/build.gradle.kts), so
|
||||
# this one line builds ALL four artifacts at once. Filenames come from
|
||||
# `archivesName` (set in app/build.gradle.kts) which injects the app
|
||||
# version, so `<version>` below is `libs.versions.appVersionName`:
|
||||
# app/build/outputs/apk/googlePlay/release/hermes-relay-<version>-googlePlay-release.apk
|
||||
# app/build/outputs/apk/sideload/release/hermes-relay-<version>-sideload-release.apk
|
||||
# app/build/outputs/bundle/googlePlayRelease/hermes-relay-<version>-googlePlay-release.aab
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
- name: Scan prerelease DEX
|
||||
if: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
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: List produced artifacts (debug aid)
|
||||
- name: Package prerelease artifacts
|
||||
if: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
echo "=== APK outputs ==="
|
||||
find app/build/outputs/apk -name '*.apk' -print 2>/dev/null || true
|
||||
echo "=== AAB outputs ==="
|
||||
find app/build/outputs/bundle -name '*.aab' -print 2>/dev/null || true
|
||||
|
||||
- name: Generate checksums
|
||||
# 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
|
||||
# different between APK and AAB). Checksums cover EXACTLY the files
|
||||
# attached to the GitHub Release (see the 2-asset policy on the
|
||||
# release step below) so SHA256SUMS.txt matches the assets 1:1.
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
python3 scripts/android-release-artifacts.py package \
|
||||
--version "${{ needs.validate.outputs.version }}" \
|
||||
--version-code "${{ needs.validate.outputs.version_code }}" \
|
||||
--commit "$(git rev-parse HEAD)" \
|
||||
--tree "${{ needs.validate.outputs.release_tree }}" \
|
||||
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
|
||||
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
|
||||
--output app/build/release-artifacts
|
||||
|
||||
- name: Require Play credentials for stable release
|
||||
env:
|
||||
@@ -244,9 +281,9 @@ jobs:
|
||||
# Deliberate 2-asset policy (#144): attach ONLY the installable
|
||||
# sideload APK and Play AAB, plus checksums covering those files.
|
||||
files: |
|
||||
app/build/outputs/apk/sideload/release/*.apk
|
||||
app/build/outputs/bundle/googlePlayRelease/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
app/build/release-artifacts/*.apk
|
||||
app/build/release-artifacts/*.aab
|
||||
app/build/release-artifacts/SHA256SUMS.txt
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
@@ -254,7 +291,9 @@ jobs:
|
||||
run: |
|
||||
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
if [[ "${{ needs.validate.outputs.version }}" != *-* ]]; then
|
||||
echo "✅ **Published the exact signed Play-preflight artifacts**" >> "$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"
|
||||
@@ -262,6 +301,5 @@ jobs:
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### Artifacts" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
find app/build/release-artifacts -maxdepth 1 -type f -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+26
-10
@@ -6,19 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Android 1.5.1] - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **Voice supports focused and conversational layouts.** Focus keeps spoken turns, Markdown, tools, media, and actions in a compact voice surface, while Conversation opens the full Chat renderer without leaving the active voice session.
|
||||
- **Voice can speak only settled answers.** A global Voice setting keeps tool progress, service updates, and intermediate commentary visual while supported voice paths wait to speak the final Hermes answer.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat answers are easier to read in every theme.** Primary assistant text now uses the theme's full-contrast foreground, and chat prose uses a 15sp size with 21sp line height.
|
||||
- **Google Play builds target Android 16.** The app now targets API level 36 while retaining its existing minimum-device support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Background chats stay reachable without an always-on connection.** Android now protects user-started work until every concurrent Gateway session settles, shows active and waiting counts in its ongoing notification, and gives each privacy-safe interaction alert expanded session context plus a direct review, answer, or secure-response action.
|
||||
- **Android alerts when a background Gateway turn needs input.** Approval, clarification, elevated-permission, and secret requests post privacy-safe notifications that reopen the correct conversation, survive reconnect replay without duplicates, and clear when the request is answered or expires.
|
||||
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
|
||||
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
|
||||
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
- **Completed streamed answers render their formatting without losing the reading position.** Markdown headings, lists, emphasis, and code blocks replace the live text renderer only after completion, then the measured trailing edge remains anchored at the bottom.
|
||||
- **Standard Voice speaks completed assistant replies again.** Session and message fences no longer suppress a valid final answer during the handoff from generation to narration.
|
||||
- **Realtime background work no longer blocks the active voice controls.** A promoted task releases the foreground spinner and microphone while its progress, tools, cancellation, and final result remain available in the owning chat.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
@@ -32,14 +40,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-22
|
||||
## [Android 1.5.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -48,6 +60,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
|
||||
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
|
||||
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-26 — Android release fast path
|
||||
|
||||
Stable Android preflight now retains the exact signed APK and Play AAB with a
|
||||
tree-bound manifest, sizes, and SHA-256 checksums. The release PR verifies that
|
||||
its simulated merge tree is unchanged and that the originating preflight run
|
||||
succeeded, while final publication downloads and verifies those private bytes
|
||||
instead of rebuilding them. Approval observes the immutable-tag release through
|
||||
Play promotion and GitHub publication.
|
||||
|
||||
A PowerShell orchestrator provides the prepared-`dev` to Production path with a
|
||||
dry run and optional configuration-preserving phone deployment. The canonical
|
||||
release guide was reduced to policy and executable steps; Android account
|
||||
setup, emergency Play recovery, track selection, signing behavior, and
|
||||
troubleshooting moved to `docs/release/android-operations.md`. No application
|
||||
version changed for this release-engineering update.
|
||||
|
||||
## 2026-07-26 — Android 1.5.1 patch reconciliation
|
||||
|
||||
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
|
||||
34. Voice now offers compact Focus and full Conversation presentation,
|
||||
Standard narration preserves valid completed replies, and promoted Realtime
|
||||
tasks release foreground voice controls while retaining progress and results.
|
||||
|
||||
Completed streamed answers promote from the stable live text node to full
|
||||
Markdown only after completion. The measured Markdown row is then positioned
|
||||
by its trailing edge until deferred code and attachment measurement settles,
|
||||
preventing the LazyColumn from restoring the start of a tall response.
|
||||
|
||||
The release also targets Android API level 36. Release notes, in-app What's
|
||||
New assets, localized Play notes, and the Play listing reference were updated
|
||||
for Android 1.5.1.
|
||||
|
||||
## 2026-07-25 — Immutable Android release dispatch repair
|
||||
|
||||
Android approval now dispatches the current release workflow definition from
|
||||
`main`, while every release job explicitly checks out the immutable
|
||||
`android-v*` tag. Validation confirms the dispatched version resolves to that
|
||||
checked-out commit and accepts the repository's surface-qualified
|
||||
`[Android x.y.z]` changelog heading. Existing tags remain unchanged, and a
|
||||
workflow-only correction can resume a failed publication without rebuilding
|
||||
from a different application tree.
|
||||
|
||||
Audited `.github/workflows/approve-release-android.yml`,
|
||||
`.github/workflows/release-android.yml`, `RELEASE.md`, and `DEVLOG.md`.
|
||||
|
||||
## 2026-07-25 — Android 1.5.0 final release reconciliation
|
||||
|
||||
The final Android 1.5.0 release tree reconciles the accumulated Dashboard-first
|
||||
connection, Gateway recovery, background delivery, Agent Passport, onboarding,
|
||||
voice, attachment, image-generation, security, localization, and Developer
|
||||
Options work into one public release narrative. Android remains version 1.5.0
|
||||
with monotonic versionCode 33 because that prepared version was not previously
|
||||
tagged or uploaded to production.
|
||||
|
||||
Release notes, the in-app What's New assets, localized Play release notes, and
|
||||
the Play listing reference now describe the final tree rather than the earlier
|
||||
voice-focused candidate. The release train is gated by the exact-tree private
|
||||
Play preflight before the `dev` to `main` release merge and public tag.
|
||||
|
||||
## 2026-07-25 — Active-turn retention and actionable interaction alerts
|
||||
|
||||
Android now promotes user-started chat work to foreground execution until every
|
||||
|
||||
+269
-850
File diff suppressed because it is too large
Load Diff
+12
-14
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
# Hermes-Relay-Android v1.5.1
|
||||
|
||||
**Release Date:** July 22, 2026
|
||||
**Release Date:** July 26, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.5.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.5.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,24 +12,22 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
|
||||
This patch restores reliable narration and background behavior in Voice, adds focused and full-conversation voice layouts, and keeps richly formatted streamed answers anchored at their completed end.
|
||||
|
||||
## Added
|
||||
|
||||
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
|
||||
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
|
||||
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
|
||||
- Voice Focus keeps narration, Markdown, tools, media, and actionable cards in a compact voice-first view.
|
||||
- Voice Conversation exposes the complete Chat renderer while preserving the active voice session.
|
||||
- A final-answer speech preference keeps intermediate progress visual while supported voice paths wait for the settled response.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Voice settings and active-turn correction copy remain complete across supported languages.
|
||||
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
|
||||
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
|
||||
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
|
||||
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
|
||||
- Standard Voice narrates valid completed assistant responses instead of losing them during the generation-to-speech handoff.
|
||||
- Realtime tasks promoted to background release the foreground spinner and microphone while progress and results remain reachable.
|
||||
- Completed streamed answers switch to full Markdown and preserve the measured trailing edge instead of jumping to the start of the response.
|
||||
- Assistant text uses the theme's full-contrast foreground with a more readable chat type scale.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.5.0** (versionCode **33**).
|
||||
- App version: **1.5.1** (versionCode **34**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -717,9 +717,10 @@ Deferred:
|
||||
|
||||
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
|
||||
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 15sp/21sp, primary assistant prose moved
|
||||
to the theme's full-contrast `onSurface`, inline+fenced code 13sp, `textLink`
|
||||
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
|
||||
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
@@ -731,10 +732,6 @@ gated to pre-first-token. Deferred:
|
||||
parses one full CommonMark document so global link references, indentation, and
|
||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
|
||||
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
|
||||
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
|
||||
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
||||
|
||||
@@ -37,7 +37,7 @@ android {
|
||||
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
||||
applicationId = "com.axiomlabs.hermesrelay"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
targetSdk = 36
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
Choose compact Voice Focus or the complete Conversation layout. Standard Voice reliably speaks completed replies, Realtime background work no longer blocks voice controls, and streamed answers render Markdown while staying anchored at their completed end.
|
||||
|
||||
@@ -1 +1 @@
|
||||
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
|
||||
可选择精简的语音专注视图或完整的对话视图。标准语音现在会可靠朗读已完成的回复,实时后台任务不再阻塞语音控制,流式回复会渲染 Markdown 并停留在完成位置。
|
||||
|
||||
@@ -1,29 +1,64 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Voice that keeps pace",
|
||||
"date": "2026-07-22",
|
||||
"version": "1.5.1",
|
||||
"title": "Voice and chat stay in place",
|
||||
"date": "2026-07-26",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A clearer voice studio",
|
||||
"header": "Voice at the right depth",
|
||||
"bullets": [
|
||||
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
|
||||
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
|
||||
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
|
||||
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Natural streaming speech",
|
||||
"header": "Reliable narration and background work",
|
||||
"bullets": [
|
||||
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
|
||||
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
|
||||
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
|
||||
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More upstream-aware controls",
|
||||
"header": "Formatted answers stay readable",
|
||||
"bullets": [
|
||||
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
|
||||
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
|
||||
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
|
||||
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Hermes, always in reach",
|
||||
"date": "2026-07-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One secure Hermes connection",
|
||||
"bullets": [
|
||||
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active work stays reachable",
|
||||
"bullets": [
|
||||
"Multiple user-started chats remain active in the background until every session settles.",
|
||||
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Richer chat and voice",
|
||||
"bullets": [
|
||||
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup without surprises",
|
||||
"bullets": [
|
||||
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
v1.5.0 - Voice that keeps pace
|
||||
v1.5.1 - Voice and chat stay in place
|
||||
|
||||
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
|
||||
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
|
||||
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
* Choose a compact Voice Focus view or the complete Conversation renderer.
|
||||
* Hear Standard Voice replies reliably after generation completes.
|
||||
* Keep Realtime background work active without blocking voice controls.
|
||||
* Read formatted streamed answers without jumping back to their beginning.
|
||||
|
||||
@@ -255,9 +255,11 @@ class DataManager(
|
||||
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
||||
outputStream.flush()
|
||||
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||
?: return@withContext false
|
||||
outputStream.use {
|
||||
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -288,9 +290,10 @@ class DataManager(
|
||||
* - Clear DataStore preferences
|
||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||
* - Clear any cached data
|
||||
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
|
||||
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
|
||||
* should show onboarding again.
|
||||
*/
|
||||
suspend fun resetAppData() {
|
||||
suspend fun resetAppData(): Boolean =
|
||||
try {
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
@@ -320,10 +323,11 @@ class DataManager(
|
||||
}
|
||||
|
||||
Log.d(TAG, "App data reset complete")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset app data", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -384,16 +388,17 @@ class DataManager(
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
*/
|
||||
suspend fun resetOnboarding() {
|
||||
suspend fun resetOnboarding(): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||
}
|
||||
Log.d(TAG, "Onboarding flag reset")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed.
|
||||
@@ -412,13 +417,14 @@ class DataManager(
|
||||
/**
|
||||
* Mark onboarding as completed.
|
||||
*/
|
||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
||||
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
|
||||
/**
|
||||
* Feature flags with compile-time defaults and runtime overrides.
|
||||
*
|
||||
* In debug builds, all features are unlocked by default.
|
||||
* In debug builds, Developer Options are unlocked by default until the user
|
||||
* explicitly locks them.
|
||||
* In release builds, experimental features are hidden unless the user
|
||||
* enables Developer Options (tap version 7 times in Settings > About).
|
||||
*
|
||||
@@ -21,7 +22,6 @@ object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
|
||||
|
||||
/** Whether the app is running a debug build. */
|
||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||
@@ -29,13 +29,7 @@ object FeatureFlags {
|
||||
/** Observe whether Developer Options have been unlocked. */
|
||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
||||
}
|
||||
|
||||
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
|
||||
fun relayEnabled(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||
}
|
||||
|
||||
/** Unlock Developer Options. */
|
||||
@@ -45,18 +39,10 @@ object FeatureFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock Developer Options and disable all experimental features. */
|
||||
/** Lock Developer Options, including in debug builds. */
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||
prefs[KEY_RELAY_ENABLED] = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
|
||||
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ data class VoiceSettings(
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 1250L,
|
||||
/**
|
||||
* When true, voice keeps progress visual and waits for the settled Hermes
|
||||
* answer before speaking. Tool status, service updates, and intermediate
|
||||
* assistant commentary are not narrated.
|
||||
*/
|
||||
val finalAnswerOnly: Boolean = false,
|
||||
/** Presentation only; changing this never restarts or interrupts voice. */
|
||||
val presentationMode: String = VoicePresentationMode.Focus.storageValue,
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
@@ -122,6 +130,16 @@ enum class VoiceAudioRoute(val storageValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoicePresentationMode(val storageValue: String) {
|
||||
Focus("focus"),
|
||||
Conversation("conversation");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoicePresentationMode =
|
||||
values().firstOrNull { it.storageValue == value } ?: Focus
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active scope for per-profile voice prefs.
|
||||
*
|
||||
@@ -182,6 +200,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// un-namespaced means switching profiles never churns these.
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_FINAL_ANSWER_ONLY = booleanPreferencesKey("voice_final_answer_only")
|
||||
private val KEY_PRESENTATION_MODE = stringPreferencesKey("voice_presentation_mode")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
@@ -191,6 +211,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
||||
const val DEFAULT_FINAL_ANSWER_ONLY = false
|
||||
const val DEFAULT_PRESENTATION_MODE = "focus"
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
|
||||
@@ -258,6 +280,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// --- global (shared across profiles) ---
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
finalAnswerOnly = prefs[KEY_FINAL_ANSWER_ONLY] ?: DEFAULT_FINAL_ANSWER_ONLY,
|
||||
presentationMode = VoicePresentationMode.fromStorage(
|
||||
prefs[KEY_PRESENTATION_MODE] ?: DEFAULT_PRESENTATION_MODE,
|
||||
).storageValue,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
@@ -367,6 +393,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setFinalAnswerOnly(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_FINAL_ANSWER_ONLY] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setPresentationMode(mode: VoicePresentationMode) {
|
||||
dataStore.edit { it[KEY_PRESENTATION_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
@@ -1278,6 +1278,7 @@ class RelayVoiceClient(
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
@@ -1309,6 +1310,7 @@ class RelayVoiceClient(
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
finalAnswerOnly = finalAnswerOnly,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||
@@ -1822,6 +1824,28 @@ class RelayVoiceClient(
|
||||
if (event.type == "hermes.run.promoted") {
|
||||
longRunningTurn.set(true)
|
||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
||||
if (persistent &&
|
||||
event.spokenHandoff == false &&
|
||||
activeTurn.compareAndSet(true, false)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime agent foreground turn ended at silent background promotion",
|
||||
)
|
||||
onTurnComplete(
|
||||
RealtimeVoiceSummary(
|
||||
provider = event.provider ?: session.provider,
|
||||
model = event.model ?: session.model,
|
||||
voice = event.voice ?: session.voice,
|
||||
sampleRate = session.sampleRate,
|
||||
audioChunks = audioChunks,
|
||||
audioBytes = audioBytes,
|
||||
firstAudioMs = event.firstAudioMs,
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (event.isAudioDelta) {
|
||||
audioChunks += 1
|
||||
@@ -1847,13 +1871,12 @@ class RelayVoiceClient(
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
if (persistent) {
|
||||
if (persistent && activeTurn.compareAndSet(true, false)) {
|
||||
// Turn boundary, not session boundary: keep the socket
|
||||
// open for the next utterance.
|
||||
activeTurn.set(false)
|
||||
longRunningTurn.set(false)
|
||||
onTurnComplete(summary)
|
||||
} else {
|
||||
} else if (!persistent) {
|
||||
if (claimTerminalSocket(
|
||||
webSocket,
|
||||
generation,
|
||||
@@ -2679,6 +2702,7 @@ class RelayVoiceClient(
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
): Result<RealtimeSessionResponse> {
|
||||
val body = buildJsonObject {
|
||||
putProfile()
|
||||
@@ -2694,6 +2718,9 @@ class RelayVoiceClient(
|
||||
sampleRate?.takeIf { it > 0 }?.let {
|
||||
put("sample_rate", JsonPrimitive(it))
|
||||
}
|
||||
if (finalAnswerOnly) {
|
||||
put("final_answer_only", JsonPrimitive(true))
|
||||
}
|
||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("chat_session_id", JsonPrimitive(it))
|
||||
}
|
||||
@@ -2980,6 +3007,9 @@ class RelayVoiceClient(
|
||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
||||
spokenHandoff = (obj["spoken_handoff"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.toBooleanStrictOrNull(),
|
||||
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
||||
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
||||
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
||||
@@ -3374,6 +3404,7 @@ data class RealtimeVoiceEvent(
|
||||
// ADR 33: background-run promotion fields.
|
||||
val tier: String? = null,
|
||||
val floor: String? = null,
|
||||
val spokenHandoff: Boolean? = null,
|
||||
// hermes.run.progress extras — drive the live background-run chip.
|
||||
val activeToolName: String? = null,
|
||||
val completedToolCount: Int? = null,
|
||||
|
||||
@@ -108,6 +108,7 @@ import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -1919,6 +1920,14 @@ fun RelayApp() {
|
||||
voiceViewModel = voiceViewModel,
|
||||
voiceClient = voiceClient,
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
voicePresentationMode = VoicePresentationMode.fromStorage(
|
||||
voiceSettings.presentationMode,
|
||||
),
|
||||
onVoicePresentationModeChange = { mode ->
|
||||
connectionSwitchScope.launch {
|
||||
voicePreferences.setPresentationMode(mode)
|
||||
}
|
||||
},
|
||||
openAgentSheetOnEntry = openAgentSheetArg,
|
||||
onAgentSheetArgConsumed = {
|
||||
backStackEntry.arguments?.putBoolean(
|
||||
|
||||
+5
-10
@@ -268,7 +268,6 @@ fun ActiveCardRelayStatusSection(
|
||||
@Composable
|
||||
fun ActiveCardFeaturesSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
@@ -349,21 +348,19 @@ fun ActiveCardFeaturesSection(
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||
relayReady -> stringResource(R.string.active_section_ready)
|
||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||
else -> stringResource(R.string.active_section_configured)
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
|
||||
!relayConfigured -> CapabilityTone.Neutral
|
||||
relayReady -> CapabilityTone.Good
|
||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Info
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
@@ -614,7 +611,6 @@ private fun CapabilityRow(
|
||||
@Composable
|
||||
fun ActiveCardAdvancedSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
|
||||
onPairRelay: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -654,7 +650,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled, showApi = true, showRelay = false)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = true, showRelay = false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -731,7 +727,7 @@ fun ActiveCardAdvancedSection(
|
||||
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
|
||||
Text(stringResource(R.string.active_section_other_relay_methods))
|
||||
}
|
||||
if (relayEnabled && relayEditorOpen) {
|
||||
if (relayEditorOpen) {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -746,7 +742,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled = true, showApi = false, showRelay = true)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = false, showRelay = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,7 +794,6 @@ fun ActiveCardAdvancedSection(
|
||||
@Composable
|
||||
private fun ManualUrlSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
showApi: Boolean,
|
||||
showRelay: Boolean,
|
||||
) {
|
||||
@@ -960,7 +955,7 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
}
|
||||
|
||||
if (relayEnabled && showRelay) {
|
||||
if (showRelay) {
|
||||
HorizontalDivider()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
|
||||
@@ -72,6 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -261,8 +262,12 @@ fun AgentTextFlow(
|
||||
motionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
// Readable, non-faded mirror of the visible tail — used as the live-region
|
||||
// text on both paths so assistive tech hears the words.
|
||||
|
||||
+1146
-11
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,6 @@ import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
|
||||
@@ -197,8 +196,6 @@ fun ConnectionWizard(
|
||||
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
@@ -621,7 +618,6 @@ fun ConnectionWizard(
|
||||
WizardStep.RelayChoice -> RelayChoiceStep(
|
||||
connectionLabel = activeConnection?.label.orEmpty(),
|
||||
dashboardUrl = currentDashboardUrl,
|
||||
relayEnabled = relayEnabled,
|
||||
onPickScan = {
|
||||
chosenMethod = PairMethod.Scan
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
@@ -638,7 +634,6 @@ fun ConnectionWizard(
|
||||
)
|
||||
|
||||
WizardStep.Method -> MethodStep(
|
||||
relayEnabled = relayEnabled,
|
||||
onPickStandard = {
|
||||
chosenMethod = PairMethod.Standard
|
||||
standardError = null
|
||||
@@ -1701,7 +1696,6 @@ private fun FoundCapabilityLine(label: String, value: String, ready: Boolean) {
|
||||
private fun RelayChoiceStep(
|
||||
connectionLabel: String,
|
||||
dashboardUrl: String,
|
||||
relayEnabled: Boolean,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
onPickShowCode: () -> Unit,
|
||||
@@ -1769,14 +1763,12 @@ private fun RelayChoiceStep(
|
||||
subtitle = stringResource(R.string.cw_method_pair_code_subtitle),
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { openExternalUrl(context, RelaySetupDocsUrl) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1794,7 +1786,6 @@ private fun RelayChoiceStep(
|
||||
|
||||
@Composable
|
||||
private fun MethodStep(
|
||||
relayEnabled: Boolean,
|
||||
onPickStandard: () -> Unit,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
@@ -1931,14 +1922,12 @@ private fun MethodStep(
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
|
||||
if (onSkip != null) {
|
||||
TextButton(
|
||||
|
||||
@@ -67,6 +67,11 @@ fun MarkdownContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
color = textColor,
|
||||
)
|
||||
val highlightsBuilder = remember(isDarkTheme) {
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||
}
|
||||
@@ -87,7 +92,8 @@ fun MarkdownContent(
|
||||
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
||||
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
|
||||
// live font-picker still applies) and is capped so the largest heading is
|
||||
// ~1.4x the 14sp body, matching Discord / GitHub-mobile in-message headings.
|
||||
// proportionate to the 15sp body, matching Discord / GitHub-mobile
|
||||
// in-message headings.
|
||||
typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
@@ -108,16 +114,17 @@ fun MarkdownContent(
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||
color = textColor.copy(alpha = 0.85f),
|
||||
),
|
||||
// Prose, list items, and quotes all sit at the 14sp body size so a
|
||||
// paragraph and the bullet list under it share one rhythm — the library
|
||||
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph.
|
||||
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
list = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
quote = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f),
|
||||
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
|
||||
// The library default 'text'/list role is bodyLarge (16sp), while
|
||||
// bodyMedium was previously 14sp and unnecessarily small for long chat.
|
||||
paragraph = chatBodyStyle,
|
||||
text = chatBodyStyle,
|
||||
bullet = chatBodyStyle,
|
||||
ordered = chatBodyStyle,
|
||||
list = chatBodyStyle,
|
||||
quote = chatBodyStyle.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = textColor.copy(alpha = 0.9f),
|
||||
),
|
||||
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
||||
// + the tinted chip already signal "code" without also shrinking it, and
|
||||
@@ -125,7 +132,7 @@ fun MarkdownContent(
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = textColor,
|
||||
),
|
||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
@@ -302,7 +309,10 @@ fun StreamingMarkdownContent(
|
||||
// code and deliberately spaced prose are not altered.
|
||||
text = content.withoutLeadingBlankLines(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
),
|
||||
color = textColor,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -174,7 +174,7 @@ fun MessageBubble(
|
||||
|
||||
val textColor = when (message.role) {
|
||||
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurface
|
||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
}
|
||||
|
||||
@@ -469,7 +469,10 @@ fun MessageBubble(
|
||||
// Plain text for user and system messages
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
),
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -73,8 +74,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
@@ -146,7 +149,8 @@ fun VoiceModeOverlay(
|
||||
voiceOutputEnabled: Boolean? = null,
|
||||
voiceOutputFallbackEnabled: Boolean? = null,
|
||||
onOverlayRequest: () -> Unit = {},
|
||||
onCompactModeChange: (Boolean) -> Unit = {},
|
||||
presentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||
onPresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||
// === END PHASE3-voice-mode-transcript ===
|
||||
// === v0.4.1 JIT permission-denied chip ===
|
||||
// Tapped when the user clicks the permission-denied chip. Default no-op
|
||||
@@ -159,22 +163,21 @@ fun VoiceModeOverlay(
|
||||
onBackgroundRunCancel: () -> Unit = {},
|
||||
onBackgroundRunTap: () -> Unit = {},
|
||||
onHermesConfirmationAnswer: (String) -> Unit = {},
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit =
|
||||
{ _, _, _ -> },
|
||||
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit =
|
||||
{ _, _, _ -> },
|
||||
// === END v0.4.1 ===
|
||||
) {
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
var controlsExpanded by remember { mutableStateOf(false) }
|
||||
var focusMode by remember { mutableStateOf(true) }
|
||||
val focusMode = presentationMode == VoicePresentationMode.Focus
|
||||
val setFocusMode: (Boolean) -> Unit = { focused ->
|
||||
focusMode = focused
|
||||
onCompactModeChange(!focused)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.voiceMode) {
|
||||
if (!uiState.voiceMode) {
|
||||
setFocusMode(true)
|
||||
}
|
||||
onPresentationModeChange(
|
||||
if (focused) VoicePresentationMode.Focus else VoicePresentationMode.Conversation,
|
||||
)
|
||||
}
|
||||
|
||||
// Voice errors surface ONLY on the overlay's own inline top banner
|
||||
@@ -193,7 +196,7 @@ fun VoiceModeOverlay(
|
||||
// chips, pill) didn't handle so stray taps/swipes don't fall
|
||||
// through to the chat + session drawer behind it. Children run on
|
||||
// the same Main pass leaf-first, so this only catches the gaps.
|
||||
// In compact mode the overlay is intentionally transparent and the
|
||||
// In Conversation the overlay is intentionally transparent and the
|
||||
// chat stays interactive, so no scrim is installed.
|
||||
.then(
|
||||
if (focusMode) {
|
||||
@@ -404,6 +407,11 @@ fun VoiceModeOverlay(
|
||||
message = msg,
|
||||
showThinking = showThinking,
|
||||
expanded = msg.id == latestId || msg.isStreaming,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
if (pendingTranscriptText != null) {
|
||||
@@ -418,6 +426,11 @@ fun VoiceModeOverlay(
|
||||
),
|
||||
showThinking = showThinking,
|
||||
expanded = true,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -449,7 +462,7 @@ fun VoiceModeOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// Compact mode: the background-run chip must survive outside focus
|
||||
// Conversation: the background-run chip must survive outside focus
|
||||
// mode too — a running task with no visible presence reads as lost
|
||||
// (the chip previously existed ONLY in the focus layout).
|
||||
AnimatedVisibility(
|
||||
@@ -821,6 +834,15 @@ private fun VoiceSessionPill(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!focusMode) {
|
||||
ConversationVoiceMicButton(
|
||||
uiState = uiState,
|
||||
onMicTap = onMicTap,
|
||||
onMicRelease = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
|
||||
@@ -855,10 +877,9 @@ private fun VoiceSessionPill(
|
||||
.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Moved out of the collapsed header (4a): the current
|
||||
// interaction-mode pill plus the inline mic control. The
|
||||
// compact mic only appears in compact mode, where the
|
||||
// full-size bottom mic button is hidden.
|
||||
// The expanded body keeps the current interaction mode
|
||||
// visible; Conversation's persistent mic stays in the
|
||||
// collapsed header so it never disappears.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -866,15 +887,6 @@ private fun VoiceSessionPill(
|
||||
) {
|
||||
StatusPill(uiState.interactionMode.label())
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (!focusMode) {
|
||||
CompactVoiceMicButton(
|
||||
uiState = uiState,
|
||||
onMicTap = onMicTap,
|
||||
onMicRelease = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -915,7 +927,11 @@ private fun VoiceSessionPill(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus),
|
||||
if (focusMode) {
|
||||
stringResource(R.string.voice_overlay_conversation)
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_focus)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -958,7 +974,7 @@ private fun VoiceSessionPill(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactVoiceMicButton(
|
||||
private fun ConversationVoiceMicButton(
|
||||
uiState: VoiceUiState,
|
||||
onMicTap: () -> Unit,
|
||||
onMicRelease: () -> Unit,
|
||||
@@ -1224,6 +1240,9 @@ private fun CompactTranscriptRow(
|
||||
message: ChatMessage,
|
||||
showThinking: Boolean,
|
||||
expanded: Boolean,
|
||||
onViewConversation: () -> Unit,
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
|
||||
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
|
||||
) {
|
||||
if (message.role == MessageRole.SYSTEM) return
|
||||
|
||||
@@ -1248,6 +1267,13 @@ private fun CompactTranscriptRow(
|
||||
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.secondary
|
||||
}
|
||||
val (markdownBody, inlineImages) = remember(message.content, message.role) {
|
||||
if (message.role == MessageRole.ASSISTANT) {
|
||||
extractChatInlineImages(message.content)
|
||||
} else {
|
||||
message.content to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1285,7 +1311,7 @@ private fun CompactTranscriptRow(
|
||||
}
|
||||
when {
|
||||
isVoiceActionBubble && hasText -> MarkdownContent(
|
||||
content = message.content,
|
||||
content = markdownBody,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
message.role == MessageRole.USER && hasText -> Text(
|
||||
@@ -1295,12 +1321,107 @@ private fun CompactTranscriptRow(
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
hasText -> Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
hasText -> MarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
message.cards.forEachIndexed { index, card ->
|
||||
if (card.actions.isNotEmpty() || card.input != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val cardKey = card.id ?: "idx:$index"
|
||||
HermesCardBubble(
|
||||
card = card,
|
||||
cardKey = cardKey,
|
||||
dispatches = message.cardDispatches,
|
||||
onActionTap = { key, action ->
|
||||
onCardAction(message.id, key, action)
|
||||
},
|
||||
onInputSubmit = { key, value ->
|
||||
onCardInput(message.id, key, value)
|
||||
},
|
||||
maxWidth = 360.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.attachments.isNotEmpty() ||
|
||||
message.cards.any { it.actions.isEmpty() && it.input == null } ||
|
||||
inlineImages.isNotEmpty()
|
||||
) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
VoiceRichResultAffordance(
|
||||
message = message,
|
||||
onViewConversation = onViewConversation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceRichResultAffordance(
|
||||
message: ChatMessage,
|
||||
onViewConversation: () -> Unit,
|
||||
) {
|
||||
val inlineImages = remember(message.content) {
|
||||
extractChatInlineImages(message.content).second
|
||||
}
|
||||
val previewModel = remember(message.attachments, inlineImages) {
|
||||
message.attachments.firstOrNull { it.isImage && !it.cachedUri.isNullOrBlank() }?.cachedUri
|
||||
?: inlineImages.firstOrNull {
|
||||
it.src.startsWith("https://") || it.src.startsWith("http://")
|
||||
}?.src
|
||||
}
|
||||
val label = when {
|
||||
message.attachments.size + inlineImages.size > 1 ->
|
||||
stringResource(
|
||||
R.string.voice_overlay_rich_results_count,
|
||||
message.attachments.size + inlineImages.size,
|
||||
)
|
||||
message.attachments.isNotEmpty() || inlineImages.isNotEmpty() ->
|
||||
stringResource(R.string.voice_overlay_image_ready)
|
||||
else -> stringResource(R.string.voice_overlay_rich_result_ready)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onViewConversation),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.48f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (previewModel != null) {
|
||||
AsyncImage(
|
||||
model = previewModel,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_view_conversation),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +206,16 @@ fun AboutScreen(
|
||||
val remaining = 7 - versionTapCount
|
||||
when {
|
||||
remaining <= 0 -> {
|
||||
scope.launch { FeatureFlags.unlockDevOptions(context) }
|
||||
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
|
||||
versionTapCount = 0
|
||||
onUnlockDeveloperOptions()
|
||||
scope.launch {
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
devUnlockedMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onUnlockDeveloperOptions()
|
||||
}
|
||||
}
|
||||
remaining <= 3 -> {
|
||||
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
||||
|
||||
@@ -156,7 +156,9 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
||||
@@ -280,6 +282,17 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
|
||||
previous.messageCount == messageCount &&
|
||||
previous.lastMessageUiKey == lastMessageUiKey
|
||||
|
||||
internal fun releaseRetainedLiveTail(
|
||||
retainedUiKey: String?,
|
||||
completedUiKey: String?,
|
||||
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
|
||||
|
||||
internal fun tailEndScrollOffset(
|
||||
tailSizePx: Int,
|
||||
footerSizePx: Int,
|
||||
viewportSizePx: Int,
|
||||
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
|
||||
|
||||
private class ChatTailTransitionRef(
|
||||
var snapshot: ChatScrollSnapshot? = null,
|
||||
)
|
||||
@@ -439,6 +452,8 @@ fun ChatScreen(
|
||||
voiceViewModel: VoiceViewModel,
|
||||
voiceClient: RelayVoiceClient? = null,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
voicePresentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||
onVoicePresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||
// Deep-link nudge from Settings → Active Agent card: when `true`, the
|
||||
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
|
||||
// fires so the host can clear the nav arg (prevents re-open on tab
|
||||
@@ -465,9 +480,10 @@ fun ChatScreen(
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
||||
val chatAlpha by animateFloatAsState(
|
||||
targetValue = if (voiceUiState.voiceMode && !voiceCompactMode) 0.4f else 1f,
|
||||
targetValue = if (
|
||||
voiceUiState.voiceMode && voicePresentationMode == VoicePresentationMode.Focus
|
||||
) 0.4f else 1f,
|
||||
animationSpec = tween(300),
|
||||
label = "chatAlpha",
|
||||
)
|
||||
@@ -788,6 +804,25 @@ fun ChatScreen(
|
||||
val clipboard = LocalClipboard.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val handleCardAction: (String, String, HermesCardAction) -> Unit =
|
||||
remember(chatViewModel, context) {
|
||||
{ messageId, cardKey, action ->
|
||||
if (action.mode == HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context,
|
||||
action,
|
||||
)
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
val handleCardInput: (String, String, String) -> Unit = remember(chatViewModel) {
|
||||
{ messageId, cardKey, value ->
|
||||
chatViewModel.answerAsk(messageId, cardKey, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
|
||||
// transient snackbar, never a chat bubble.
|
||||
@@ -885,7 +920,6 @@ fun ChatScreen(
|
||||
if (!voiceUiState.voiceMode) {
|
||||
voiceOverlayHost.hide()
|
||||
pendingVoiceOverlayPermission = false
|
||||
voiceCompactMode = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,10 +1164,12 @@ fun ChatScreen(
|
||||
derivedStateOf {
|
||||
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
||||
val settlingVisibleTail = completionSettlingUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == completionSettlingUiKey
|
||||
messages.isNotEmpty() &&
|
||||
!isAtBottom &&
|
||||
!programmaticBottomScroll &&
|
||||
!((isStreaming || retainingVisibleTail) &&
|
||||
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
|
||||
smoothAutoScroll &&
|
||||
!userScrolledAway)
|
||||
}
|
||||
@@ -1331,34 +1367,99 @@ fun ChatScreen(
|
||||
) {
|
||||
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
||||
// Retention is only a completion-transition aid. Never leave the
|
||||
// finalized tail on the plain streaming renderer just because the
|
||||
// user disabled follow-scroll or is reading above the bottom.
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var settledFrames = 0
|
||||
repeat(6) {
|
||||
var previousMarkdownTailSize: Int? = null
|
||||
var previousMarkdownFooterSize: Int? = null
|
||||
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
|
||||
repeat(60) completionFrame@{
|
||||
withFrameNanos { }
|
||||
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (listState.canScrollForward) {
|
||||
settledFrames = 0
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
if (viewportHeight > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(viewportHeight.toFloat())
|
||||
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
|
||||
if (listState.canScrollForward) {
|
||||
settledFrames = 0
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
if (viewportHeight > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(viewportHeight.toFloat())
|
||||
}
|
||||
}
|
||||
return@completionFrame
|
||||
}
|
||||
} else {
|
||||
|
||||
settledFrames += 1
|
||||
if (settledFrames >= 2) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (settledFrames < 2) return@completionFrame
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
settledFrames = 0
|
||||
return@completionFrame
|
||||
}
|
||||
|
||||
// Once Markdown owns the row, position its measured trailing edge
|
||||
// explicitly. `canScrollForward` is insufficient here: LazyColumn
|
||||
// may preserve the leading edge of a tall item while reporting an
|
||||
// otherwise valid item anchor. Repeating catches deferred parsing,
|
||||
// highlighted code, and attachment measurement without competing
|
||||
// with the ordinary streaming-growth coroutine.
|
||||
val layout = listState.layoutInfo
|
||||
val tailIndex = messages.size // header item + zero-based messages
|
||||
val footerIndex = tailIndex + 1
|
||||
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
|
||||
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
|
||||
if (tailInfo == null) {
|
||||
listState.scrollToItem(tailIndex)
|
||||
settledFrames = 0
|
||||
return@completionFrame
|
||||
}
|
||||
|
||||
val viewportHeight = layout.viewportSize.height
|
||||
if (viewportHeight <= 0) return@completionFrame
|
||||
val desiredOffset = tailEndScrollOffset(
|
||||
tailSizePx = tailInfo.size,
|
||||
footerSizePx = footerInfo?.size ?: 0,
|
||||
viewportSizePx = viewportHeight,
|
||||
)
|
||||
if (desiredOffset == 0) {
|
||||
listState.scrollToItem(footerIndex)
|
||||
} else {
|
||||
listState.scrollToItem(tailIndex, desiredOffset)
|
||||
}
|
||||
val footerSize = footerInfo?.size ?: 0
|
||||
settledFrames = if (
|
||||
previousMarkdownTailSize == tailInfo.size &&
|
||||
previousMarkdownFooterSize == footerSize
|
||||
) {
|
||||
settledFrames + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
previousMarkdownTailSize = tailInfo.size
|
||||
previousMarkdownFooterSize = footerSize
|
||||
if (settledFrames >= 12) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
completionSettlingUiKey = null
|
||||
}
|
||||
|
||||
@@ -2308,24 +2409,8 @@ fun ChatScreen(
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = { msgId, cardKey, action ->
|
||||
// OPEN_URL is resolved at the UI layer
|
||||
// because launching ACTION_VIEW needs a
|
||||
// Context. Record the dispatch first so
|
||||
// the card collapses even if launch fails.
|
||||
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context,
|
||||
action,
|
||||
)
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onCardInput = { msgId, cardKey, value ->
|
||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
@@ -3184,14 +3269,13 @@ fun ChatScreen(
|
||||
voiceConfigScope = activeVoiceScope,
|
||||
voiceOutputEnabled = activeVoiceEnabled,
|
||||
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
presentationMode = voicePresentationMode,
|
||||
onPresentationModeChange = onVoicePresentationModeChange,
|
||||
onOverlayRequest = showVoiceSystemOverlay,
|
||||
// Gear button in the overlay's expanded controls. The overlay
|
||||
// exits voice mode before invoking this, so navigation lands
|
||||
// on Voice Settings with no overlay left on top.
|
||||
onOpenSettings = onNavigateToVoiceSettings,
|
||||
onCompactModeChange = { compact ->
|
||||
voiceCompactMode = compact
|
||||
},
|
||||
// === v0.4.1 JIT permission-denied chip ===
|
||||
// Tap deep-links to Settings → Apps → Hermes-Relay →
|
||||
// Permissions for the running package. Use BuildConfig
|
||||
@@ -3215,6 +3299,8 @@ fun ChatScreen(
|
||||
onHermesConfirmationAnswer = { answer ->
|
||||
voiceViewModel.answerHermesConfirmation(answer)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
// === END v0.4.1 ===
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
||||
@@ -110,9 +109,6 @@ fun ConnectionDetailScreen(
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
val connection = connections.firstOrNull { it.id == connectionId }
|
||||
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
||||
LaunchedEffect(connection == null) {
|
||||
@@ -275,7 +271,6 @@ fun ConnectionDetailScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
relayUiState = relayUiState,
|
||||
relayEnabled = relayEnabled,
|
||||
onReconnect = onReconnect,
|
||||
onRepair = { onRepair(connectionId) },
|
||||
onOpenApiInfo = { showApiInfoSheet = true },
|
||||
@@ -303,7 +298,6 @@ fun ConnectionDetailScreen(
|
||||
|
||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onPairRelay = { onRepair(connectionId) },
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
@@ -423,7 +417,6 @@ private fun ActiveOverview(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
connection: Connection,
|
||||
relayUiState: RelayUiState,
|
||||
relayEnabled: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
@@ -498,7 +491,6 @@ private fun ActiveOverview(
|
||||
|
||||
ActiveCardFeaturesSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
onOpenApiInfo = onOpenApiInfo,
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -76,6 +77,7 @@ fun DashboardSignInScreen(
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
@@ -135,13 +137,13 @@ fun DashboardSignInScreen(
|
||||
LaunchedEffect(dashboardUrl, connectionId) {
|
||||
if (dashboardUrl.isBlank()) {
|
||||
loading = false
|
||||
actionMessage = context.getString(R.string.dashboard_no_url_configured)
|
||||
actionMessage = resources.getString(R.string.dashboard_no_url_configured)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val status = client.getStatus().getOrElse {
|
||||
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
|
||||
actionMessage = it.message ?: resources.getString(R.string.dashboard_request_failed)
|
||||
actionIsError = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
@@ -179,11 +181,11 @@ fun DashboardSignInScreen(
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = result.exceptionOrNull()?.message
|
||||
?: context.getString(R.string.dashboard_signin_no_session)
|
||||
?: resources.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
|
||||
actionMessage = e.message ?: resources.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
@@ -199,20 +201,20 @@ fun DashboardSignInScreen(
|
||||
return
|
||||
}
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
if (authClient == null) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
|
||||
actionInFlight = true
|
||||
actionIsError = false
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_opening)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_opening)
|
||||
nativeSignInJob = scope.launch {
|
||||
try {
|
||||
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
|
||||
@@ -228,19 +230,19 @@ fun DashboardSignInScreen(
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||
actionIsError = false
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = context.getString(R.string.dashboard_signin_no_session)
|
||||
actionMessage = resources.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
actionMessage = error.message
|
||||
?: context.getString(R.string.dashboard_signin_failed)
|
||||
?: resources.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
@@ -272,8 +274,8 @@ fun DashboardSignInScreen(
|
||||
client.shutdown()
|
||||
}
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||
finishAuthentication()
|
||||
}
|
||||
},
|
||||
@@ -327,7 +329,7 @@ fun DashboardSignInScreen(
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = ::startRedirectSignIn,
|
||||
onCancelNativeSignIn = {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionIsError = false
|
||||
nativeSignInJob?.cancel()
|
||||
},
|
||||
|
||||
@@ -34,13 +34,11 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
||||
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management
|
||||
* (clear session / wipe caches), and any experimental toggles.
|
||||
* unlock. Hosts experimental labs, test tools, and data-management utilities.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -79,8 +76,6 @@ fun DeveloperSettingsScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Data management local state — unfolded from the private
|
||||
// DataManagementSection helper in the old SettingsScreen.
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
@@ -185,8 +180,17 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.resetOnboarding()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetOnboarding { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_onboarding_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RestartAlt,
|
||||
@@ -300,42 +304,6 @@ fun DeveloperSettingsScreen(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Relay features toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = relayEnabled,
|
||||
onCheckedChange = { scope.launch { FeatureFlags.setRelayEnabled(context, it) } }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -432,9 +400,15 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch { FeatureFlags.lockDevOptions(context) }
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
|
||||
onBack()
|
||||
scope.launch {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_locked_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onBack()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
@@ -562,8 +536,16 @@ fun DeveloperSettingsScreen(
|
||||
onClick = {
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
if (json == null) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_export_failed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -619,8 +601,17 @@ fun DeveloperSettingsScreen(
|
||||
TextButton(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
connectionViewModel.resetAppData()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetAppData { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_app_data_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
|
||||
|
||||
@@ -3224,6 +3224,17 @@ private fun GlobalVoiceControlsCard(
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
SettingSwitchRow(
|
||||
title = stringResource(R.string.voice_settings_final_answer_only),
|
||||
detail = stringResource(R.string.voice_settings_final_answer_only_desc),
|
||||
checked = voiceSettings.finalAnswerOnly,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch { prefsRepo.setFinalAnswerOnly(enabled) }
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.voice_settings_interaction_mode),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
|
||||
@@ -5922,6 +5922,13 @@ class ChatViewModel : ViewModel() {
|
||||
queuedCount = event.queuedCount ?: 0,
|
||||
),
|
||||
)
|
||||
if (event.spokenHandoff == false) {
|
||||
// Silent promotion is the foreground turn boundary. The
|
||||
// background run keeps its owner/card and continues to
|
||||
// receive progress, cancellation, and delivery events.
|
||||
handler.onStreamComplete(assistantMessageId)
|
||||
activeStream = null
|
||||
}
|
||||
}
|
||||
"hermes.run.queued" -> {
|
||||
handler.updateBackgroundTask(assistantMessageId) { task ->
|
||||
|
||||
@@ -6371,50 +6371,62 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOnboarding() {
|
||||
fun resetOnboarding(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
dataManager.resetOnboarding()
|
||||
_onboardingCompleted.value = false
|
||||
val success = dataManager.resetOnboarding()
|
||||
if (success) {
|
||||
_onboardingCompleted.value = false
|
||||
}
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAppData() {
|
||||
fun resetAppData(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
dataManager.resetAppData()
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
val success = runCatching {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to reset app data", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSettings(onResult: (String) -> Unit) {
|
||||
fun exportSettings(onResult: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val json = dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
val json = runCatching {
|
||||
dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to prepare settings backup", it)
|
||||
}.getOrNull()
|
||||
onResult(json)
|
||||
}
|
||||
}
|
||||
@@ -6436,24 +6448,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
// Apply imported settings
|
||||
if (backup.connections.isNotEmpty()) {
|
||||
val success = runCatching {
|
||||
val importedRelayUrl = if (backup.connections.isEmpty()) {
|
||||
backup.relayUrl ?: backup.serverUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A backup is a replacement snapshot, including when it
|
||||
// intentionally contains zero connections.
|
||||
dataManager.restoreConnectionBackup(backup)
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_THEME] = backup.theme
|
||||
importedRelayUrl?.let { preferences[KEY_RELAY_URL] = it }
|
||||
backup.apiServerUrl
|
||||
?.takeIf { backup.connections.isEmpty() }
|
||||
?.let { preferences[KEY_API_SERVER_URL] = it }
|
||||
}
|
||||
connectionStore.activeConnection.value?.let { restored ->
|
||||
restorePersistedActiveConnectionContext(restored)
|
||||
}
|
||||
} else {
|
||||
// Prefer v2 fields, fall back to v1 serverUrl for relay
|
||||
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
setTheme(backup.theme)
|
||||
if (backup.onboardingCompleted) {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
_onboardingCompleted.value = true
|
||||
}
|
||||
onResult(true)
|
||||
if (backup.connections.isEmpty()) {
|
||||
// Preserve v1/v2 compatibility after clearing the current
|
||||
// multi-connection snapshot.
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
check(dataManager.setOnboardingCompleted(backup.onboardingCompleted)) {
|
||||
"Failed to restore onboarding state"
|
||||
}
|
||||
_onboardingCompleted.value = backup.onboardingCompleted
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to import settings backup", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,16 @@ internal data class AssistantSpeechBatch(
|
||||
val assistantMessages: List<ChatMessage>,
|
||||
val aggregateText: String,
|
||||
val hasTurnAssistant: Boolean,
|
||||
)
|
||||
) {
|
||||
/** Last non-empty assistant bubble: the settled answer after any tool commentary. */
|
||||
val finalAnswerText: String
|
||||
get() = assistantMessages
|
||||
.asReversed()
|
||||
.firstOrNull { it.content.isNotBlank() }
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-voice-turn cursor over every assistant bubble created after the user
|
||||
@@ -323,9 +332,8 @@ enum class BackgroundRunPhase {
|
||||
DONE,
|
||||
}
|
||||
|
||||
internal fun realtimeTurnActiveAfterResponseDone(backgroundPhase: BackgroundRunPhase?): Boolean =
|
||||
backgroundPhase == BackgroundRunPhase.RUNNING ||
|
||||
backgroundPhase == BackgroundRunPhase.RECONNECTING
|
||||
internal fun realtimeTurnActiveAfterPromotion(spokenHandoff: Boolean?): Boolean =
|
||||
spokenHandoff != false
|
||||
|
||||
internal fun preserveRealtimeTurnOnStop(backgroundPhase: BackgroundRunPhase?): Boolean =
|
||||
backgroundPhase == BackgroundRunPhase.RUNNING ||
|
||||
@@ -648,6 +656,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var voicePreferences: VoicePreferencesRepository? = null
|
||||
private var voicePreferencesJob: Job? = null
|
||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
private var finalAnswerOnly: Boolean = false
|
||||
private var realtimeTraceDetails: Boolean = false
|
||||
private var realtimePersistentSession: Boolean = true
|
||||
private var realtimeModel: String = ""
|
||||
@@ -1368,10 +1377,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
|
||||
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
|
||||
val finalAnswerPolicyChanged = finalAnswerOnly != settings.finalAnswerOnly
|
||||
val realtimeSelectionChanged =
|
||||
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
|
||||
if (
|
||||
voiceEngineMode != nextEngineMode ||
|
||||
finalAnswerPolicyChanged ||
|
||||
realtimeTraceDetails != settings.realtimeTraceDetails ||
|
||||
realtimeSelectionChanged
|
||||
) {
|
||||
@@ -1379,6 +1390,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
TAG,
|
||||
"Voice prefs updated engine=${nextEngineMode.storageValue} " +
|
||||
"interaction=${settings.interactionMode} " +
|
||||
"finalAnswerOnly=${settings.finalAnswerOnly} " +
|
||||
"realtimeTraceDetails=${settings.realtimeTraceDetails} " +
|
||||
"realtimeModel=${settings.realtimeModel.ifBlank { "relay-default" }} " +
|
||||
"realtimeVoice=${settings.realtimeVoice.ifBlank { "relay-default" }}",
|
||||
@@ -1390,11 +1402,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
(voiceEngineMode == VoiceEngineMode.RealtimeAgent &&
|
||||
nextEngineMode != VoiceEngineMode.RealtimeAgent) ||
|
||||
(realtimePersistentSession && !settings.realtimePersistentSession) ||
|
||||
finalAnswerPolicyChanged ||
|
||||
realtimeSelectionChanged
|
||||
) {
|
||||
closeRealtimeSession()
|
||||
}
|
||||
voiceEngineMode = nextEngineMode
|
||||
finalAnswerOnly = settings.finalAnswerOnly
|
||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||
realtimePersistentSession = settings.realtimePersistentSession
|
||||
realtimeModel = settings.realtimeModel
|
||||
@@ -3162,17 +3176,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
clearSpokenChunksState()
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
|
||||
// Kick off streaming observer BEFORE sending the message so we don't
|
||||
// miss early deltas that arrive synchronously from the callback.
|
||||
startStreamObserver(chatVm)
|
||||
if (!finalAnswerOnly) {
|
||||
prepareStandardSpeechStream()
|
||||
}
|
||||
|
||||
// Route the transcribed text through the normal chat pipeline.
|
||||
// This will create a user message + kick off the SSE stream.
|
||||
// This creates the user row synchronously before kicking off the
|
||||
// transport, so bind the turn before observing the replaying StateFlows.
|
||||
// Starting the observer first leaves a small window where a legitimate
|
||||
// session adoption can be rejected before the submitted user key exists.
|
||||
// StateFlow replay preserves any assistant text that arrives before the
|
||||
// observer starts.
|
||||
val submittedUserUiKey =
|
||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
||||
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
|
||||
startStreamObserver(chatVm)
|
||||
}
|
||||
|
||||
private suspend fun runVoiceRelayPreflight(engineLabel: String): Boolean {
|
||||
@@ -3299,7 +3317,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (speak && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
|
||||
if (speak && !finalAnswerOnly && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
|
||||
// W3: per-turn throttle independent of the per-key dedupe above.
|
||||
// Suppress the TTS enqueue (UI state + diagnostics already
|
||||
// applied) when spoken status is too frequent or has hit the
|
||||
@@ -3371,6 +3389,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
conversationContext = conversationContext,
|
||||
model = realtimeModel,
|
||||
voice = realtimeVoice,
|
||||
finalAnswerOnly = finalAnswerOnly,
|
||||
onHandoff = { event -> recordRealtimeVoiceHandoff(sessionGeneration, event) },
|
||||
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
|
||||
onTurnComplete = { summary ->
|
||||
@@ -3415,9 +3434,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
if (suppressCommandResponse) {
|
||||
if (event.type == "voice.response.done") {
|
||||
providerRealtimeAgentTurnActive.set(
|
||||
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase),
|
||||
)
|
||||
providerRealtimeAgentTurnActive.set(false)
|
||||
suppressLocalCommandResponse = false
|
||||
realtimeAudioSuppressed = false
|
||||
}
|
||||
@@ -3638,10 +3655,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
"hermes.run.promoted" -> {
|
||||
providerRealtimeAgentTurnActive.set(true)
|
||||
// The run detached to the background; the provider speaks the
|
||||
// handoff. Surface a persistent chip so the user knows a long
|
||||
// task is still in flight (ADR 33 Tier B/C).
|
||||
providerRealtimeAgentTurnActive.set(
|
||||
realtimeTurnActiveAfterPromotion(event.spokenHandoff),
|
||||
)
|
||||
// The run detached to the background. A spoken handoff keeps
|
||||
// the foreground turn active until response.done; a silent
|
||||
// handoff ends it here. The task chip remains either way.
|
||||
val tier = event.tier ?: "promoted"
|
||||
Log.i(
|
||||
TAG,
|
||||
@@ -3845,9 +3864,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
hermesConfirmation = null,
|
||||
)
|
||||
}
|
||||
providerRealtimeAgentTurnActive.set(
|
||||
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase)
|
||||
)
|
||||
providerRealtimeAgentTurnActive.set(false)
|
||||
}
|
||||
"voice.error" -> {
|
||||
realtimeConfirmationControl = null
|
||||
@@ -4328,26 +4345,42 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) { messages, runActive, sessionId -> Triple(messages, runActive, sessionId) }
|
||||
.collect { (messages, runActive, sessionId) ->
|
||||
if (!sessionFence.accepts(sessionId, messages)) {
|
||||
cancelStandardSpeechStream("chat session changed")
|
||||
streamObserverJob?.cancel()
|
||||
// Session id and message history are independent flows.
|
||||
// During session creation/adoption, combine can briefly
|
||||
// pair the new id with the old history (or vice versa).
|
||||
// Skip that inconsistent snapshot without permanently
|
||||
// killing narration; the next coherent emission is still
|
||||
// fenced by the submitted user row/session identity.
|
||||
return@collect
|
||||
}
|
||||
|
||||
val batch = cursor.poll(messages)
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
if (finalAnswerOnly) {
|
||||
if (batch.deltas.isNotEmpty()) {
|
||||
onVisualStreamDelta(batch.aggregateText)
|
||||
}
|
||||
} else {
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
// Tool state can change without text growth.
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
if (!finalAnswerOnly) {
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
}
|
||||
|
||||
if (!runActive && batch.hasTurnAssistant) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
if (finalAnswerOnly) {
|
||||
speakSettledFinalAnswer(batch.finalAnswerText)
|
||||
} else if (!finishStandardSpeechStream()) {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
@@ -4355,6 +4388,44 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVisualStreamDelta(fullContent: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
responseText = fullContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Final-only mode deliberately trades streaming latency for a clean spoken
|
||||
* result. The last non-empty assistant bubble is the settled answer; earlier
|
||||
* bubbles and tool states remain visible in Chat but never enter TTS.
|
||||
*/
|
||||
private fun speakSettledFinalAnswer(answer: String) {
|
||||
val spoken = sanitizeForTts(answer)
|
||||
if (spoken.isBlank()) return
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Speaking,
|
||||
outputAudioActive = false,
|
||||
responseText = answer,
|
||||
)
|
||||
}
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
if (offerStandardSpeechText(spoken)) {
|
||||
finishStandardSpeechStream()
|
||||
} else {
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
appendSanitizedDelta(spoken)
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginAssistantSpeechBubble() {
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
@@ -4393,7 +4464,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* device through the normal tool loop.
|
||||
*/
|
||||
private fun observeHermesToolLoopForSpeech(message: ChatMessage) {
|
||||
if (!_uiState.value.voiceMode || message.toolCalls.isEmpty()) return
|
||||
if (finalAnswerOnly || !_uiState.value.voiceMode || message.toolCalls.isEmpty()) return
|
||||
|
||||
var spokenForMessage = brokeredToolSpeechCounts[message.id] ?: 0
|
||||
message.toolCalls.forEach { tool ->
|
||||
|
||||
@@ -1130,7 +1130,7 @@
|
||||
<string name="dev_settings_relay_features">Recursos do Relay</string>
|
||||
<string name="dev_settings_relay_features_desc">Mostrar configurações do Servidor Relay e de pareamento para desenvolvimento do Bridge/Terminal</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Laboratório de voz em tempo real</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Abrir a bancada de testes do WebSocket do provedor em versões de desenvolvimento</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Verifique a configuração de voz em tempo real e execute um teste de microfone</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratório de voz em tempo real</string>
|
||||
<string name="dev_settings_lock_dev_options">Bloquear opções do desenvolvedor</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta seção e desativar recursos experimentais</string>
|
||||
@@ -1161,6 +1161,7 @@
|
||||
<string name="dev_settings_export_failed">Falha na exportação</string>
|
||||
<string name="dev_settings_imported">Configurações importadas</string>
|
||||
<string name="dev_settings_import_failed">Falha na importação — arquivo inválido</string>
|
||||
<string name="dev_settings_reset_failed">Falha ao redefinir</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Exportar backup sensível?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Este backup inclui conexões salvas, chaves da API, tokens de sessão do relay, IDs de dispositivos e cookies do painel. Qualquer pessoa com o arquivo poderá acessar seu servidor Hermes.</string>
|
||||
<string name="dev_settings_export_action">Exportar</string>
|
||||
@@ -1501,6 +1502,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Salvar agente em tempo real</string>
|
||||
<string name="voice_settings_global_controls_title">Controles globais de voz</string>
|
||||
<string name="voice_settings_global_controls_desc">Estas configurações se aplicam aos dois mecanismos de voz em todos os perfis.</string>
|
||||
<string name="voice_settings_final_answer_only">Somente a resposta final</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Fala apenas a resposta concluída. O progresso das ferramentas, as atualizações de serviço e os comentários intermediários permanecem visuais.</string>
|
||||
<string name="voice_settings_interaction_mode">Modo de interação</string>
|
||||
<string name="voice_settings_interaction_tap">Tocar para falar</string>
|
||||
<string name="voice_settings_interaction_hold">Manter pressionado para falar</string>
|
||||
@@ -1995,8 +1998,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Recolher controles de voz</string>
|
||||
<string name="voice_overlay_expand_cd">Expandir controles de voz</string>
|
||||
<string name="voice_overlay_exit_cd">Sair do modo de voz</string>
|
||||
<string name="voice_overlay_compact">Compacto</string>
|
||||
<string name="voice_overlay_focus">Foco</string>
|
||||
<string name="voice_overlay_conversation">Conversa</string>
|
||||
<string name="voice_overlay_image_ready">Imagem pronta</string>
|
||||
<string name="voice_overlay_rich_result_ready">Resultado avançado pronto</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d resultados prontos</string>
|
||||
<string name="voice_overlay_view_conversation">Ver conversa</string>
|
||||
<string name="voice_overlay_overlay">Sobreposição</string>
|
||||
<string name="voice_overlay_exit">Sair</string>
|
||||
<string name="voice_overlay_settings_cd">Configurações de voz</string>
|
||||
@@ -2138,6 +2145,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirma na próxima mensagem</string>
|
||||
<string name="conn_info_connect">Conectar</string>
|
||||
<string name="conn_info_connected">Conectado</string>
|
||||
<string name="conn_info_active_configuration">Configuração ativa</string>
|
||||
<string name="conn_info_inherited">Herdado</string>
|
||||
<string name="conn_info_bypassed">Ignorado</string>
|
||||
<string name="conn_info_customize_identity">Personalizar identidade</string>
|
||||
<string name="conn_info_agent_passport">Passaporte do agente</string>
|
||||
<string name="conn_info_chat_override">Substituição do chat</string>
|
||||
<string name="conn_info_fast_tier">Nível rápido</string>
|
||||
<string name="conn_info_fast">Rápido</string>
|
||||
<string name="conn_info_context">Contexto</string>
|
||||
<string name="conn_info_profile_already_bypasses">As aprovações já são ignoradas pelo perfil.</string>
|
||||
<string name="conn_info_start_new_chat">Iniciar novo chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Escolha como as aprovações são aplicadas.</string>
|
||||
<string name="conn_info_approval_policy">Política de aprovação</string>
|
||||
<string name="conn_info_connecting">Conectando…</string>
|
||||
<string name="conn_info_connection">Conexão</string>
|
||||
<string name="conn_info_connection_state">Estado da conexão</string>
|
||||
|
||||
@@ -1185,7 +1185,7 @@
|
||||
<string name="dev_settings_relay_features">Relay 功能</string>
|
||||
<string name="dev_settings_relay_features_desc">显示 Relay 服务器和配对设置,用于 Bridge/Terminal 开发</string>
|
||||
<string name="dev_settings_realtime_voice_lab">实时语音实验室</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">为开发构建打开 provider websocket 测试台</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">检查实时语音设置并运行专项麦克风测试</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">打开实时语音实验室</string>
|
||||
<string name="dev_settings_lock_dev_options">锁定开发者选项</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">隐藏此分区并禁用实验性功能</string>
|
||||
@@ -1216,6 +1216,7 @@
|
||||
<string name="dev_settings_export_failed">导出失败</string>
|
||||
<string name="dev_settings_imported">设置已导入</string>
|
||||
<string name="dev_settings_import_failed">导入失败——文件无效</string>
|
||||
<string name="dev_settings_reset_failed">重置失败</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">导出敏感备份?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">此备份包含已保存的连接、API 密钥、Relay 会话令牌、设备 ID 和仪表盘 Cookie。任何持有该文件的人都可能访问您的 Hermes 服务器。</string>
|
||||
<string name="dev_settings_export_action">导出</string>
|
||||
@@ -1562,6 +1563,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">保存实时 Agent</string>
|
||||
<string name="voice_settings_global_controls_title">全局语音控制</string>
|
||||
<string name="voice_settings_global_controls_desc">这些设置适用于所有个人资料上的两个语音引擎。</string>
|
||||
<string name="voice_settings_final_answer_only">仅朗读最终答案</string>
|
||||
<string name="voice_settings_final_answer_only_desc">只朗读最终确定的答案。工具进度、服务更新和中间评论仍仅以视觉方式显示。</string>
|
||||
<string name="voice_settings_interaction_mode">交互模式</string>
|
||||
<string name="voice_settings_interaction_tap">点击说话</string>
|
||||
<string name="voice_settings_interaction_hold">按住说话</string>
|
||||
@@ -2087,8 +2090,12 @@
|
||||
<string name="voice_overlay_collapse_cd">收起语音控制</string>
|
||||
<string name="voice_overlay_expand_cd">展开语音控制</string>
|
||||
<string name="voice_overlay_exit_cd">退出语音模式</string>
|
||||
<string name="voice_overlay_compact">紧凑</string>
|
||||
<string name="voice_overlay_focus">专注</string>
|
||||
<string name="voice_overlay_conversation">对话</string>
|
||||
<string name="voice_overlay_image_ready">图片已就绪</string>
|
||||
<string name="voice_overlay_rich_result_ready">丰富结果已就绪</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d 个结果已就绪</string>
|
||||
<string name="voice_overlay_view_conversation">查看对话</string>
|
||||
<string name="voice_overlay_overlay">浮窗</string>
|
||||
<string name="voice_overlay_exit">退出</string>
|
||||
<string name="voice_overlay_settings_cd">语音设置</string>
|
||||
@@ -2232,6 +2239,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">下条消息确认</string>
|
||||
<string name="conn_info_connect">连接</string>
|
||||
<string name="conn_info_connected">已连接</string>
|
||||
<string name="conn_info_active_configuration">当前配置</string>
|
||||
<string name="conn_info_inherited">继承</string>
|
||||
<string name="conn_info_bypassed">已绕过</string>
|
||||
<string name="conn_info_customize_identity">自定义身份</string>
|
||||
<string name="conn_info_agent_passport">智能体档案</string>
|
||||
<string name="conn_info_chat_override">对话覆盖</string>
|
||||
<string name="conn_info_fast_tier">快速层级</string>
|
||||
<string name="conn_info_fast">快速</string>
|
||||
<string name="conn_info_context">上下文</string>
|
||||
<string name="conn_info_profile_already_bypasses">此配置已绕过审批。</string>
|
||||
<string name="conn_info_start_new_chat">开始新对话</string>
|
||||
<string name="conn_info_approval_mode_short_desc">选择如何执行审批。</string>
|
||||
<string name="conn_info_approval_policy">审批策略</string>
|
||||
<string name="conn_info_connecting">连接中…</string>
|
||||
<string name="conn_info_connection">连接</string>
|
||||
<string name="conn_info_connection_state">连接状态</string>
|
||||
|
||||
@@ -1188,7 +1188,7 @@
|
||||
<string name="dev_settings_relay_features">Relay-Funktionen</string>
|
||||
<string name="dev_settings_relay_features_desc">Relay-Server- und Kopplungseinstellungen für Bridge-/Terminal-Entwicklung anzeigen</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Echtzeit-Sprachlabor</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">WebSocket-Testumgebung des Anbieters für Entwicklungs-Builds öffnen</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Echtzeit-Sprachkonfiguration prüfen und einen gezielten Mikrofontest ausführen</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Echtzeit-Sprachlabor öffnen</string>
|
||||
<string name="dev_settings_lock_dev_options">Entwickleroptionen sperren</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Diesen Abschnitt ausblenden und experimentelle Funktionen deaktivieren</string>
|
||||
@@ -1219,6 +1219,7 @@
|
||||
<string name="dev_settings_export_failed">Export fehlgeschlagen</string>
|
||||
<string name="dev_settings_imported">Einstellungen importiert</string>
|
||||
<string name="dev_settings_import_failed">Import fehlgeschlagen — ungültige Datei</string>
|
||||
<string name="dev_settings_reset_failed">Zurücksetzen fehlgeschlagen</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Vertrauliche Sicherung exportieren?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Diese Sicherung enthält gespeicherte Verbindungen, API-Schlüssel, Relay-Sitzungstoken, Geräte-IDs und Dashboard-Cookies. Jede Person mit dieser Datei kann möglicherweise auf deinen Hermes-Server zugreifen.</string>
|
||||
<string name="dev_settings_export_action">Exportieren</string>
|
||||
@@ -1565,6 +1566,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Echtzeit-Agent speichern</string>
|
||||
<string name="voice_settings_global_controls_title">Globale Sprachsteuerung</string>
|
||||
<string name="voice_settings_global_controls_desc">Diese Einstellungen gelten für beide Sprach-Engines in jedem Profil.</string>
|
||||
<string name="voice_settings_final_answer_only">Nur endgültige Antwort</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Spricht nur die abgeschlossene Antwort. Werkzeugfortschritt, Dienstmeldungen und Zwischenkommentare bleiben visuell.</string>
|
||||
<string name="voice_settings_interaction_mode">Interaktionsmodus</string>
|
||||
<string name="voice_settings_interaction_tap">Tippen zum Sprechen</string>
|
||||
<string name="voice_settings_interaction_hold">Halten zum Sprechen</string>
|
||||
@@ -2090,8 +2093,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Sprachsteuerung einklappen</string>
|
||||
<string name="voice_overlay_expand_cd">Sprachsteuerung ausklappen</string>
|
||||
<string name="voice_overlay_exit_cd">Sprachmodus beenden</string>
|
||||
<string name="voice_overlay_compact">Kompakt</string>
|
||||
<string name="voice_overlay_focus">Fokus</string>
|
||||
<string name="voice_overlay_conversation">Gespräch</string>
|
||||
<string name="voice_overlay_image_ready">Bild bereit</string>
|
||||
<string name="voice_overlay_rich_result_ready">Rich-Ergebnis bereit</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d Ergebnisse bereit</string>
|
||||
<string name="voice_overlay_view_conversation">Gespräch anzeigen</string>
|
||||
<string name="voice_overlay_overlay">Overlay</string>
|
||||
<string name="voice_overlay_exit">Beenden</string>
|
||||
<string name="voice_overlay_settings_cd">Spracheinstellungen</string>
|
||||
@@ -2237,6 +2244,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Bei nächster Nachricht bestätigen</string>
|
||||
<string name="conn_info_connect">Verbinden</string>
|
||||
<string name="conn_info_connected">Verbunden</string>
|
||||
<string name="conn_info_active_configuration">Aktive Konfiguration</string>
|
||||
<string name="conn_info_inherited">Geerbt</string>
|
||||
<string name="conn_info_bypassed">Umgangen</string>
|
||||
<string name="conn_info_customize_identity">Identität anpassen</string>
|
||||
<string name="conn_info_agent_passport">Agentenpass</string>
|
||||
<string name="conn_info_chat_override">Chat-Überschreibung</string>
|
||||
<string name="conn_info_fast_tier">Schnellstufe</string>
|
||||
<string name="conn_info_fast">Schnell</string>
|
||||
<string name="conn_info_context">Kontext</string>
|
||||
<string name="conn_info_profile_already_bypasses">Genehmigungen werden bereits vom Profil umgangen.</string>
|
||||
<string name="conn_info_start_new_chat">Neuen Chat starten</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Wähle, wie Genehmigungen erzwungen werden.</string>
|
||||
<string name="conn_info_approval_policy">Genehmigungsrichtlinie</string>
|
||||
<string name="conn_info_connecting">Verbindung wird hergestellt…</string>
|
||||
<string name="conn_info_connection">Verbindung</string>
|
||||
<string name="conn_info_connection_state">Verbindungsstatus</string>
|
||||
|
||||
@@ -1076,7 +1076,7 @@
|
||||
<string name="dev_settings_relay_features">Características de Relay</string>
|
||||
<string name="dev_settings_relay_features_desc">Mostrar el servidor Relay y la configuración de emparejamiento para el desarrollo de Bridge/Terminal</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Abra el banco de pruebas del proveedor websocket para compilaciones de desarrollo.</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Revisa la configuración de voz en tiempo real y ejecuta una prueba de micrófono</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_lock_dev_options">Bloquear opciones de desarrollador</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta sección y desactivar las funciones experimentales</string>
|
||||
@@ -1107,6 +1107,7 @@
|
||||
<string name="dev_settings_export_failed">Exportación fallida</string>
|
||||
<string name="dev_settings_imported">Configuraciones importadas</string>
|
||||
<string name="dev_settings_import_failed">Error al importar: archivo no válido</string>
|
||||
<string name="dev_settings_reset_failed">Error al restablecer</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">¿Exportar copia de seguridad confidencial?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Esta copia de seguridad incluye conexiones guardadas, claves API, tokens de sesión relay, ID de dispositivos y cookies del panel. Cualquier persona que tenga el archivo podrá acceder a su servidor Hermes.</string>
|
||||
<string name="dev_settings_export_action">Exportar</string>
|
||||
@@ -1441,6 +1442,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Guardar agente en tiempo real</string>
|
||||
<string name="voice_settings_global_controls_title">Controles de voz globales</string>
|
||||
<string name="voice_settings_global_controls_desc">Estas configuraciones se aplican a ambos motores de voz, en todos los perfiles.</string>
|
||||
<string name="voice_settings_final_answer_only">Solo la respuesta final</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Reproduce únicamente la respuesta definitiva. El progreso de herramientas, las actualizaciones de servicio y los comentarios intermedios permanecen visuales.</string>
|
||||
<string name="voice_settings_interaction_mode">Modo de interacción</string>
|
||||
<string name="voice_settings_interaction_tap">Toca para hablar</string>
|
||||
<string name="voice_settings_interaction_hold">Espera para hablar</string>
|
||||
@@ -1902,8 +1905,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Contraer controles de voz</string>
|
||||
<string name="voice_overlay_expand_cd">Ampliar los controles de voz</string>
|
||||
<string name="voice_overlay_exit_cd">Salir del modo de voz</string>
|
||||
<string name="voice_overlay_compact">Compacto</string>
|
||||
<string name="voice_overlay_focus">Enfocar</string>
|
||||
<string name="voice_overlay_conversation">Conversación</string>
|
||||
<string name="voice_overlay_image_ready">Imagen lista</string>
|
||||
<string name="voice_overlay_rich_result_ready">Resultado enriquecido listo</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d resultados listos</string>
|
||||
<string name="voice_overlay_view_conversation">Ver conversación</string>
|
||||
<string name="voice_overlay_overlay">Cubrir</string>
|
||||
<string name="voice_overlay_exit">Salida</string>
|
||||
<string name="voice_overlay_settings_cd">Configuraciones de voz</string>
|
||||
@@ -2043,6 +2050,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirma en el siguiente mensaje</string>
|
||||
<string name="conn_info_connect">Conectar</string>
|
||||
<string name="conn_info_connected">Conectado</string>
|
||||
<string name="conn_info_active_configuration">Configuración activa</string>
|
||||
<string name="conn_info_inherited">Heredado</string>
|
||||
<string name="conn_info_bypassed">Omitido</string>
|
||||
<string name="conn_info_customize_identity">Personalizar identidad</string>
|
||||
<string name="conn_info_agent_passport">Pasaporte del agente</string>
|
||||
<string name="conn_info_chat_override">Anulación del chat</string>
|
||||
<string name="conn_info_fast_tier">Nivel rápido</string>
|
||||
<string name="conn_info_fast">Rápido</string>
|
||||
<string name="conn_info_context">Contexto</string>
|
||||
<string name="conn_info_profile_already_bypasses">El perfil ya omite las aprobaciones.</string>
|
||||
<string name="conn_info_start_new_chat">Iniciar nuevo chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Elige cómo se aplican las aprobaciones.</string>
|
||||
<string name="conn_info_approval_policy">Política de aprobación</string>
|
||||
<string name="conn_info_connecting">Conectando…</string>
|
||||
<string name="conn_info_connection">Conexión</string>
|
||||
<string name="conn_info_connection_state">Estado de conexión</string>
|
||||
|
||||
@@ -1201,7 +1201,7 @@
|
||||
<string name="dev_settings_relay_features">Relay の特徴</string>
|
||||
<string name="dev_settings_relay_features_desc">Relay サーバーと Bridge/ターミナル開発用のペアリング設定を表示</string>
|
||||
<string name="dev_settings_realtime_voice_lab">リアルタイム音声ラボ</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">開発ビルド用のプロバイダー WebSocket テストベンチを開きます</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">リアルタイム音声の設定を確認し、マイクの集中テストを実行します</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">リアルタイム音声ラボを開く</string>
|
||||
<string name="dev_settings_lock_dev_options">開発者向けオプションをロックする</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">このセクションを非表示にし、実験的な機能を無効にします</string>
|
||||
@@ -1232,6 +1232,7 @@
|
||||
<string name="dev_settings_export_failed">エクスポートに失敗しました</string>
|
||||
<string name="dev_settings_imported">インポートされた設定</string>
|
||||
<string name="dev_settings_import_failed">インポートに失敗しました - 無効なファイル</string>
|
||||
<string name="dev_settings_reset_failed">リセットに失敗しました</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">機密性の高いバックアップをエクスポートしますか?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">このバックアップには、保存された接続、API キー、Relay セッション トークン、デバイス ID、およびダッシュボード Cookie が含まれます。ファイルを持っている人は誰でも、Hermes サーバーにアクセスできる可能性があります。</string>
|
||||
<string name="dev_settings_export_action">輸出</string>
|
||||
@@ -1578,6 +1579,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">リアルタイムエージェントを保存する</string>
|
||||
<string name="voice_settings_global_controls_title">グローバル音声コントロール</string>
|
||||
<string name="voice_settings_global_controls_desc">これらの設定は、すべてのプロファイルの両方の音声エンジンに適用されます。</string>
|
||||
<string name="voice_settings_final_answer_only">最終回答のみ</string>
|
||||
<string name="voice_settings_final_answer_only_desc">確定した回答だけを読み上げます。ツールの進行状況、サービス更新、中間コメントは画面表示のみになります。</string>
|
||||
<string name="voice_settings_interaction_mode">インタラクションモード</string>
|
||||
<string name="voice_settings_interaction_tap">タップして話す</string>
|
||||
<string name="voice_settings_interaction_hold">押し続けて話す</string>
|
||||
@@ -2103,8 +2106,12 @@
|
||||
<string name="voice_overlay_collapse_cd">音声コントロールを折りたたむ</string>
|
||||
<string name="voice_overlay_expand_cd">音声コントロールを拡張する</string>
|
||||
<string name="voice_overlay_exit_cd">音声モードを終了する</string>
|
||||
<string name="voice_overlay_compact">コンパクト</string>
|
||||
<string name="voice_overlay_focus">集中</string>
|
||||
<string name="voice_overlay_conversation">会話</string>
|
||||
<string name="voice_overlay_image_ready">画像の準備ができました</string>
|
||||
<string name="voice_overlay_rich_result_ready">リッチな結果の準備ができました</string>
|
||||
<string name="voice_overlay_rich_results_count">結果が%1$d件準備できました</string>
|
||||
<string name="voice_overlay_view_conversation">会話を表示</string>
|
||||
<string name="voice_overlay_overlay">かぶせる</string>
|
||||
<string name="voice_overlay_exit">出口</string>
|
||||
<string name="voice_overlay_settings_cd">音声設定</string>
|
||||
@@ -2248,6 +2255,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">次のメッセージで確認します</string>
|
||||
<string name="conn_info_connect">接続する</string>
|
||||
<string name="conn_info_connected">接続済み</string>
|
||||
<string name="conn_info_active_configuration">有効な構成</string>
|
||||
<string name="conn_info_inherited">継承</string>
|
||||
<string name="conn_info_bypassed">バイパス</string>
|
||||
<string name="conn_info_customize_identity">アイデンティティをカスタマイズ</string>
|
||||
<string name="conn_info_agent_passport">エージェントパスポート</string>
|
||||
<string name="conn_info_chat_override">チャットの上書き</string>
|
||||
<string name="conn_info_fast_tier">高速ティア</string>
|
||||
<string name="conn_info_fast">高速</string>
|
||||
<string name="conn_info_context">コンテキスト</string>
|
||||
<string name="conn_info_profile_already_bypasses">承認はプロファイルですでにバイパスされています。</string>
|
||||
<string name="conn_info_start_new_chat">新しいチャットを開始</string>
|
||||
<string name="conn_info_approval_mode_short_desc">承認を適用する方法を選択します。</string>
|
||||
<string name="conn_info_approval_policy">承認ポリシー</string>
|
||||
<string name="conn_info_connecting">接続中…</string>
|
||||
<string name="conn_info_connection">繋がり</string>
|
||||
<string name="conn_info_connection_state">接続状態</string>
|
||||
|
||||
@@ -1290,7 +1290,7 @@
|
||||
<string name="dev_settings_relay_features">Relay features</string>
|
||||
<string name="dev_settings_relay_features_desc">Show Relay Server and Pairing settings for Bridge/Terminal development</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Realtime voice lab</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Open the provider websocket testbench for dev builds</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Inspect realtime voice setup and run a focused microphone test</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Open realtime voice lab</string>
|
||||
<string name="dev_settings_image_generation_lab">Image generation lab</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Preview generation loops and the final image reveal</string>
|
||||
@@ -1324,6 +1324,7 @@
|
||||
<string name="dev_settings_export_failed">Export failed</string>
|
||||
<string name="dev_settings_imported">Settings imported</string>
|
||||
<string name="dev_settings_import_failed">Import failed — invalid file</string>
|
||||
<string name="dev_settings_reset_failed">Reset failed</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Export sensitive backup?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server.</string>
|
||||
<string name="dev_settings_export_action">Export</string>
|
||||
@@ -1681,6 +1682,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Save realtime agent</string>
|
||||
<string name="voice_settings_global_controls_title">Global Voice Controls</string>
|
||||
<string name="voice_settings_global_controls_desc">These settings apply to both voice engines, on every profile.</string>
|
||||
<string name="voice_settings_final_answer_only">Final answer only</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Speak only the settled answer. Tool progress, service updates, and intermediate commentary stay visual.</string>
|
||||
<string name="voice_settings_interaction_mode">Interaction mode</string>
|
||||
<string name="voice_settings_interaction_tap">Tap to talk</string>
|
||||
<string name="voice_settings_interaction_hold">Hold to talk</string>
|
||||
@@ -2210,8 +2213,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Collapse voice controls</string>
|
||||
<string name="voice_overlay_expand_cd">Expand voice controls</string>
|
||||
<string name="voice_overlay_exit_cd">Exit voice mode</string>
|
||||
<string name="voice_overlay_compact">Compact</string>
|
||||
<string name="voice_overlay_focus">Focus</string>
|
||||
<string name="voice_overlay_conversation">Conversation</string>
|
||||
<string name="voice_overlay_focus">Voice focus</string>
|
||||
<string name="voice_overlay_image_ready">Image ready</string>
|
||||
<string name="voice_overlay_rich_result_ready">Rich result ready</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d results ready</string>
|
||||
<string name="voice_overlay_view_conversation">View conversation</string>
|
||||
<string name="voice_overlay_overlay">Overlay</string>
|
||||
<string name="voice_overlay_exit">Exit</string>
|
||||
<string name="voice_overlay_settings_cd">Voice settings</string>
|
||||
@@ -2357,6 +2364,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirms on next message</string>
|
||||
<string name="conn_info_connect">Connect</string>
|
||||
<string name="conn_info_connected">Connected</string>
|
||||
<string name="conn_info_active_configuration">Active configuration</string>
|
||||
<string name="conn_info_inherited">Inherited</string>
|
||||
<string name="conn_info_bypassed">Bypassed</string>
|
||||
<string name="conn_info_customize_identity">Customize identity</string>
|
||||
<string name="conn_info_agent_passport">Agent Passport</string>
|
||||
<string name="conn_info_chat_override">Chat override</string>
|
||||
<string name="conn_info_fast_tier">Fast tier</string>
|
||||
<string name="conn_info_fast">Fast</string>
|
||||
<string name="conn_info_context">Context</string>
|
||||
<string name="conn_info_profile_already_bypasses">Approvals already bypassed by profile.</string>
|
||||
<string name="conn_info_start_new_chat">Start new chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Choose how approvals are enforced.</string>
|
||||
<string name="conn_info_approval_policy">Approval policy</string>
|
||||
<string name="conn_info_connecting">Connecting…</string>
|
||||
<string name="conn_info_connection">Connection</string>
|
||||
<string name="conn_info_connection_state">Connection state</string>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.coVerify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class DataManagerIoTest {
|
||||
@Test
|
||||
fun writeBackupFailsWhenProviderReturnsNoOutputStream() = runTest {
|
||||
val uri = mockk<Uri>()
|
||||
val resolver = mockk<ContentResolver>()
|
||||
val context = mockk<Context>()
|
||||
every { context.contentResolver } returns resolver
|
||||
every { resolver.openOutputStream(uri) } returns null
|
||||
|
||||
val success = DataManager(context).writeBackupToUri(uri, "{}")
|
||||
|
||||
assertFalse(success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreTreatsEmptyConnectionsAsReplacementSnapshot() = runTest {
|
||||
val context = mockk<Context>()
|
||||
val store = mockk<ConnectionStore>()
|
||||
every { context.filesDir } returns File("build/tmp/data-manager-test/files")
|
||||
coJustRun {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
|
||||
DataManager(context, store).restoreConnectionBackup(DataManager.AppBackup())
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FeatureFlagsTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runTest {
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingPreferenceUsesBuildDefault() = runTest {
|
||||
assertEquals(FeatureFlags.isDevBuild, FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitLockOverridesDebugBuildDefault() = runTest {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
|
||||
assertFalse(FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlockPersistsAfterExplicitLock() = runTest {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
|
||||
assertTrue(FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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
|
||||
@@ -65,4 +67,44 @@ class VoicePreferencesRepositoryTest {
|
||||
assertEquals("grok-voice-think-fast-1.0", settings.realtimeModel)
|
||||
assertEquals("leo", settings.realtimeVoice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finalAnswerOnlyPersistsGloballyAcrossProfileScopes() = runTest {
|
||||
assertFalse(repository.settings.first().finalAnswerOnly)
|
||||
|
||||
repository.setFinalAnswerOnly(true)
|
||||
assertTrue(repository.settings.first().finalAnswerOnly)
|
||||
|
||||
repository.setActiveScope("connection-a", "coder")
|
||||
assertTrue(repository.settings.first().finalAnswerOnly)
|
||||
|
||||
repository.setActiveScope("connection-b", "writer")
|
||||
assertTrue(repository.settings.first().finalAnswerOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun presentationModePersistsGloballyAcrossProfileScopes() = runTest {
|
||||
assertEquals(
|
||||
VoicePresentationMode.Focus.storageValue,
|
||||
repository.settings.first().presentationMode,
|
||||
)
|
||||
|
||||
repository.setPresentationMode(VoicePresentationMode.Conversation)
|
||||
assertEquals(
|
||||
VoicePresentationMode.Conversation.storageValue,
|
||||
repository.settings.first().presentationMode,
|
||||
)
|
||||
|
||||
repository.setActiveScope("connection-a", "coder")
|
||||
assertEquals(
|
||||
VoicePresentationMode.Conversation.storageValue,
|
||||
repository.settings.first().presentationMode,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownPresentationModeFallsBackToFocus() {
|
||||
assertEquals(VoicePresentationMode.Focus, VoicePresentationMode.fromStorage("unknown"))
|
||||
assertEquals(VoicePresentationMode.Focus, VoicePresentationMode.fromStorage(null))
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -147,6 +147,28 @@ class RelayVoiceClientRoutingTest {
|
||||
assertEquals("leo", payload["voice"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realtimeAgentSessionSendsFinalAnswerOnlyPolicy() = runTest {
|
||||
val client = RelayVoiceClient(
|
||||
context = context,
|
||||
okHttpClient = httpClient,
|
||||
relayUrlProvider = { relayUrl(lanServer) },
|
||||
sessionTokenProvider = { "session-token" },
|
||||
)
|
||||
|
||||
val result = client.runRealtimeAgent(
|
||||
prompt = "Check Hermes quietly",
|
||||
inputPcm = ByteArray(0),
|
||||
finalAnswerOnly = true,
|
||||
) { _, _ -> }
|
||||
|
||||
assertTrue(result.exceptionOrNull()?.message, result.isSuccess)
|
||||
val request = lanServer.takeRequest(2, TimeUnit.SECONDS)
|
||||
?: error("missing realtime session request")
|
||||
val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
|
||||
assertEquals("true", payload["final_answer_only"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceOutputSessionResponseParsesResumeMetadata() {
|
||||
val response = Json.decodeFromString(
|
||||
@@ -2102,6 +2124,81 @@ class RelayVoiceClientRoutingTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistentRealtimePromotionUsesSpokenHandoffAsForegroundBoundary() = runBlocking {
|
||||
val opened = CountDownLatch(1)
|
||||
val turns = Channel<RealtimeTurnInput>(Channel.UNLIMITED)
|
||||
val turnCompletions = Channel<Unit>(Channel.UNLIMITED)
|
||||
val followUpDelivered = CompletableDeferred<Result<Unit>>()
|
||||
lateinit var socket: ScriptedWebSocket
|
||||
lateinit var listener: WebSocketListener
|
||||
lanServer.dispatcher = sessionOnlyDispatcher(
|
||||
path = "/voice/realtime-agent/session",
|
||||
body = """
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "realtime-agent-promotion-boundary-test",
|
||||
"websocket_path": "/voice/realtime-agent/session-test",
|
||||
"provider": "xai_realtime",
|
||||
"model": "grok-voice-latest",
|
||||
"voice": "leo",
|
||||
"sample_rate": 24000
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val client = RelayVoiceClient(
|
||||
context = context,
|
||||
okHttpClient = httpClient,
|
||||
relayUrlProvider = { relayUrl(lanServer) },
|
||||
sessionTokenProvider = { "session-token" },
|
||||
webSocketFactory = { request, callback ->
|
||||
listener = callback
|
||||
socket = ScriptedWebSocket(request, callback) { true }
|
||||
callback.onOpen(socket, mockk(relaxed = true))
|
||||
opened.countDown()
|
||||
socket
|
||||
},
|
||||
)
|
||||
val sessionJob = async(Dispatchers.IO) {
|
||||
client.runRealtimeAgent(
|
||||
prompt = "Start a long task",
|
||||
inputPcm = ByteArray(0),
|
||||
turnInputs = turns,
|
||||
onTurnComplete = { turnCompletions.trySend(Unit) },
|
||||
) { _, _ -> }
|
||||
}
|
||||
|
||||
try {
|
||||
assertTrue(opened.await(2, TimeUnit.SECONDS))
|
||||
listener.onMessage(
|
||||
socket,
|
||||
"""{"type":"hermes.run.promoted","run_id":"run-silent","spoken_handoff":false}""",
|
||||
)
|
||||
withTimeout(2_000) { turnCompletions.receive() }
|
||||
|
||||
turns.send(
|
||||
RealtimeTurnInput(
|
||||
inputPcm = ByteArray(6_400) { 4 },
|
||||
deliveryResult = followUpDelivered,
|
||||
)
|
||||
)
|
||||
assertTrue(withTimeout(2_000) { followUpDelivered.await() }.isSuccess)
|
||||
|
||||
listener.onMessage(
|
||||
socket,
|
||||
"""{"type":"hermes.run.promoted","run_id":"run-spoken","spoken_handoff":true}""",
|
||||
)
|
||||
delay(100)
|
||||
assertTrue(turnCompletions.tryReceive().isFailure)
|
||||
|
||||
listener.onMessage(socket, """{"type":"voice.response.done"}""")
|
||||
withTimeout(2_000) { turnCompletions.receive() }
|
||||
} finally {
|
||||
turns.close()
|
||||
withTimeout(2_000) { sessionJob.await() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun relayUrl(server: MockWebServer): String =
|
||||
"ws://${server.hostName}:${server.port}"
|
||||
|
||||
|
||||
@@ -295,6 +295,10 @@ class GatewayClientHarness(
|
||||
put("key", "fast")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
|
||||
}
|
||||
"yolo" -> buildJsonObject {
|
||||
put("key", "yolo")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "0")
|
||||
}
|
||||
"approvals.mode" -> {
|
||||
approvalMode =
|
||||
(params["value"] as? JsonPrimitive)?.contentOrNull ?: approvalMode
|
||||
@@ -1892,6 +1896,25 @@ class GatewayChatClientTest {
|
||||
assertFalse(rpc.containsKey("scope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yolo update targets live session with ephemeral session scope`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.setYolo(true) }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(result.getOrThrow())
|
||||
val rpc = harness.awaitRpc("config.set")
|
||||
assertEquals("yolo", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("1", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("session", (rpc["scope"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
// --- Edit & regenerate ---
|
||||
|
||||
@Test
|
||||
|
||||
+5
-7
@@ -12,7 +12,7 @@ import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import com.hermesandroid.relay.viewmodel.backgroundRunAfterCancelRequest
|
||||
import com.hermesandroid.relay.viewmodel.preserveRealtimeTurnOnStop
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterResponseDone
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterPromotion
|
||||
import com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -27,12 +27,10 @@ class VoiceModeOverlayStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun responseDone_keepsLogicalTurnActiveOnlyWhileBackgroundRunIsLive() {
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RUNNING))
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RECONNECTING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DELIVERING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DONE))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(null))
|
||||
fun promotion_keepsForegroundBusyOnlyForSpokenHandoff() {
|
||||
assertEquals(false, realtimeTurnActiveAfterPromotion(spokenHandoff = false))
|
||||
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = true))
|
||||
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatScrollSnapshotTest {
|
||||
@Test
|
||||
fun `completion releases only the retained live tail`() {
|
||||
assertNull(releaseRetainedLiveTail("assistant-live", "assistant-live"))
|
||||
assertEquals(
|
||||
"assistant-live",
|
||||
releaseRetainedLiveTail("assistant-live", "different-message"),
|
||||
)
|
||||
assertNull(releaseRetainedLiveTail(null, "assistant-live"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tall markdown tail is positioned by its trailing edge`() {
|
||||
assertEquals(
|
||||
1_208,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 2_400,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 600,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
|
||||
@@ -33,6 +33,19 @@ class AssistantSpeechCursorTest {
|
||||
assertEquals(listOf("The check is complete."), second.deltas.map { it.text })
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("I'll check that.\n\nThe check is complete.", second.aggregateText)
|
||||
assertEquals("The check is complete.", second.finalAnswerText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final answer skips blank tool bubbles and intermediate commentary`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
val interim = message("interim", MessageRole.ASSISTANT, "I'll check that.")
|
||||
val toolOnly = message("tool", MessageRole.ASSISTANT, " ")
|
||||
val final = message("final", MessageRole.ASSISTANT, " The settled answer. ")
|
||||
|
||||
val batch = cursor.poll(listOf(interim, toolOnly, final))
|
||||
|
||||
assertEquals("The settled answer.", batch.finalAnswerText)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+68
@@ -240,6 +240,74 @@ class ChatViewModelRealtimeTurnTest {
|
||||
assertEquals(2, handler.messages.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentBackgroundPromotionReleasesForegroundStreamButKeepsTaskOwnership() {
|
||||
val assistantId = viewModel.startRealtimeAgentTurn(
|
||||
userText = "Check release readiness",
|
||||
chatSessionId = "session-1",
|
||||
)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = assistantId,
|
||||
event = RealtimeVoiceEvent(
|
||||
type = "hermes.run.promoted",
|
||||
runId = "run-silent",
|
||||
tier = "durable",
|
||||
spokenHandoff = false,
|
||||
raw = "{}",
|
||||
),
|
||||
)
|
||||
|
||||
val promoted = handler.messages.value.single { it.id == assistantId }
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertFalse(promoted.isStreaming)
|
||||
assertEquals(BackgroundTaskPhase.RUNNING, promoted.backgroundTask?.phase)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = "newer-turn",
|
||||
event = RealtimeVoiceEvent(
|
||||
type = "hermes.run.progress",
|
||||
runId = "run-silent",
|
||||
message = "Checking Android",
|
||||
raw = "{}",
|
||||
),
|
||||
)
|
||||
|
||||
val updated = handler.messages.value.single { it.id == assistantId }
|
||||
assertEquals("Checking Android", updated.backgroundTask?.statusLine)
|
||||
assertEquals(BackgroundTaskPhase.RUNNING, updated.backgroundTask?.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spokenBackgroundPromotionWaitsForProviderResponseBoundary() {
|
||||
val assistantId = viewModel.startRealtimeAgentTurn(
|
||||
userText = "Check release readiness",
|
||||
chatSessionId = "session-1",
|
||||
)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = assistantId,
|
||||
event = RealtimeVoiceEvent(
|
||||
type = "hermes.run.promoted",
|
||||
runId = "run-spoken",
|
||||
spokenHandoff = true,
|
||||
raw = "{}",
|
||||
),
|
||||
)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = assistantId,
|
||||
event = RealtimeVoiceEvent(type = "voice.response.done", raw = "{}"),
|
||||
)
|
||||
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertEquals(
|
||||
BackgroundTaskPhase.RUNNING,
|
||||
handler.messages.value.single { it.id == assistantId }.backgroundTask?.phase,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backgroundRunKeepsItsOwnerAfterANewerLocalVoiceCommand() {
|
||||
val backgroundAssistantId = viewModel.startRealtimeAgentTurn(
|
||||
|
||||
@@ -42,6 +42,15 @@ class VoiceTurnSessionFenceTest {
|
||||
assertFalse(fence.accepts(sessionId = "other", messages = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient mismatch does not lose existing session binding`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = "active")
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
|
||||
assertFalse(fence.accepts(sessionId = "other", messages = emptyList()))
|
||||
assertTrue(fence.accepts(sessionId = "active", messages = emptyList()))
|
||||
}
|
||||
|
||||
private fun user(uiKey: String) = ChatMessage(
|
||||
id = "id-$uiKey",
|
||||
role = MessageRole.USER,
|
||||
|
||||
+17
-14
@@ -1,27 +1,30 @@
|
||||
# Voice Settings design QA
|
||||
# Agent Passport design QA
|
||||
|
||||
Status: **passed**
|
||||
|
||||
## Compared
|
||||
|
||||
- Source: `voice-audit/mockup-04-recommended-hybrid.png`
|
||||
- Implementation: `voice-audit/implementation-03-provider-preview-refined.png`
|
||||
- Viewport: Android `Medium_Phone`, 1080 x 2400 px (about 390 dp wide), font scale 1.0
|
||||
- State: Output tab, xAI Grok TTS, Eve selected and actively previewing
|
||||
- Source: `C:\Users\Bailey\.codex\generated_images\019f6640-e6dc-7c33-8aca-a1610e2a19f0\call_JzVlJKlG8KWTJRo2xup7oTlq.png`
|
||||
- Implementation: `C:\Users\Bailey\.codex\visualizations\2026\07\25\agent-drawer-audit\passport-qa-final.png`
|
||||
- Source size: 852 x 1846 px
|
||||
- Device viewport: Android, 1080 x 2340 px, font scale 1.0
|
||||
- State: Agent tab, Victor pinned profile, disconnected gateway
|
||||
|
||||
## Resolved findings
|
||||
|
||||
- **P2 - Group boundaries were too weak.** Provider, model/voice, and language/quality surfaces now use the product's rounded Material cards with outline-token borders and a subtle surface-variant fill.
|
||||
- **P2 - Preview state was too tall.** The existing voice waveform now accepts a caller-provided height; inline preview uses a compact 28 dp waveform while the full voice overlay keeps its 56 dp default.
|
||||
- **P2 - Long catalogs displaced the core controls.** The selected/recommended voices remain inline, while the full catalog opens from a compact `View all voices` action.
|
||||
- **P2 - Secondary controls competed with selection.** Latency, fallback, expressive tags, and manual IDs now stay inside the collapsed Language & quality section.
|
||||
- **P3 - Active tab lacked the target emphasis.** The selected segment now uses the theme primary/on-primary colors.
|
||||
- **P2 - Header hierarchy and identity treatment differed from the selected concept.** The sheet now uses the Agent Passport title, large profile card, circular avatar, pin-style profile chip, status line, and four-column metrics strip.
|
||||
- **P2 - Configuration controls lacked the concept's card hierarchy.** Personality, model, and reasoning are grouped into one outlined Active configuration card with matching icon medallions and row affordances.
|
||||
- **P2 - Safety controls did not match the selected inline treatment.** Approval status, chat override, and fast tier now share one outlined Safety & speed card with compact segmented controls.
|
||||
- **P2 - Primary action looked detached and visually unfinished.** Start new chat now uses the selected full-width purple-to-relay gradient, white outlined chat icon, and a single clean label.
|
||||
- **P2 - Nested sheet and content gestures caused visible vertical bounce.** Sheet drag gestures are disabled for this scrollable detail surface and overscroll is suppressed; repeated device swipes keep the sheet anchored while the content scrolls.
|
||||
|
||||
## Final review
|
||||
|
||||
- Layout and hierarchy match the selected hybrid: summary, Output/Listening/Advanced tabs, grouped provider, grouped model/voice, inline play/stop, active waveform, and collapsed language/quality.
|
||||
- Interactions are wired: tabs switch sections, provider/model/voice selections update the draft, only one preview can play, the active play button becomes Stop, and full catalogs remain accessible.
|
||||
- Accessibility: native Material controls retain semantics and practical tap targets; the active preview has explicit play/stop descriptions; the screen remains vertically scrollable for large text and long provider catalogs.
|
||||
- Accepted native adaptation: Android exposed dropdowns and dynamic account-backed catalogs use more vertical space than the static concept. The hierarchy and core actions remain intact without clipping or overlap.
|
||||
- Layout and hierarchy match the selected Agent Passport direction: profile identity, metrics, Agent/Session tabs, active configuration, safety and speed, primary chat action, and identity customization.
|
||||
- Interactions remain wired: profile selection, configuration rows, Agent/Session tabs, approval and fast controls, new chat, and identity customization.
|
||||
- Runtime values remain live rather than copied from the static concept; connection state, message count, route label, provider, and model can therefore differ from the reference.
|
||||
- Native Android adaptation preserves system insets and scrollability on the taller 1080 x 2340 device viewport.
|
||||
|
||||
No open P0, P1, or P2 findings.
|
||||
|
||||
final result: passed
|
||||
|
||||
+4
-1
@@ -1716,7 +1716,10 @@ override:
|
||||
**Protocol additions (relay <-> Android, additive).**
|
||||
|
||||
- `hermes.run.promoted` - run moved to background; carries `run_id`,
|
||||
`promote_after_ms`, `spoken_handoff`.
|
||||
`promote_after_ms`, `spoken_handoff`. A false `spoken_handoff` is also the
|
||||
foreground turn boundary; when true, the following `voice.response.done` is
|
||||
the boundary. In both cases the background socket, task card, cancellation,
|
||||
progress, and result delivery remain active.
|
||||
- `hermes.run.background_completed` - background run finished; precedes the
|
||||
provider/relay summary.
|
||||
- Extend `hermes.run.progress` with `tier` and `floor` so the client can render
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"main": "91384aba5431a441e4ab2282d0efbdccd42f94b1a80d088971bcbe4ab12b23eb",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"main": "91384aba5431a441e4ab2282d0efbdccd42f94b1a80d088971bcbe4ab12b23eb",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"main": "91384aba5431a441e4ab2282d0efbdccd42f94b1a80d088971bcbe4ab12b23eb",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"main": "91384aba5431a441e4ab2282d0efbdccd42f94b1a80d088971bcbe4ab12b23eb",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"main": "91384aba5431a441e4ab2282d0efbdccd42f94b1a80d088971bcbe4ab12b23eb",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -14,9 +14,9 @@ It's not a hosted AI service. It's a companion app for the Hermes agent you run,
|
||||
|
||||
QUICK START
|
||||
|
||||
1. Run hermes-agent with its API server enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and enter your server's address (for example [http://192.168.1.100:8642](http://192.168.1.100:8642)).
|
||||
3. The setup wizard checks what your server supports and shows a readiness card — then you're talking.
|
||||
1. Run hermes-agent with its Dashboard/Gateway enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and select the nearby Dashboard, or enter its address and custom port.
|
||||
3. Sign in through the Dashboard when prompted. The setup wizard verifies Chat, sessions, Manage, and voice before finishing.
|
||||
|
||||
No server yet? Tap "Try the demo" on the setup screen to explore the app offline — a sample conversation, no login or server required.
|
||||
|
||||
@@ -24,7 +24,7 @@ A plain Hermes install is enough. Chat, management, and voice all work with no p
|
||||
|
||||
HOW IT WORKS
|
||||
|
||||
Chat streams directly from your Hermes API Server in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and more voice engines.
|
||||
Chat, sessions, Manage, and voice use the Hermes Dashboard/Gateway with one sign-in. A headless API server remains an automatic compatibility fallback. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and more voice engines.
|
||||
|
||||
GOOGLE PLAY BUILD
|
||||
|
||||
@@ -85,11 +85,12 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.4.9 - Clearer Hermes connections
|
||||
v1.5.1 - Voice and chat stay in place
|
||||
|
||||
* Connect through the Hermes dashboard with one sign-in.
|
||||
* Setup now explains nearby, remote, Tailscale, custom-port, and optional Relay paths.
|
||||
* Server default consistently displays Hermes' pinned active profile.
|
||||
* Voice Focus and full Conversation layouts.
|
||||
* Reliable Standard Voice narration after generation.
|
||||
* Realtime background work without blocked voice controls.
|
||||
* Formatted streamed answers stay at their completed end.
|
||||
```
|
||||
|
||||
## Category
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# Android release operations
|
||||
|
||||
Setup, fallback, and troubleshooting reference for the canonical Android
|
||||
release process in [RELEASE.md](../../RELEASE.md).
|
||||
|
||||
The normal stable path is:
|
||||
|
||||
```powershell
|
||||
pwsh scripts/release-android.ps1 -Version X.Y.Z
|
||||
```
|
||||
|
||||
The procedures below are initial setup or emergency recovery, not the normal
|
||||
release checklist.
|
||||
|
||||
## Release signing
|
||||
|
||||
Generate and protect a dedicated Android release keystore. Never commit the
|
||||
keystore, its Base64 representation, passwords, service-account JSON, or local
|
||||
secret files.
|
||||
|
||||
Store these GitHub Actions secrets:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `HERMES_KEYSTORE_BASE64` | Base64-encoded release keystore |
|
||||
| `HERMES_KEYSTORE_PASSWORD` | Keystore password |
|
||||
| `HERMES_KEY_ALIAS` | Release-key alias |
|
||||
| `HERMES_KEY_PASSWORD` | Release-key password |
|
||||
|
||||
PowerShell encoding example:
|
||||
|
||||
```powershell
|
||||
[Convert]::ToBase64String(
|
||||
[IO.File]::ReadAllBytes('release.keystore')
|
||||
) | Set-Content -Encoding ascii release.keystore.b64
|
||||
```
|
||||
|
||||
Delete the temporary Base64 file after storing the secret.
|
||||
|
||||
Google Play App Signing manages the store-distributed signing certificate.
|
||||
The repository keystore is the upload/release certificate and must remain
|
||||
stable across releases.
|
||||
|
||||
## Play Developer API
|
||||
|
||||
`PLAY_SERVICE_ACCOUNT_JSON` is required for stable automated releases.
|
||||
|
||||
One-time setup:
|
||||
|
||||
1. Enable the Google Play Android Developer API in a Google Cloud project.
|
||||
2. Create a dedicated service account and JSON key.
|
||||
3. Grant that account the minimum Play Console application permissions needed
|
||||
to view the app, create and edit Production releases, and use Play App
|
||||
Signing.
|
||||
4. Store the complete JSON document as the GitHub Actions secret
|
||||
`PLAY_SERVICE_ACCOUNT_JSON`.
|
||||
5. Confirm **Play Preflight — Android** can create a Production draft.
|
||||
|
||||
The service account is not optional for the stable workflow. Manual Console
|
||||
upload is an emergency fallback only.
|
||||
|
||||
## Managed Publishing
|
||||
|
||||
For unattended releases, keep Play Console Managed Publishing disabled.
|
||||
|
||||
- Disabled: `completed` submits the production change; availability follows
|
||||
Google review and propagation.
|
||||
- Enabled: an approved change remains under **Changes ready to publish** until
|
||||
a Console operator publishes it.
|
||||
|
||||
The Play Developer API reports acceptance of the edit; review reports and
|
||||
storefront propagation remain asynchronous.
|
||||
|
||||
## Track intent
|
||||
|
||||
- **Production**: stable `android-vX.Y.Z` releases.
|
||||
- **Open testing / beta**: intentional public beta.
|
||||
- **Closed testing / alpha**: intentional private beta.
|
||||
- **Internal**: throwaway test distribution.
|
||||
|
||||
Tracks are options, not a mandatory ladder. Stable releases go directly to
|
||||
Production unless the release plan explicitly says otherwise.
|
||||
|
||||
## Emergency manual Play recovery
|
||||
|
||||
Use this only when the automated workflow is unavailable and the release owner
|
||||
has explicitly approved a manual recovery.
|
||||
|
||||
To upload the prepared Google Play bundle as a Production draft:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat publishGooglePlayReleaseBundle `
|
||||
--track=production `
|
||||
--release-status=draft `
|
||||
--resolution-strategy=ignore `
|
||||
--release-name='Hermes-Relay X.Y.Z'
|
||||
```
|
||||
|
||||
To promote an existing Production draft:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat promoteGooglePlayReleaseArtifact `
|
||||
--update=production `
|
||||
--version-code=N `
|
||||
--release-status=completed `
|
||||
--release-name='Hermes-Relay X.Y.Z'
|
||||
```
|
||||
|
||||
Both commands require `play-service-account.json` in the configured local
|
||||
location. Remove the file after use.
|
||||
|
||||
If API automation is unavailable, the final fallback is Play Console:
|
||||
|
||||
1. Open **Release → Production**.
|
||||
2. Create or edit the release.
|
||||
3. Upload the `-googlePlay-release.aab`.
|
||||
4. Use the locale-specific files under
|
||||
`app/src/googlePlay/play/release-notes/` for What's New.
|
||||
5. Review and start the rollout.
|
||||
|
||||
Record the exact versionCode and resulting Play status in the release issue.
|
||||
|
||||
## Private preflight artifact
|
||||
|
||||
Stable preflight stores a private Actions artifact named:
|
||||
|
||||
```text
|
||||
play-preflight-X.Y.Z-<git-tree>
|
||||
```
|
||||
|
||||
Retention is 30 days. The release workflow rejects an expired artifact, an
|
||||
artifact from a failed run, a tree/version mismatch, unexpected files, or any
|
||||
size/SHA-256 mismatch.
|
||||
|
||||
If the artifact expires before approval, rerun preflight from the unchanged
|
||||
source tree. Never recreate the artifact locally and upload it under the proof
|
||||
name.
|
||||
|
||||
## Phone signing behavior
|
||||
|
||||
Android will not update an installed package when the signing certificate
|
||||
changes.
|
||||
|
||||
- Public sideload releases use the release key.
|
||||
- Rapid development builds use the development/debug key.
|
||||
- Both currently use `com.axiomlabs.hermesrelay.sideload`.
|
||||
|
||||
Therefore, a public release APK cannot update a development-signed installation
|
||||
without uninstalling it and erasing app data. The orchestrator's `-DeployPhone`
|
||||
option preserves configuration by installing a development-signed build from
|
||||
the exact release source tree. Use a release-signed installation when the goal
|
||||
is to test the downloadable public APK itself.
|
||||
|
||||
Never automatically uninstall the app as a signing workaround.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No successful preflight artifact
|
||||
|
||||
Confirm:
|
||||
|
||||
- preflight ran from the final `dev` or untagged `main` tree;
|
||||
- requested version matches `appVersionName`;
|
||||
- Play draft upload succeeded;
|
||||
- both preflight jobs concluded successfully;
|
||||
- the artifact has not expired;
|
||||
- the release PR merge tree is unchanged.
|
||||
|
||||
### Version code already used
|
||||
|
||||
Increase `appVersionCode` with `scripts/bump-android-version.sh`. Play version
|
||||
codes are permanent and cannot be reused even when a draft is discarded.
|
||||
|
||||
### Play rejects signing
|
||||
|
||||
Verify all keystore secrets exist and inspect the artifact certificate. Stable
|
||||
preflight intentionally fails when `HERMES_KEYSTORE_BASE64` is absent; it never
|
||||
falls back to a debug-signed Play upload.
|
||||
|
||||
### Approval succeeded but publication failed
|
||||
|
||||
Approval now waits for the release workflow and inherits its failure. Inspect
|
||||
the linked **Release Android** run. If workflow code—not application content—is
|
||||
the problem, repair the workflow on `main`, dispatch it for the existing
|
||||
version, and keep every job checked out at the immutable tag. Never move the
|
||||
tag.
|
||||
|
||||
### Release PR still runs the old full Android matrix
|
||||
|
||||
`pull_request` workflows use the definition on the PR base branch. The first
|
||||
release carrying the fast path may therefore run the older `main` workflow.
|
||||
After that release lands, canonical `dev` → `main` release PRs use the
|
||||
preflight-proof job.
|
||||
|
||||
### Phone install reports `INSTALL_FAILED_UPDATE_INCOMPATIBLE`
|
||||
|
||||
The installed app and APK have different signing certificates. Use the
|
||||
orchestrator's compatible `-DeployPhone` path, use a release-signed test
|
||||
installation, or explicitly export configuration before an approved clean
|
||||
install. Do not silently uninstall.
|
||||
+3
-3
@@ -419,7 +419,7 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
|
||||
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, and voice activity buffer. See `docs/decisions.md` §19.
|
||||
- **Connection (single-server settings)** — summary-first detail for one Hermes installation. Dashboard/Gateway health drives standard Chat, Manage, Sessions, and Voice readiness. API fallback and Relay extensions appear as independently optional capabilities. Advanced configuration exposes manual Dashboard, API, and Relay endpoints plus their native credentials; missing API or Relay settings never make a healthy Dashboard/Gateway connection look broken. Pairing-code and QR fallbacks remain available for Relay and compatibility setups. Transport security posture and paired-device grants remain visible without leading the normal setup flow with ports or bearer keys.
|
||||
- **Chat** — Show reasoning toggle, smooth auto-scroll toggle (live-follow streaming, default on), show token usage toggle, app context prompt toggle, tool call display (Off/Compact/Detailed), streaming endpoint selector (`auto` / `sessions` / `runs`), Stats for Nerds (analytics charts)
|
||||
- **Voice** — route-aware voice engine selector (`Vanilla Hermes` via dashboard audio, `Relay Voice Output`, and experimental `Realtime Agent`), global interaction mode (tap / hold / continuous), silence threshold slider, Auto-TTS toggle, selected-engine cards for dashboard or relay-backed settings, language picker, and a Test Current Engine card. Vanilla Hermes voice depends on Manage/dashboard auth; Relay-backed engines run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
|
||||
- **Voice** — route-aware voice engine selector (`Vanilla Hermes` via dashboard audio, `Relay Voice Output`, and experimental `Realtime Agent`), global interaction mode (tap / hold / continuous), silence threshold slider, a final-answer-only speech policy, Auto-TTS toggle, selected-engine cards for dashboard or relay-backed settings, language picker, and a Test Current Engine card. Final-answer-only keeps tool/service progress and intermediate commentary visual while both voice engines wait to speak the settled answer; approvals, confirmation questions, and blocking failures remain actionable. Vanilla Hermes voice depends on Manage/dashboard auth; Relay-backed engines run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
|
||||
- **Notification companion** — opt-in status, "Open Android Settings" action, test notification dump
|
||||
- **Permissions** — central permission/capability review screen linked from Settings and onboarding. It makes the Vanilla Hermes path explicit ("Chat and Manage" need no Android runtime grant), lists optional camera/microphone/notification access with current status and Android Settings links, and shows sideload-only Device Control requirements only in the sideload flavor.
|
||||
- **Appearance** — theme (auto/light/dark), dynamic colors toggle
|
||||
@@ -847,7 +847,7 @@ utilities.
|
||||
- `GET /voice/config` — provider availability + current settings from `tts:` / `stt:` in `~/.hermes/config.yaml`. When the basic TTS provider is Gemini or xAI, the response includes a `tts.enhanced` capability block (voices/models/audio-tag support + `supports_persona`/`supports_language` flags) so the app renders a per-request enhanced-voice picker. The Vanilla Hermes dashboard `/api/audio/speak` has no per-request surface — enhanced voice there stays config-only via Manage `PUT /api/config`.
|
||||
- `GET/PATCH /voice/output/config`, `POST /voice/output/session`, and `GET /voice/output/{session_id}` — relay-mediated streaming TTS renderer sessions. Android sends final Hermes text or brokered tool-status text and receives mono PCM deltas for direct `AudioTrack` playback. Session creation accepts optional provider/model/voice/sample-rate/language overrides for ephemeral draft previews; omitted values continue to resolve from the saved profile/relay defaults. Session responses include resumable-session metadata and PCM events carry `event_id`/`audio_event_id`, so short route changes during stable speech playback can resume and replay missed audio without re-rendering. Config responses include provider option metadata (`providers[].models`, `providers[].voices`, `providers[].languages`, `providers[].sample_rates`) for first-class dropdowns.
|
||||
- `GET/PATCH /voice/realtime/config`, `POST /voice/realtime/session`, and `GET /voice/realtime/{session_id}` — relay-mediated realtime provider-agent sessions for lab/dev experiments. Android can send PCM input events and receives mono PCM provider deltas for direct `AudioTrack` playback. Realtime config responses expose the same provider option shape where known.
|
||||
- `GET/PATCH /voice/realtime-agent/config`, `POST /voice/realtime-agent/session`, and `GET /voice/realtime-agent/{session_id}` — experimental Hermes-brokered Realtime Agent engine. The broker binds active profile/chat session/auth, streams Android mic PCM to a native realtime provider such as `xai_realtime` or `openai_realtime`, normalizes provider transcript/audio/function-call events, mirrors Hermes session/tool/confirmation events into Android, and returns compact Hermes tool results to the provider for concise spoken follow-up. Session responses include resumable-session metadata (`resume_token`, `resume_supported`, `resume_ttl_ms`); server events carry `event_id`, audio deltas carry `audio_event_id`, and Android can resume a detached session through the current `effectiveRelayUrl` after short Wi-Fi/cellular/LAN/Tailscale changes without starting a second Hermes run. A replacement route is usable only after relay `voice.session.resumed` confirmation; socket generation + resume-episode claims reject stale failure/close/fatal callbacks, unacknowledged input is replayed atomically, and each route-loss episode owns a bounded retry budget that starts at loss rather than session prewarm. Terminal exhaustion detaches session-owned reconnect UI so a stopped retry loop cannot leave an active task pill behind. The only provider-facing tool surface is `hermes_run_task`, `hermes_get_status`, `hermes_cancel`, and `hermes_confirm`.
|
||||
- `GET/PATCH /voice/realtime-agent/config`, `POST /voice/realtime-agent/session`, and `GET /voice/realtime-agent/{session_id}` — experimental Hermes-brokered Realtime Agent engine. The broker binds active profile/chat session/auth, streams Android mic PCM to a native realtime provider such as `xai_realtime` or `openai_realtime`, normalizes provider transcript/audio/function-call events, mirrors Hermes session/tool/confirmation events into Android, and returns compact Hermes tool results to the provider for concise spoken follow-up. Session creation accepts an ephemeral `final_answer_only` boolean; when enabled, the broker disables routine spoken handoffs and progress while preserving approval, confirmation, and blocking-failure prompts. Session responses include resumable-session metadata (`resume_token`, `resume_supported`, `resume_ttl_ms`); server events carry `event_id`, audio deltas carry `audio_event_id`, and Android can resume a detached session through the current `effectiveRelayUrl` after short Wi-Fi/cellular/LAN/Tailscale changes without starting a second Hermes run. A replacement route is usable only after relay `voice.session.resumed` confirmation; socket generation + resume-episode claims reject stale failure/close/fatal callbacks, unacknowledged input is replayed atomically, and each route-loss episode owns a bounded retry budget that starts at loss rather than session prewarm. Terminal exhaustion detaches session-owned reconnect UI so a stopped retry loop cannot leave an active task pill behind. The only provider-facing tool surface is `hermes_run_task`, `hermes_get_status`, `hermes_cancel`, and `hermes_confirm`.
|
||||
- `GET /voice/output/providers/{provider_id}/options`, `GET /voice/realtime/providers/{provider_id}/options`, and `GET /voice/realtime-agent/providers/{provider_id}/options` — provider-specific option refresh before saving. Android calls these when a provider is selected so dynamic account-backed choices can be fetched by the relay without exposing provider secrets. xAI refreshes built-in/paginated custom voices when API/OAuth auth is available; ElevenLabs refreshes voices/models/languages with its API key; OpenAI uses static documented voice choices. Realtime Agent provider payloads include `supports_realtime_agent_native` so render/lab-only realtime support is not confused with native speech-to-speech Hermes tooling. Responses include `schema_version`, grouped voice metadata, recommended/custom flags, and model/voice compatibility hints when known. Unknown or unauthenticated discovery falls back to static provider metadata plus manual entry.
|
||||
- `POST /voice/output/providers/{provider_id}/validate`, `POST /voice/realtime/providers/{provider_id}/validate`, and `POST /voice/realtime-agent/providers/{provider_id}/validate` — pre-save validation for provider/model/voice/sample-rate selections. Unknown manual IDs return warnings; explicit incompatibilities return blocking errors.
|
||||
- Voice-output provider defaults are relay-owned under `voice_output:` in `~/.hermes-relay/config.yaml` (or `RELAY_VOICE_OUTPUT_CONFIG`), then overridden by `RELAY_VOICE_OUTPUT_*` env vars for temporary tests. Authenticated operator clients may patch safe defaults (`enabled`, `provider`, `model`, `voice`, `sample_rate`, `language`, `codec`, `optimize_streaming_latency`, `text_normalization`, `auto_speech_tags`, `fallback_enabled`) through the relay. With `?profile=<name>`, the patch writes that profile's `voice_output:` section. Provider secrets and local auth paths stay server-side. `auto_speech_tags` is an xAI enhanced-voice control: when the renderer is `xai_tts` the relay applies `upstream_voice.apply_xai_speech_tags()` (upstream's inline/wrapping tone markers) to each chunk before rendering, so the streaming path matches the basic `/voice/synthesize` tone behavior. The `voice_lab` renderer set is xai/openai/elevenlabs — there is no Gemini streaming provider, so Gemini enhanced voice is `/voice/synthesize`-only.
|
||||
@@ -864,7 +864,7 @@ utilities.
|
||||
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
|
||||
- `VoiceModeOverlay` — full-screen UI with the MorphingSphere at 60% height in `voiceMode=true`, transcribed + response text, mic button supporting Tap / Hold / Continuous interaction modes.
|
||||
- `MorphingSphere` gains `SphereState.Listening` (soft blue/purple, subtle wobble with user amplitude) and `SphereState.Speaking` (vivid green/teal, dramatic core-warmth pulse with agent amplitude). Additive changes — existing call sites unchanged via defaulted `voiceAmplitude` / `voiceMode` params.
|
||||
- Voice Settings screen off the main Settings — Output / Listening / Advanced tabs split engine/provider selection from turn-taking controls and diagnostics. Output groups the provider summary and model/voice catalog, exposes inline no-save play/stop previews for the draft model and individual voices, shows the speaking waveform on the active row, and keeps Discard separate from Save. Dropdowns come from relay-advertised provider metadata, refresh through provider-specific options routes when the selected provider changes, become searchable/grouped for large voice catalogs, and validate compatibility before saving, with advanced manual entry for raw provider/model/voice IDs. Voice routes receive the selected Hermes profile; the relay reports whether values came from profile config, relay config, or global fallback. Test Current Engine remains under Advanced and uses `/voice/output/*` playback for stable mode and `/voice/realtime-agent/*` provider-native session playback for realtime mode; normal assistant speech uses the same streaming renderer PCM path when available.
|
||||
- Voice Settings screen off the main Settings — Output / Listening / Advanced tabs split engine/provider selection from turn-taking controls and diagnostics. Global controls include a final-answer-only policy shared by Standard and Realtime voice. Output groups the provider summary and model/voice catalog, exposes inline no-save play/stop previews for the draft model and individual voices, shows the speaking waveform on the active row, and keeps Discard separate from Save. Dropdowns come from relay-advertised provider metadata, refresh through provider-specific options routes when the selected provider changes, become searchable/grouped for large voice catalogs, and validate compatibility before saving, with advanced manual entry for raw provider/model/voice IDs. Voice routes receive the selected Hermes profile; the relay reports whether values came from profile config, relay config, or global fallback. Test Current Engine remains under Advanced and uses `/voice/output/*` playback for stable mode and `/voice/realtime-agent/*` provider-native session playback for realtime mode; normal assistant speech uses the same streaming renderer PCM path when available.
|
||||
|
||||
See `docs/decisions.md` → **Voice Mode — Architecture** for the historical baseline decisions. Current voice mode records PCM/WAV for STT, routes stable assistant speech through `/voice/output/*`, keeps `/voice/realtime/*` as a provider-agent lab path, and exposes `/voice/realtime-agent/*` as an experimental Hermes-brokered engine.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.5.0"
|
||||
appVersionCode = "33"
|
||||
appVersionName = "1.5.1"
|
||||
appVersionCode = "34"
|
||||
agp = "9.3.0"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.06.01"
|
||||
|
||||
@@ -259,6 +259,7 @@ class RealtimeAgentSession:
|
||||
# ADR 33 promotion state (populated from realtime_voice settings at create).
|
||||
promotion_enabled: bool = False
|
||||
promote_after_ms: int = 6000
|
||||
final_answer_only: bool = False
|
||||
spoken_handoff: bool = True
|
||||
result_delivery: str = "speak_verbatim"
|
||||
promoted_transcript: str | None = None
|
||||
@@ -402,6 +403,7 @@ class RealtimeAgentHandler:
|
||||
config=self.config,
|
||||
)
|
||||
context_messages = _parse_context_messages(payload.get("context_messages"))
|
||||
final_answer_only = _bool_value(payload.get("final_answer_only")) is True
|
||||
fetch_context_messages = getattr(self.hermes, "fetch_context_messages", None)
|
||||
if not context_messages and chat_session_id and callable(fetch_context_messages):
|
||||
context_messages = await self.hermes.fetch_context_messages(
|
||||
@@ -439,10 +441,19 @@ class RealtimeAgentHandler:
|
||||
),
|
||||
promotion_enabled=bool(settings.get("promotion_enabled", False)),
|
||||
promote_after_ms=int(settings.get("promote_after_ms", 6000)),
|
||||
spoken_handoff=bool(settings.get("spoken_handoff", True)),
|
||||
result_delivery=str(settings.get("result_delivery", "speak_verbatim")),
|
||||
final_answer_only=final_answer_only,
|
||||
spoken_handoff=(
|
||||
False if final_answer_only else bool(settings.get("spoken_handoff", True))
|
||||
),
|
||||
result_delivery=(
|
||||
"speak_verbatim"
|
||||
if final_answer_only
|
||||
else str(settings.get("result_delivery", "speak_verbatim"))
|
||||
),
|
||||
progress_spoken_after_seconds=(
|
||||
max(0, int(settings.get("progress_spoken_after_ms", 0))) / 1000.0
|
||||
0.0
|
||||
if final_answer_only
|
||||
else max(0, int(settings.get("progress_spoken_after_ms", 0))) / 1000.0
|
||||
),
|
||||
progress_repeat_seconds=(
|
||||
max(0, int(settings.get("progress_repeat_ms", 30000))) / 1000.0
|
||||
@@ -4805,6 +4816,19 @@ def _native_instructions(session: RealtimeAgentSession) -> str:
|
||||
current_timezone = interface_context["current_timezone"]
|
||||
context_block = _provider_context_block(session.context_messages)
|
||||
profile_block = _profile_prompt_block(session.profile_prompt_context)
|
||||
speech_policy = (
|
||||
"Final-answer-only speech is enabled. Do not speak acknowledgements, "
|
||||
"tool progress, service or status updates, or intermediate commentary. "
|
||||
"Call Hermes silently and wait to speak until its settled final answer is "
|
||||
"available. Approval or confirmation questions and blocking failures may "
|
||||
"still be spoken because the user must act on them. "
|
||||
if session.final_answer_only
|
||||
else (
|
||||
"You may speak one brief acknowledgement such as 'I'll check Hermes' "
|
||||
"or 'I'll check that' before the tool call, then call hermes_run_task "
|
||||
"immediately. The relay will provide restrained status while Hermes runs. "
|
||||
)
|
||||
)
|
||||
return (
|
||||
"You are the provider-native speech loop for Hermes Relay. Keep replies "
|
||||
"brief and conversational. Active interface: "
|
||||
@@ -4842,11 +4866,9 @@ def _native_instructions(session: RealtimeAgentSession) -> str:
|
||||
"information beyond what was already delivered. If the needed context is "
|
||||
"missing, stale, or requires fresh data or verification you do not "
|
||||
"already have, call hermes_run_task before answering. Do not say you lack "
|
||||
"context before a Hermes call. You "
|
||||
"may speak one brief acknowledgement such as 'I'll check Hermes' or "
|
||||
"'I'll check that' before the tool call, then call hermes_run_task "
|
||||
"immediately. Do not give a substantive answer until Hermes returns. "
|
||||
"The relay will provide restrained status while Hermes runs. "
|
||||
"context before a Hermes call. "
|
||||
f"{speech_policy}"
|
||||
"Do not give a substantive answer until Hermes returns. "
|
||||
"Route through Hermes for latest/recent/versioned data, device/desktop/app "
|
||||
"state, personal/session/project context, side effects, high-stakes or "
|
||||
"precision-sensitive answers, explicit check/verify/look-up requests, and "
|
||||
|
||||
@@ -630,6 +630,26 @@ class RealtimeAgentRoutesTests(AioHTTPTestCase):
|
||||
self.assertGreaterEqual(body["resume_ttl_ms"], 1000)
|
||||
self.assertTrue(body["experimental"])
|
||||
|
||||
async def test_final_answer_only_overrides_spoken_progress_for_one_session(self) -> None:
|
||||
token = await self._make_session()
|
||||
|
||||
resp = await self.client.post(
|
||||
"/voice/realtime-agent/session",
|
||||
json={"final_answer_only": True},
|
||||
headers=self._bearer(token),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
body = await resp.json()
|
||||
session = self._server().realtime_agent.sessions[body["session_id"]]
|
||||
self.assertTrue(session.final_answer_only)
|
||||
self.assertFalse(session.spoken_handoff)
|
||||
self.assertEqual(session.progress_spoken_after_seconds, 0.0)
|
||||
self.assertEqual(session.result_delivery, "speak_verbatim")
|
||||
instructions = broker_module._native_instructions(session)
|
||||
self.assertIn("Do not speak acknowledgements", instructions)
|
||||
self.assertIn("wait to speak until its settled final answer", instructions)
|
||||
|
||||
async def test_provider_native_instructions_include_recent_context(self) -> None:
|
||||
token = await self._make_session()
|
||||
fake_provider = FakeNativeProvider()
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package and verify the immutable Android Play-preflight artifact set."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
MANIFEST_NAME = "play-preflight.json"
|
||||
CHECKSUMS_NAME = "SHA256SUMS.txt"
|
||||
EXPECTED_ROLES = {
|
||||
"sideload-apk": "hermes-relay-{version}-sideload-release.apk",
|
||||
"google-play-aab": "hermes-relay-{version}-googlePlay-release.aab",
|
||||
}
|
||||
GIT_OBJECT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ArtifactError(ValueError):
|
||||
"""Raised when a release artifact set violates its contract."""
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def safe_artifact_name(name: str) -> str:
|
||||
candidate = Path(name)
|
||||
if candidate.name != name or name in {"", ".", ".."}:
|
||||
raise ArtifactError(f"Unsafe artifact filename: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def expected_checksum_text(artifacts: list[dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
f"{artifact['sha256']} {artifact['name']}"
|
||||
for artifact in sorted(artifacts, key=lambda item: item["name"])
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def package_artifacts(args: argparse.Namespace) -> None:
|
||||
if not GIT_OBJECT_PATTERN.fullmatch(args.commit):
|
||||
raise ArtifactError(f"Invalid commit id: {args.commit!r}")
|
||||
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
|
||||
raise ArtifactError(f"Invalid tree id: {args.tree!r}")
|
||||
if not str(args.version_code).isdigit():
|
||||
raise ArtifactError(f"Invalid versionCode: {args.version_code!r}")
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
if any(output.iterdir()):
|
||||
raise ArtifactError(f"Output directory must be empty: {output}")
|
||||
|
||||
sources = {
|
||||
"sideload-apk": args.sideload_apk.resolve(),
|
||||
"google-play-aab": args.google_play_aab.resolve(),
|
||||
}
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
for role, source in sources.items():
|
||||
if not source.is_file():
|
||||
raise ArtifactError(f"Missing {role}: {source}")
|
||||
expected_name = EXPECTED_ROLES[role].format(version=args.version)
|
||||
if source.name != expected_name:
|
||||
raise ArtifactError(
|
||||
f"{role} must be named {expected_name}, got {source.name}"
|
||||
)
|
||||
|
||||
destination = output / safe_artifact_name(source.name)
|
||||
shutil.copy2(source, destination)
|
||||
artifacts.append(
|
||||
{
|
||||
"role": role,
|
||||
"name": destination.name,
|
||||
"sha256": sha256(destination),
|
||||
"size": destination.stat().st_size,
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"version": args.version,
|
||||
"versionCode": str(args.version_code),
|
||||
"commit": args.commit,
|
||||
"tree": args.tree,
|
||||
"track": "production",
|
||||
"status": "draft",
|
||||
"artifacts": sorted(artifacts, key=lambda item: item["role"]),
|
||||
}
|
||||
(output / MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(output / CHECKSUMS_NAME).write_text(
|
||||
expected_checksum_text(artifacts),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
print(json.dumps(manifest, sort_keys=True))
|
||||
|
||||
|
||||
def load_manifest(directory: Path) -> dict[str, Any]:
|
||||
manifest_path = directory / MANIFEST_NAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise ArtifactError(f"Missing {MANIFEST_NAME} in {directory}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ArtifactError(f"Invalid {MANIFEST_NAME}: {exc}") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise ArtifactError(f"{MANIFEST_NAME} must contain a JSON object")
|
||||
return manifest
|
||||
|
||||
|
||||
def extract_archive(args: argparse.Namespace) -> None:
|
||||
archive_path = args.archive.resolve()
|
||||
if not archive_path.is_file():
|
||||
raise ArtifactError(f"Artifact archive does not exist: {archive_path}")
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
if any(output.iterdir()):
|
||||
raise ArtifactError(f"Output directory must be empty: {output}")
|
||||
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for info in archive.infolist():
|
||||
if info.is_dir():
|
||||
raise ArtifactError(
|
||||
f"Artifact archive contains a directory: {info.filename!r}"
|
||||
)
|
||||
name = safe_artifact_name(info.filename)
|
||||
if name in seen:
|
||||
raise ArtifactError(f"Duplicate archive entry: {name}")
|
||||
unix_type = (info.external_attr >> 16) & 0o170000
|
||||
if unix_type == 0o120000:
|
||||
raise ArtifactError(f"Artifact archive contains a symlink: {name}")
|
||||
seen.add(name)
|
||||
with archive.open(info) as source, (output / name).open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ArtifactError(f"Invalid artifact ZIP: {exc}") from exc
|
||||
|
||||
|
||||
def verify_artifacts(args: argparse.Namespace) -> None:
|
||||
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
|
||||
raise ArtifactError(f"Invalid expected tree id: {args.tree!r}")
|
||||
if not str(args.version_code).isdigit():
|
||||
raise ArtifactError(f"Invalid expected versionCode: {args.version_code!r}")
|
||||
directory = args.directory.resolve()
|
||||
if not directory.is_dir():
|
||||
raise ArtifactError(f"Artifact directory does not exist: {directory}")
|
||||
manifest = load_manifest(directory)
|
||||
commit = manifest.get("commit")
|
||||
if not isinstance(commit, str) or not GIT_OBJECT_PATTERN.fullmatch(commit):
|
||||
raise ArtifactError(f"Manifest commit is invalid: {commit!r}")
|
||||
|
||||
expected_metadata = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"version": args.version,
|
||||
"versionCode": str(args.version_code),
|
||||
"tree": args.tree,
|
||||
"track": "production",
|
||||
"status": "draft",
|
||||
}
|
||||
for key, expected in expected_metadata.items():
|
||||
actual = manifest.get(key)
|
||||
if str(actual) != str(expected):
|
||||
raise ArtifactError(
|
||||
f"Manifest {key} mismatch: expected {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
entries = manifest.get("artifacts")
|
||||
if not isinstance(entries, list):
|
||||
raise ArtifactError("Manifest artifacts must be a list")
|
||||
by_role: dict[str, dict[str, Any]] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise ArtifactError("Every manifest artifact must be an object")
|
||||
role = entry.get("role")
|
||||
if role not in EXPECTED_ROLES:
|
||||
raise ArtifactError(f"Unexpected artifact role: {role!r}")
|
||||
if role in by_role:
|
||||
raise ArtifactError(f"Duplicate artifact role: {role}")
|
||||
by_role[role] = entry
|
||||
if set(by_role) != set(EXPECTED_ROLES):
|
||||
missing = sorted(set(EXPECTED_ROLES) - set(by_role))
|
||||
raise ArtifactError(f"Missing artifact roles: {', '.join(missing)}")
|
||||
|
||||
verified: list[dict[str, Any]] = []
|
||||
for role, expected_pattern in EXPECTED_ROLES.items():
|
||||
entry = by_role[role]
|
||||
name = safe_artifact_name(str(entry.get("name", "")))
|
||||
expected_name = expected_pattern.format(version=args.version)
|
||||
if name != expected_name:
|
||||
raise ArtifactError(f"{role} must be named {expected_name}, got {name}")
|
||||
path = directory / name
|
||||
if not path.is_file():
|
||||
raise ArtifactError(f"Missing artifact file: {name}")
|
||||
actual_hash = sha256(path)
|
||||
if actual_hash != entry.get("sha256"):
|
||||
raise ArtifactError(f"SHA-256 mismatch for {name}")
|
||||
if path.stat().st_size != entry.get("size"):
|
||||
raise ArtifactError(f"Size mismatch for {name}")
|
||||
verified.append(entry)
|
||||
|
||||
checksum_path = directory / CHECKSUMS_NAME
|
||||
try:
|
||||
checksum_text = checksum_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise ArtifactError(f"Missing {CHECKSUMS_NAME}") from exc
|
||||
if checksum_text != expected_checksum_text(verified):
|
||||
raise ArtifactError(f"{CHECKSUMS_NAME} does not match the manifest")
|
||||
|
||||
allowed_names = {
|
||||
MANIFEST_NAME,
|
||||
CHECKSUMS_NAME,
|
||||
*(entry["name"] for entry in verified),
|
||||
}
|
||||
directory_entries = list(directory.iterdir())
|
||||
non_files = sorted(path.name for path in directory_entries if not path.is_file())
|
||||
if non_files:
|
||||
raise ArtifactError(
|
||||
"Artifact directory contains non-file entries: " + ", ".join(non_files)
|
||||
)
|
||||
actual_names = {path.name for path in directory_entries}
|
||||
if actual_names != allowed_names:
|
||||
unexpected = sorted(actual_names - allowed_names)
|
||||
missing = sorted(allowed_names - actual_names)
|
||||
details = []
|
||||
if unexpected:
|
||||
details.append(f"unexpected: {', '.join(unexpected)}")
|
||||
if missing:
|
||||
details.append(f"missing: {', '.join(missing)}")
|
||||
raise ArtifactError("Artifact set mismatch (" + "; ".join(details) + ")")
|
||||
|
||||
print(json.dumps(manifest, sort_keys=True))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
package = subparsers.add_parser("package", help="Create a preflight artifact set")
|
||||
package.add_argument("--version", required=True)
|
||||
package.add_argument("--version-code", required=True)
|
||||
package.add_argument("--commit", required=True)
|
||||
package.add_argument("--tree", required=True)
|
||||
package.add_argument("--sideload-apk", required=True, type=Path)
|
||||
package.add_argument("--google-play-aab", required=True, type=Path)
|
||||
package.add_argument("--output", required=True, type=Path)
|
||||
package.set_defaults(func=package_artifacts)
|
||||
|
||||
extract = subparsers.add_parser(
|
||||
"extract", help="Safely extract a downloaded Actions artifact"
|
||||
)
|
||||
extract.add_argument("--archive", required=True, type=Path)
|
||||
extract.add_argument("--output", required=True, type=Path)
|
||||
extract.set_defaults(func=extract_archive)
|
||||
|
||||
verify = subparsers.add_parser("verify", help="Verify a preflight artifact set")
|
||||
verify.add_argument("--version", required=True)
|
||||
verify.add_argument("--version-code", required=True)
|
||||
verify.add_argument("--tree", required=True)
|
||||
verify.add_argument("--directory", required=True, type=Path)
|
||||
verify.set_defaults(func=verify_artifacts)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
args.func(args)
|
||||
except ArtifactError as exc:
|
||||
parser.error(str(exc))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,285 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')]
|
||||
[string]$Version,
|
||||
|
||||
[switch]$DeployPhone,
|
||||
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[switch]$DryRun,
|
||||
|
||||
[string]$Repository = 'Codename-11/hermes-relay'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
Set-Location $RepoRoot
|
||||
|
||||
function Invoke-Captured {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Command,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
|
||||
$output = & $Command @Arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Command $($Arguments -join ' ') failed:`n$($output -join [Environment]::NewLine)"
|
||||
}
|
||||
return ($output -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
|
||||
function Invoke-Streaming {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Command,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
|
||||
& $Command @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Start-GitHubWorkflow {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Workflow,
|
||||
[Parameter(Mandatory = $true)][string]$Ref,
|
||||
[Parameter(Mandatory = $true)][hashtable]$Inputs
|
||||
)
|
||||
|
||||
$arguments = @('workflow', 'run', $Workflow, '--repo', $Repository, '--ref', $Ref)
|
||||
foreach ($entry in $Inputs.GetEnumerator() | Sort-Object Key) {
|
||||
$arguments += @('-f', "$($entry.Key)=$($entry.Value)")
|
||||
}
|
||||
$output = Invoke-Captured -Command 'gh' -Arguments $arguments
|
||||
if ($output -notmatch '/actions/runs/(?<id>\d+)') {
|
||||
throw "GitHub did not return a workflow run URL:`n$output"
|
||||
}
|
||||
return $Matches.id
|
||||
}
|
||||
|
||||
function Wait-GitHubRun {
|
||||
param([Parameter(Mandatory = $true)][string]$RunId)
|
||||
|
||||
Invoke-Streaming -Command 'gh' -Arguments @(
|
||||
'run', 'watch', $RunId,
|
||||
'--repo', $Repository,
|
||||
'--exit-status',
|
||||
'--interval', '15'
|
||||
)
|
||||
}
|
||||
|
||||
function Require-CleanPreparedDev {
|
||||
$branch = Invoke-Captured -Command 'git' -Arguments @('branch', '--show-current')
|
||||
if ($branch -ne 'dev') {
|
||||
throw "Android publication must start from dev; current branch is $branch"
|
||||
}
|
||||
$status = Invoke-Captured -Command 'git' -Arguments @('status', '--porcelain')
|
||||
if ($status) {
|
||||
throw "Working tree is not clean:`n$status"
|
||||
}
|
||||
|
||||
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'dev', 'main', '--tags')
|
||||
$head = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
|
||||
$originDev = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/dev')
|
||||
if ($head -ne $originDev) {
|
||||
throw "Local dev ($head) does not match origin/dev ($originDev)"
|
||||
}
|
||||
|
||||
$existingTag = & git ls-remote --tags origin "refs/tags/android-v$Version"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Unable to query existing Android tags'
|
||||
}
|
||||
if ($existingTag) {
|
||||
throw "android-v$Version already exists"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-ReleaseMetadata {
|
||||
$versionsFile = Get-Content 'gradle/libs.versions.toml' -Raw
|
||||
if ($versionsFile -notmatch 'appVersionName\s*=\s*"(?<version>[^"]+)"') {
|
||||
throw 'Unable to read appVersionName from gradle/libs.versions.toml'
|
||||
}
|
||||
if ($Matches.version -ne $Version) {
|
||||
throw "Requested version $Version does not match appVersionName $($Matches.version)"
|
||||
}
|
||||
if ($versionsFile -notmatch 'appVersionCode\s*=\s*"(?<code>\d+)"') {
|
||||
throw 'Unable to read appVersionCode from gradle/libs.versions.toml'
|
||||
}
|
||||
$versionCode = $Matches.code
|
||||
if (-not (Select-String -Path 'CHANGELOG.md' -Pattern "^## \[(Android )?$([regex]::Escape($Version))\]" -Quiet)) {
|
||||
throw "CHANGELOG.md has no Android release heading for $Version"
|
||||
}
|
||||
return $versionCode
|
||||
}
|
||||
|
||||
function Deploy-CompatiblePhoneBuild {
|
||||
param([Parameter(Mandatory = $true)][string]$VersionCode)
|
||||
|
||||
$sdk = if ($env:ANDROID_HOME) {
|
||||
$env:ANDROID_HOME
|
||||
} else {
|
||||
Join-Path $env:LOCALAPPDATA 'Android\Sdk'
|
||||
}
|
||||
$adb = Join-Path $sdk 'platform-tools\adb.exe'
|
||||
if (-not (Test-Path -LiteralPath $adb)) {
|
||||
throw "adb was not found at $adb"
|
||||
}
|
||||
$env:ANDROID_HOME = $sdk
|
||||
$env:ANDROID_SDK_ROOT = $sdk
|
||||
|
||||
Invoke-Streaming -Command '.\gradlew.bat' -Arguments @(
|
||||
':app:assembleSideloadDebug',
|
||||
'--no-daemon',
|
||||
'--console=plain'
|
||||
)
|
||||
$apk = Get-ChildItem 'app\build\outputs\apk\sideload\debug' -Filter "*-$Version-sideload-debug.apk" |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $apk) {
|
||||
throw "No compatible sideload debug APK was produced for $Version"
|
||||
}
|
||||
|
||||
$adbArguments = @()
|
||||
if ($DeviceSerial) {
|
||||
$adbArguments += @('-s', $DeviceSerial)
|
||||
}
|
||||
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @('install', '-r', $apk.FullName))
|
||||
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
|
||||
'shell', 'am', 'force-stop', 'com.axiomlabs.hermesrelay.sideload'
|
||||
))
|
||||
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
|
||||
'shell', 'am', 'start', '-n',
|
||||
'com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity'
|
||||
))
|
||||
$package = Invoke-Captured -Command $adb -Arguments ($adbArguments + @(
|
||||
'shell', 'dumpsys', 'package', 'com.axiomlabs.hermesrelay.sideload'
|
||||
))
|
||||
if ($package -notmatch "versionCode=$([regex]::Escape($VersionCode))\b" -or
|
||||
$package -notmatch "versionName=$([regex]::Escape($Version))-sideload\b") {
|
||||
throw "Phone package verification failed for version $Version / code $VersionCode"
|
||||
}
|
||||
Write-Host "Phone verified at $Version-sideload (versionCode $VersionCode)."
|
||||
}
|
||||
|
||||
$versionCode = Require-ReleaseMetadata
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "Dry run: Android $Version (versionCode $versionCode)"
|
||||
Write-Host '1. Require clean origin/dev and an unused Android tag.'
|
||||
Write-Host '2. Run and await Play Preflight from dev.'
|
||||
Write-Host '3. Open or reuse the dev-to-main release PR, await checks, and merge.'
|
||||
Write-Host '4. Verify the main tree equals the preflighted dev tree.'
|
||||
Write-Host '5. Run and await Approve Android Release from main.'
|
||||
Write-Host '6. Verify the immutable tag and GitHub release.'
|
||||
if ($DeployPhone) {
|
||||
Write-Host '7. Build/install the compatible sideload-debug APK without erasing phone data.'
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
Invoke-Streaming -Command 'gh' -Arguments @('auth', 'status')
|
||||
Require-CleanPreparedDev
|
||||
|
||||
$devCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
|
||||
$devTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD^{tree}')
|
||||
Write-Host "Starting Android $Version from dev $devCommit (tree $devTree)."
|
||||
|
||||
$preflightRun = Start-GitHubWorkflow -Workflow 'play-preflight-android.yml' -Ref 'dev' -Inputs @{
|
||||
version = $Version
|
||||
}
|
||||
Write-Host "Play preflight run: https://github.com/$Repository/actions/runs/$preflightRun"
|
||||
Wait-GitHubRun -RunId $preflightRun
|
||||
|
||||
$pullRequests = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'pr', 'list',
|
||||
'--repo', $Repository,
|
||||
'--base', 'main',
|
||||
'--head', 'dev',
|
||||
'--state', 'open',
|
||||
'--limit', '1',
|
||||
'--json', 'number,url,headRefOid'
|
||||
) | ConvertFrom-Json
|
||||
if (-not $pullRequests) {
|
||||
$body = @"
|
||||
Promote the exact Play-preflighted Android $Version release tree from ``dev`` to ``main``.
|
||||
|
||||
- dev SHA: ``$devCommit``
|
||||
- release tree: ``$devTree``
|
||||
- Play preflight: https://github.com/$Repository/actions/runs/$preflightRun
|
||||
|
||||
Approval will verify this unchanged tree before creating ``android-v$Version``.
|
||||
"@
|
||||
$prUrl = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'pr', 'create',
|
||||
'--repo', $Repository,
|
||||
'--base', 'main',
|
||||
'--head', 'dev',
|
||||
'--title', "release(android): promote android-v$Version",
|
||||
'--body', $body
|
||||
)
|
||||
$prNumber = [regex]::Match($prUrl, '/pull/(?<number>\d+)').Groups['number'].Value
|
||||
} else {
|
||||
if ([string]$pullRequests[0].headRefOid -ne $devCommit) {
|
||||
throw "Existing release PR head $($pullRequests[0].headRefOid) does not match preflighted dev $devCommit"
|
||||
}
|
||||
$prNumber = [string]$pullRequests[0].number
|
||||
$prUrl = [string]$pullRequests[0].url
|
||||
}
|
||||
if (-not $prNumber) {
|
||||
throw "Unable to determine release PR number from $prUrl"
|
||||
}
|
||||
Write-Host "Release PR: $prUrl"
|
||||
Invoke-Streaming -Command 'gh' -Arguments @(
|
||||
'pr', 'checks', $prNumber,
|
||||
'--repo', $Repository,
|
||||
'--watch',
|
||||
'--fail-fast'
|
||||
)
|
||||
$checkedHead = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'pr', 'view', $prNumber,
|
||||
'--repo', $Repository,
|
||||
'--json', 'headRefOid',
|
||||
'--jq', '.headRefOid'
|
||||
)
|
||||
if ($checkedHead -ne $devCommit) {
|
||||
throw "Release PR advanced to $checkedHead after preflight; rerun from the new dev tip"
|
||||
}
|
||||
Invoke-Streaming -Command 'gh' -Arguments @(
|
||||
'pr', 'merge', $prNumber,
|
||||
'--repo', $Repository,
|
||||
'--merge'
|
||||
)
|
||||
|
||||
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'main', '--tags')
|
||||
$mainCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main')
|
||||
$mainTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main^{tree}')
|
||||
if ($mainTree -ne $devTree) {
|
||||
throw "Merged main tree $mainTree does not match preflighted dev tree $devTree"
|
||||
}
|
||||
|
||||
$approvalRun = Start-GitHubWorkflow -Workflow 'approve-release-android.yml' -Ref 'main' -Inputs @{
|
||||
version = $Version
|
||||
}
|
||||
Write-Host "Approval run: https://github.com/$Repository/actions/runs/$approvalRun"
|
||||
Wait-GitHubRun -RunId $approvalRun
|
||||
|
||||
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', '--tags')
|
||||
$tagCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', "android-v$Version")
|
||||
if ($tagCommit -ne $mainCommit) {
|
||||
throw "android-v$Version points to $tagCommit instead of main $mainCommit"
|
||||
}
|
||||
$release = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'release', 'view', "android-v$Version",
|
||||
'--repo', $Repository,
|
||||
'--json', 'url,tagName,publishedAt'
|
||||
) | ConvertFrom-Json
|
||||
Write-Host "Published release: $($release.url)"
|
||||
|
||||
if ($DeployPhone) {
|
||||
Deploy-CompatiblePhoneBuild -VersionCode $versionCode
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from argparse import Namespace
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("android-release-artifacts.py")
|
||||
SPEC = importlib.util.spec_from_file_location("android_release_artifacts", SCRIPT_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class AndroidReleaseArtifactsTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.apk = self.root / "hermes-relay-1.5.2-sideload-release.apk"
|
||||
self.aab = self.root / "hermes-relay-1.5.2-googlePlay-release.aab"
|
||||
self.apk.write_bytes(b"apk payload")
|
||||
self.aab.write_bytes(b"aab payload")
|
||||
self.output = self.root / "release"
|
||||
self.package_args = Namespace(
|
||||
version="1.5.2",
|
||||
version_code="35",
|
||||
commit="a" * 40,
|
||||
tree="b" * 40,
|
||||
sideload_apk=self.apk,
|
||||
google_play_aab=self.aab,
|
||||
output=self.output,
|
||||
)
|
||||
self.verify_args = Namespace(
|
||||
version="1.5.2",
|
||||
version_code="35",
|
||||
tree="b" * 40,
|
||||
directory=self.output,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_package_and_verify_round_trip(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
manifest = json.loads(
|
||||
(self.output / MODULE.MANIFEST_NAME).read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual("1.5.2", manifest["version"])
|
||||
self.assertEqual("35", manifest["versionCode"])
|
||||
self.assertEqual(
|
||||
{"google-play-aab", "sideload-apk"},
|
||||
{artifact["role"] for artifact in manifest["artifacts"]},
|
||||
)
|
||||
|
||||
def test_verify_rejects_modified_artifact(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
packaged_apk = self.output / self.apk.name
|
||||
packaged_apk.write_bytes(b"tampered")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "SHA-256 mismatch"):
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
def test_verify_rejects_wrong_release_tree(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
self.verify_args.tree = "c" * 40
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "tree mismatch"):
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
def test_package_rejects_wrong_extension(self) -> None:
|
||||
self.package_args.sideload_apk = self.root / "not-an-apk.txt"
|
||||
self.package_args.sideload_apk.write_text("no", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "must be named"):
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
|
||||
def test_verify_rejects_unexpected_file(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
(self.output / "extra.bin").write_bytes(b"unexpected")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "unexpected"):
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
def test_extract_rejects_path_traversal(self) -> None:
|
||||
archive = self.root / "artifact.zip"
|
||||
with zipfile.ZipFile(archive, "w") as handle:
|
||||
handle.writestr("../outside.txt", "unsafe")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "Unsafe artifact filename"):
|
||||
MODULE.extract_archive(
|
||||
Namespace(archive=archive, output=self.root / "extracted")
|
||||
)
|
||||
self.assertFalse((self.root / "outside.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user