Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e4ff0a9e | ||
|
|
f5dab50e4d | ||
|
|
2479ddb9a6 | ||
|
|
45fef54c6e | ||
|
|
258583527e | ||
|
|
851f7f4dfc | ||
|
|
2e5fd4a3cf | ||
|
|
6d86d310ec | ||
|
|
e55dd99f62 | ||
|
|
7793934edf | ||
|
|
f49c6c4203 | ||
|
|
52a7d67cc4 |
@@ -12,10 +12,22 @@
|
||||
|
||||
-
|
||||
|
||||
## Lineage / contributor credit
|
||||
|
||||
<!--
|
||||
If this PR salvages or supersedes earlier work, link every source PR and name
|
||||
the original contributor(s). Preserve original commit authors where practical;
|
||||
otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
|
||||
-->
|
||||
|
||||
- Source PR(s): N/A
|
||||
- Attribution preserved by: N/A
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Target branch is `dev` unless this is a release PR
|
||||
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
|
||||
- [ ] Translation changes: `python scripts/check-android-locales.py` ran and the locale was reviewed on a device/emulator, or N/A
|
||||
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
|
||||
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
|
||||
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
|
||||
@@ -23,3 +35,4 @@
|
||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [ ] CHANGELOG.md updated (if user-facing)
|
||||
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
|
||||
- [ ] Salvaged work links the source PR and preserves contributor authorship, or N/A
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Hermes-Relay-Android — explicit public release approval
|
||||
#
|
||||
# Run from main only after the private Play preflight is clean and the release
|
||||
# PR has merged. Creating the stable tag is the public release decision; the
|
||||
# tag-triggered release workflow submits Play first, then publishes GitHub.
|
||||
|
||||
name: Approve Android Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Approved Android version (for example 1.4.3)"
|
||||
required: true
|
||||
type: string
|
||||
confirm_play_checks:
|
||||
description: "I reviewed and accept the Play pre-review and pre-launch results"
|
||||
required: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: approve-android-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: Verify preflight and create release tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate approval request
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
CONFIRM_PLAY_CHECKS: ${{ inputs.confirm_play_checks }}
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
||||
echo "::error::Approve Android Release must run from main, not $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONFIRM_PLAY_CHECKS" != "true" ]; then
|
||||
echo "::error::Play checks must be reviewed and explicitly accepted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
if [ "$REQUESTED_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Requested version $REQUESTED_VERSION does not match appVersionName $TOML_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$TOML_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify this exact release tree passed Play preflight
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
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
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified Play preflight proof: $ARTIFACT_NAME"
|
||||
|
||||
- name: Ensure release tag does not already exist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
if gh api "/repos/${GITHUB_REPOSITORY}/git/ref/tags/android-v${VERSION}" >/dev/null 2>&1; then
|
||||
echo "::error::Tag android-v${VERSION} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create approved Android release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh api --method POST "/repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f ref="refs/tags/android-v${VERSION}" \
|
||||
-f sha="$GITHUB_SHA"
|
||||
|
||||
- name: Start the tag release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-android.yml \
|
||||
--ref="android-v${VERSION}" \
|
||||
-f version="$VERSION"
|
||||
|
||||
- name: Approval summary
|
||||
run: |
|
||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -25,7 +25,12 @@ on:
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
- ".github/workflows/play-preflight-android.yml"
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
@@ -36,7 +41,12 @@ on:
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
- ".github/workflows/play-preflight-android.yml"
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
@@ -53,7 +63,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -66,6 +76,12 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Validate translation catalogs
|
||||
run: python3 scripts/check-android-locales.py
|
||||
|
||||
- name: Reject unsafe Android collection APIs
|
||||
run: python3 scripts/check-android-collection-apis.py
|
||||
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lint --console=plain
|
||||
|
||||
@@ -79,7 +95,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -123,7 +139,7 @@ jobs:
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -148,6 +164,7 @@ jobs:
|
||||
--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 \
|
||||
@@ -186,7 +203,7 @@ jobs:
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -204,3 +221,9 @@ jobs:
|
||||
# smoke; the goal is to exercise the build, not to produce a shippable AAB.
|
||||
- name: Build release bundles + APKs (both flavors, debug-signed)
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/googlePlay/release/*.apk \
|
||||
--apk app/build/outputs/apk/sideload/release/*.apk
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
echo "Checking standard-path route contract against upstream ref: $REF"
|
||||
|
||||
- name: Checkout vanilla upstream (no plugin, no bootstrap)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test typed stream rendering
|
||||
run: npm test
|
||||
|
||||
- name: Build (tsc → dist/)
|
||||
run: npm run build
|
||||
|
||||
@@ -62,7 +65,7 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -90,10 +93,10 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# Depth 2 includes the pull_request merge commit's first parent, which
|
||||
# lets the next step detect whether this PR changes the workflow file.
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
id-token: write # OIDC token exchange for the Claude action
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -322,7 +322,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
- "assets/play-store-icon-512.png"
|
||||
- "assets/play-store-feature-1024x500.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "app/src/googlePlay/play/default-language.txt"
|
||||
- "app/src/googlePlay/play/*.txt"
|
||||
- "app/src/googlePlay/play/listings/**"
|
||||
- "scripts/screenshots.py"
|
||||
- ".github/workflows/play-listing.yml"
|
||||
@@ -20,7 +20,7 @@ on:
|
||||
- "assets/play-store-icon-512.png"
|
||||
- "assets/play-store-feature-1024x500.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "app/src/googlePlay/play/default-language.txt"
|
||||
- "app/src/googlePlay/play/*.txt"
|
||||
- "app/src/googlePlay/play/listings/**"
|
||||
- "scripts/screenshots.py"
|
||||
- ".github/workflows/play-listing.yml"
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Hermes-Relay-Android — private Google Play preflight
|
||||
#
|
||||
# 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. Play can then run pre-review and
|
||||
# pre-launch checks while no public GitHub Release or sideload APK exists.
|
||||
|
||||
name: Play Preflight — Android
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Android version to preflight (for example 1.4.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: play-preflight-android
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Build and upload private Play draft
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Require final release branch and matching version
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/dev" ] && [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
||||
echo "::error::Run Play preflight from dev or untagged main, not $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
if [ "$REQUESTED_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Requested version $REQUESTED_VERSION does not match appVersionName $TOML_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$TOML_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require Play and release-signing secrets
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "::error::PLAY_SERVICE_ACCOUNT_JSON is required for Play preflight"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "::error::HERMES_KEYSTORE_BASE64 is required for Play preflight"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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 release metadata and source compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.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 final release artifacts
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Scan final release DEX
|
||||
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: Upload private production draft to Play
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
run: |
|
||||
trap 'rm -f play-service-account.json' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle \
|
||||
--track=production \
|
||||
--release-status=draft \
|
||||
--resolution-strategy=ignore \
|
||||
--release-name="Hermes-Relay ${{ steps.metadata.outputs.version }} preflight"
|
||||
|
||||
- name: Record successful preflight for the exact commit
|
||||
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
|
||||
|
||||
- name: Upload preflight 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
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Preflight summary
|
||||
run: |
|
||||
echo "## Play preflight ready" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Version: **${{ steps.metadata.outputs.version }}** (code ${{ steps.metadata.outputs.version_code }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Commit: \`$GITHUB_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Review Play pre-review checks and the pre-launch report. After they are acceptable, ensure this exact release tree is on main, then run **Approve Android Release** from main." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -11,9 +11,19 @@ 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.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Approved Android version"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
@@ -22,12 +32,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
version_code: ${{ steps.version.outputs.version_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
if [ "$GITHUB_REF" = "$REF_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
|
||||
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"
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
@@ -44,13 +68,30 @@ jobs:
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
- name: Require successful Play preflight for this exact release tree
|
||||
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
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
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
echo "Run Play Preflight from the final dev tree, review Play's checks, merge that unchanged tree to main, then approve the release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Play preflight proof found: $ARTIFACT_NAME"
|
||||
|
||||
ci:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -63,6 +104,12 @@ jobs:
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Validate release metadata and Android API compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
|
||||
# Keep the tag release gate aligned with CI — Android's broad Gradle
|
||||
# `test` aggregate currently hangs in deferred JVM suites tracked by
|
||||
# issue #32, so the release gate runs the stable connection/pairing slice.
|
||||
@@ -79,7 +126,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -116,6 +163,12 @@ jobs:
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
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)
|
||||
run: |
|
||||
echo "=== APK outputs ==="
|
||||
@@ -135,6 +188,32 @@ jobs:
|
||||
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Require Play credentials for stable release
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "::error::PLAY_SERVICE_ACCOUNT_JSON is required for stable Android releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Submit preflighted Play draft to production review
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
trap 'rm -f play-service-account.json' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew promoteGooglePlayReleaseArtifact \
|
||||
--update=production \
|
||||
--version-code=${{ needs.validate.outputs.version_code }} \
|
||||
--release-status=completed \
|
||||
--release-name="Hermes-Relay ${{ needs.validate.outputs.version }}"
|
||||
|
||||
# Public distribution happens only after Play accepts the production
|
||||
# submission above. This keeps a Play-detected release blocker from
|
||||
# appearing after the sideload APK is already public.
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
@@ -142,56 +221,13 @@ jobs:
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Deliberate 2-asset policy (#144): attach ONLY
|
||||
# `hermes-relay-<version>-sideload-release.apk` (the file users
|
||||
# install by tapping — full Device Control feature set) and
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` (the Play Console
|
||||
# upload bundle — NOT tap-installable on a phone), plus the
|
||||
# SHA256SUMS.txt covering exactly those two files. GitHub sorts
|
||||
# assets alphabetically, so extra files made the non-installable
|
||||
# .aab list first and confused new users. The parity twins
|
||||
# (googlePlay APK, sideload AAB) are still BUILT by the step above
|
||||
# and reproducible from the tag via CI, just not attached.
|
||||
# NEVER rename the sideload APK: the in-app update checker
|
||||
# (update/UpdateChecker.kt) matches assets by ".apk" + "sideload"
|
||||
# in the name, and user-docs verify steps cite the filename.
|
||||
# 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
|
||||
|
||||
- name: Upload to Play Console (production draft)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
# Runs only when the Play service-account secret is configured AND this is
|
||||
# a stable tag (prereleases — versions containing a dash — are skipped so
|
||||
# an `-rc.N` build never lands on the production listing). HERMES_KEYSTORE_PATH
|
||||
# was exported into $GITHUB_ENV by the "Decode release keystore" step above
|
||||
# and persists across steps in this job, so the AAB is release-signed.
|
||||
#
|
||||
# `publishGooglePlayReleaseBundle` is the flavor-scoped task — only the
|
||||
# googlePlay AAB is uploaded (sideload is disabled via playConfigs in
|
||||
# app/build.gradle.kts). The play{} block pins releaseStatus = DRAFT, so the
|
||||
# build lands on the Production track as a DRAFT: CI does the upload, a human
|
||||
# clicks "Start rollout" in Play Console. A bad tag can never auto-go-live.
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON != '' && !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle --track=production
|
||||
rm -f play-service-account.json
|
||||
|
||||
- name: Play upload skipped (no secret)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON == '' }}
|
||||
run: |
|
||||
echo "ℹ️ PLAY_SERVICE_ACCOUNT_JSON not set — skipped Play Console upload." \
|
||||
"GitHub Release artifacts are still published; upload to Play manually" \
|
||||
"(see RELEASE.md §5)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v6
|
||||
@@ -104,10 +104,10 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -176,13 +176,13 @@ jobs:
|
||||
steps:
|
||||
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
|
||||
# (the other publish-release steps only consume downloaded build artifacts).
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract CLI version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
+29
-1
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop chat can use Relay typed streaming over WSS.** The opt-in `--relay-chat` mode sends `chat.send`, renders typed `stream.event` v1 assistant/tool/artifact/memory/skill/error lifecycles, de-duplicates reconnect events, and preserves the existing gateway chat path as the default.
|
||||
|
||||
## [1.4.3] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Language switching is available inside the app.** Settings → Appearance now offers System default, English, and Simplified Chinese, stays synchronized with Android's per-app language setting, and persists the choice on Android 12 and lower.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Release builds reject unsupported collection APIs.** CI now scans Kotlin sources and final minified APK bytecode for Java 21 list endpoint calls that can crash on Android versions before API 35.
|
||||
|
||||
## [1.4.2] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Android now supports Simplified Chinese.** Chat, Manage, Voice, connection setup, settings, diagnostics, notifications, accessibility labels, and both product flavors follow the device language, with Android per-app language discovery on supported versions.
|
||||
- **Localization is contributor-ready.** CI enforces resource, plural, and format-argument parity; translated README and VitePress entry points establish a repeatable path for adding languages without duplicating fast-moving technical references.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Connection scan and queued-message counts use proper plurals.** Count formatting no longer depends on English-only suffix arguments and cannot fail when a locale needs a different plural structure.
|
||||
|
||||
## [1.4.1] - 2026-07-11
|
||||
|
||||
### Added
|
||||
@@ -1523,7 +1548,10 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.3...HEAD
|
||||
[1.4.3]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.2...android-v1.4.3
|
||||
[1.4.2]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.1...android-v1.4.2
|
||||
[1.4.1]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...android-v1.4.1
|
||||
[1.4.0]: https://github.com/Codename-11/hermes-relay/compare/android-v1.3.0...android-v1.4.0
|
||||
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
|
||||
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
|
||||
|
||||
@@ -96,6 +96,52 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
|
||||
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## Stale PR salvage and contributor credit
|
||||
|
||||
A valuable pull request can become unsafe to merge when `dev` has materially
|
||||
changed around it. Maintainers may create a replacement **salvage PR** from the
|
||||
current `dev` instead of resolving a stale branch by choosing whole conflict
|
||||
sides.
|
||||
|
||||
A salvage PR must:
|
||||
|
||||
- Link the original PR and contributor in its title or opening summary.
|
||||
- Recover only the intended feature; unrelated fork, release, signing, and
|
||||
generated migration changes stay out.
|
||||
- Preserve the original commit author when a substantive commit can be safely
|
||||
cherry-picked.
|
||||
- Use a verified `Co-authored-by: Name <email>` trailer when the implementation
|
||||
must be reconstructed or substantially rewritten.
|
||||
- Include a `Lineage` section listing source and superseded PRs, plus a concise
|
||||
explanation of integration changes made for current `dev`.
|
||||
- Run current verification rather than relying on checks from the stale branch.
|
||||
- Leave a comment linking the replacement before the source PR is closed.
|
||||
|
||||
The maintainer remains the committer for integration commits. The original
|
||||
contributor remains the author or co-author of the recovered work. Do not guess
|
||||
an email address: use the source commit's verified address or ask the
|
||||
contributor.
|
||||
|
||||
## Localization contributions
|
||||
|
||||
English resources are canonical and Android locale catalogs must retain exact
|
||||
resource and format-argument parity. Read [docs/localization.md](docs/localization.md)
|
||||
before changing user-facing strings or adding a language.
|
||||
|
||||
Translation PRs should cover one locale or one clear catalog refresh. They must
|
||||
not include custom APK publishing, signing configuration, version bumps, or
|
||||
fork-specific branding. Run:
|
||||
|
||||
```bash
|
||||
python scripts/check-android-locales.py
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
Also identify a fluent reviewer or explain the device and language review used.
|
||||
Translated READMEs use separate `README.<locale>.md` files; `README.md` remains
|
||||
the canonical project description. User docs may be added incrementally under
|
||||
`user-docs/<locale>/`, with links back to canonical English reference material.
|
||||
|
||||
## Changelog & writing conventions
|
||||
|
||||
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-11 — Android localization foundation and Simplified Chinese
|
||||
|
||||
The Android UI now resolves its broad static copy through canonical resources,
|
||||
with a complete Simplified Chinese catalog under the script-qualified
|
||||
`values-b+zh+Hans` directory and a matching sideload-flavor catalog. Android's
|
||||
locale configuration advertises English and Simplified Chinese to system per-app
|
||||
language settings. Current notification, voice-overlay, background-process,
|
||||
diagnostic, attachment, card, crash, timeline, and session-TTL surfaces were
|
||||
reconciled after the original localization branch diverged from `dev`.
|
||||
|
||||
`scripts/check-android-locales.py` discovers locale catalogs in every Android
|
||||
source set and rejects malformed XML, duplicate resources, missing or extra
|
||||
keys, mismatched resource types, incompatible format arguments, and locale
|
||||
configuration drift. Android CI runs the checker before lint. Scan summaries and
|
||||
queued-message counts were converted from English suffix formatting to Android
|
||||
plurals after validation exposed a missing-format-argument path in the current
|
||||
connection wizard.
|
||||
|
||||
The public contribution workflow now documents stale-PR salvage, preserved
|
||||
authorship, scoped translation PRs, locale qualifiers, device review, and the
|
||||
English canonical boundary. The root README links a maintained Chinese summary,
|
||||
and VitePress exposes a Simplified Chinese locale with localized landing, quick
|
||||
start, and feature pages while linking fast-moving reference material back to
|
||||
canonical English.
|
||||
|
||||
## 2026-07-10 — In-flight Chat turns recover across app recreation
|
||||
|
||||
Current upstream Hermes can keep a running Dashboard/TUI Gateway session alive
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
@@ -151,6 +152,16 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Simplified Chinese
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置 — 全面汉化</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理 — 仪表盘汉化</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航菜单 — 简体中文</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
## Features
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<p align="center">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — 随身携带您的 Hermes 代理" width="800">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>运行在您的电脑上,连接到您的设备。</strong><br>
|
||||
Hermes-Relay 是 <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的原生 Android 客户端,提供流式聊天、免手动语音和代理管理;另有单文件 CLI,让代理在已配对的电脑上安全使用终端、文件和截图工具。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>简体中文</strong> · <a href="README.md">English</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
|
||||
<a href="CHANGELOG.md">更新日志</a>
|
||||
</p>
|
||||
|
||||
> 英文 [README.md](README.md) 是最新、完整的项目说明。本页维护中文安装入口和核心功能摘要;协议、架构和维护者文档以英文版本为准。
|
||||
|
||||
## 功能简介
|
||||
|
||||
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、多连接和配置文件。
|
||||
- **无需插件的标准路径**:聊天、管理和标准语音可直接连接未修改的上游 Hermes Agent。
|
||||
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音和电脑工具。
|
||||
- **安全连接**:二维码配对、Android Keystore、证书固定、按通道授权和可配置会话有效期。
|
||||
- **远程使用**:可配置 Tailscale 或 HTTPS 地址,在家庭局域网和远程路由之间自动切换。
|
||||
- **两种 Android 发行渠道**:Google Play 版本适合日常使用;sideload 版本包含完整手机控制能力。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 Android 应用
|
||||
|
||||
- [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay):自动更新,包含聊天、语音、管理、终端、媒体和通知功能。
|
||||
- [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases):下载最新 `android-v*` 版本中以 `-sideload-release.apk` 结尾的文件,获得完整手机控制功能。
|
||||
|
||||
### 2. 启动 Hermes API 服务
|
||||
|
||||
手机需要能够访问 Hermes API 服务,并使用 API 密钥进行身份验证:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)"
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<电脑IP>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
`0.0.0.0` 会让同一网络中的设备访问 API。请保留强密钥;离开可信局域网时,应使用 Tailscale 或 HTTPS 反向代理,不要直接把端口暴露到互联网。
|
||||
|
||||
### 3. 在手机上连接
|
||||
|
||||
打开应用后,可以:
|
||||
|
||||
- 扫描局域网中的 Hermes;
|
||||
- 手动输入 `http://<主机>:8642` 和 API 密钥;
|
||||
- 扫描包含 API、Dashboard 和可选 Relay 地址的设置二维码。
|
||||
|
||||
如需在手机上管理模型、密钥、技能和配置文件,请运行 Hermes Dashboard,并在应用的 **管理** 页面登录一次。同一登录会话也会启用标准语音。
|
||||
|
||||
### 4. 可选:安装 Relay
|
||||
|
||||
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音或电脑工具时安装:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
## 中文界面
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 参与翻译
|
||||
|
||||
Android 英文资源是规范来源。新增语言必须保持资源名称、类型和格式参数一致,并通过:
|
||||
|
||||
```bash
|
||||
python scripts/check-android-locales.py
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
翻译规范、目录命名、复数和占位符规则见 [docs/localization.md](docs/localization.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE) — Copyright (c) 2026 [Axiom-Labs](https://codename-11.dev)
|
||||
+58
-22
@@ -485,11 +485,39 @@ prefixed `hermes-relay-<version>-` via `archivesName` in
|
||||
Optional device smoke test: `scripts\dev.bat release` then
|
||||
`adb install -r app\build\outputs\apk\sideload\release\hermes-relay-*-sideload-release.apk`.
|
||||
|
||||
### 4. Commit on `dev`, merge to `main`, tag from `main`
|
||||
### 4. Run the private Play preflight from `dev`
|
||||
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
The release-prep commit lands on `dev` first. Before any public tag or GitHub
|
||||
Release exists, open **Actions → Play Preflight — Android**, choose **Run
|
||||
workflow**, select the final `dev` branch, and enter the prepared version.
|
||||
|
||||
The preflight workflow:
|
||||
|
||||
1. requires the workflow to run from `dev` or untagged `main` with matching
|
||||
version metadata;
|
||||
2. runs the release metadata, locale, and Android collection-API checks;
|
||||
3. builds and release-signs the same APK/AAB variants used by the public release;
|
||||
4. scans the final minified APK DEX for unsupported collection calls;
|
||||
5. uploads the Google Play AAB as a private **Production draft**; and
|
||||
6. records a 30-day preflight proof keyed to the version and Git tree hash.
|
||||
|
||||
No sideload APK or GitHub Release is published by preflight. Wait for Play's
|
||||
pre-review checks and pre-launch report, then review every error and warning.
|
||||
If the release source changes after preflight, rerun it—the approval workflow
|
||||
matches the complete Git tree, not just the version number.
|
||||
|
||||
GitHub exposes manual workflows only after their workflow file exists on the
|
||||
default branch. For the first release that introduces this process, merge the
|
||||
release PR without creating a tag, run preflight from untagged `main`, review
|
||||
Play, and then use the approval workflow. This publishes no app artifacts before
|
||||
the Play review gate.
|
||||
|
||||
### 5. Merge to `main` and approve the public release
|
||||
|
||||
After Play preflight is acceptable, merge the release PR from `dev` to `main`
|
||||
with `--no-ff`. The merge commit may differ from the preflight commit, but its
|
||||
tree must be identical. If the merge changes the tree, rerun private preflight
|
||||
from untagged `main`:
|
||||
|
||||
```bash
|
||||
# From a clean dev checkout:
|
||||
@@ -501,17 +529,22 @@ git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Run Play Preflight — Android from dev and review Play's results.
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag android-v0.6.2
|
||||
git push origin android-v0.6.2
|
||||
```
|
||||
|
||||
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
|
||||
`main`, enter the version, and check the Play-results confirmation box. The
|
||||
approval workflow verifies that `main` has the exact preflighted tree and creates
|
||||
the `android-v<version>` tag. Manual stable tags are still guarded by the same
|
||||
preflight proof in the tag workflow.
|
||||
|
||||
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
||||
artifacts, changes the existing Play Production draft to `completed` (submitting
|
||||
it for review), and only after Play accepts that operation creates the public
|
||||
GitHub Release with the sideload APK. A missing preflight, changed release tree,
|
||||
missing Play credential, or Play submission failure prevents public GitHub
|
||||
publication.
|
||||
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin package itself is also being released.
|
||||
@@ -553,21 +586,24 @@ touches more than one release surface. The workflow also runs plugin tests,
|
||||
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
|
||||
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
### 6. Play review and publishing behavior
|
||||
|
||||
> **If `PLAY_SERVICE_ACCOUNT_JSON` is configured as a repo secret, this step is
|
||||
> automated for stable tags.** The release workflow runs
|
||||
> `publishGooglePlayReleaseBundle --track=production` and the build appears as a
|
||||
> Production **draft** — skip to the Play Console, confirm the draft, and click
|
||||
> **Start rollout**. The manual path below is the fallback when the secret is
|
||||
> unset (or for staging on a non-production track).
|
||||
> **Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`.** Preflight
|
||||
> uploads the Production draft; approval promotes that same version code to
|
||||
> `completed`. Stable releases no longer fall back to publishing GitHub first
|
||||
> when Play credentials or submission are unavailable.
|
||||
>
|
||||
> This automated tag path is intentionally bundle-only. It uploads the
|
||||
> This automated path is intentionally bundle-only. It uploads the
|
||||
> `googlePlayRelease` AAB and release-scoped "What's new" notes, but it does
|
||||
> not republish static listing assets such as screenshots, title, description,
|
||||
> icon, or feature graphic. Use the Play Store Listing workflow when those
|
||||
> assets change.
|
||||
|
||||
If Play Console **Managed publishing** is enabled, an approved submission remains
|
||||
under **Changes ready to publish** until a Play Console user publishes it. If it
|
||||
is disabled, the production submission may become available after Google review.
|
||||
Either behavior begins only after the public-release approval described above.
|
||||
|
||||
**Pick the track first.** The AAB is track-agnostic — the same
|
||||
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
|
||||
not habit:
|
||||
@@ -614,7 +650,7 @@ To promote an existing release between tracks without rebuilding:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
|
||||
```
|
||||
|
||||
### 6. Tracks (a menu, not a mandatory ladder)
|
||||
### 7. Tracks (a menu, not a mandatory ladder)
|
||||
|
||||
The org account is exempt from the 14-day / 12-tester closed-testing rule, so a
|
||||
stable GA publishes **straight to Production** — there is no required promotion
|
||||
@@ -633,7 +669,7 @@ the Play Console UI or:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
```
|
||||
|
||||
### 7. After release
|
||||
### 8. After release
|
||||
|
||||
- Verify the GitHub Release has APK, AAB, and `SHA256SUMS.txt` attached.
|
||||
- Confirm the release body includes the **Download** section that tells
|
||||
|
||||
+19
-18
@@ -1,18 +1,20 @@
|
||||
# Hermes-Relay-Android v1.4.1
|
||||
# Hermes-Relay-Android v1.4.3
|
||||
|
||||
**Release Date:** July 11, 2026
|
||||
|
||||
**Since v1.4.0:** Chat now keeps durable work visible and recoverable. Follow background terminal work from the conversation, receive its completion automatically, and reopen the app into the same in-flight answer with its visible progress intact. Voice adds practical spoken controls and mode presets, while streaming chat gets smoother Markdown, table, and image handling.
|
||||
**Since v1.4.2:** The app language can now be changed directly from Settings → Appearance. Choose System default, English, or Simplified Chinese without leaving Hermes-Relay.
|
||||
|
||||
v1.4.1 is recommended for everyone. Realtime Agent delivery hardening and voice presets pair with relay plugin v1.4.1; Standard chat and Vanilla Hermes voice remain compatible with unmodified upstream Hermes.
|
||||
v1.4.3 is recommended for multilingual users. The picker stays synchronized with Android's per-app language setting and persists the choice on Android 12 and lower. This Android-only patch does not require a relay plugin update.
|
||||
|
||||
Release packaging now also rejects Java 21 list endpoint calls that are unavailable before Android API 35, covering both first-party Kotlin and bundled dependency bytecode.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
**Installing on your phone?** Download hermes-relay-1.4.1-sideload-release.apk and tap it — that's the direct-install build with the full feature set (installs as com.axiomlabs.hermesrelay.sideload). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
**Installing on your phone?** Download hermes-relay-1.4.3-sideload-release.apk and tap it — that's the direct-install build with the full feature set (installs as com.axiomlabs.hermesrelay.sideload). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The other file, hermes-relay-1.4.1-googlePlay-release.aab, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
|
||||
The other file, hermes-relay-1.4.3-googlePlay-release.aab, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
|
||||
|
||||
Verify integrity with SHA256SUMS.txt from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
@@ -20,27 +22,26 @@ Verify integrity with SHA256SUMS.txt from the same release. See the [Sideload gu
|
||||
|
||||
## Highlights
|
||||
|
||||
### Chat that keeps up
|
||||
### Change language without leaving the app
|
||||
|
||||
- **See background work where it belongs.** Standard Chat surfaces active and recent background processes in a compact strip and expandable sheet with elapsed time, output, a targeted Stop action, and local Dismiss.
|
||||
- **Get the completion without asking again.** When Hermes finishes detached work, its follow-up answer appears in the originating conversation automatically. The server's internal completion marker stays in history but is shown as a compact process notice.
|
||||
- **Come back to the same answer.** Closing and reopening the app restores the partial reply, live reasoning, lifecycle status, tool and subagent states, background-task state, and any pending approval or clarification. The app reattaches when the server still has a live turn, otherwise it reconciles the finished transcript without repeating your prompt.
|
||||
- **Pick the app language in Appearance.** System default, English, and 简体中文 are available in a wrapping, accessible selector.
|
||||
- **Stay synchronized with Android.** A selection made in Hermes-Relay appears in Android's per-app language setting, and a system-side selection is reflected in the app.
|
||||
- **Keep older devices supported.** AppCompat stores and restores the language choice on Android 12 and lower.
|
||||
|
||||
### Voice you can direct
|
||||
### Built for the existing localization system
|
||||
|
||||
- **Use natural spoken controls.** Pause or resume listening, stop speech, cancel background work, repeat a settled result, or start a new Standard voice chat.
|
||||
- **Choose an interaction preset.** Hands-free, Low latency, Careful tools, and Quiet presets adjust existing voice and long-task behavior without changing your voice identity or routing.
|
||||
- **Keep delivered answers authoritative.** Realtime delivery is generation-safe and uses one relay-TTS fallback if the provider cannot deliver a completed Hermes result.
|
||||
- The picker reads the same English and Simplified Chinese catalogs introduced in 1.4.2.
|
||||
- Future locales use the same explicit registry: add the catalog, locale-configuration entry, picker label, and tag-resolution test.
|
||||
|
||||
### Clearer conversations
|
||||
### Safer Android compatibility checks
|
||||
|
||||
- **Browse images together.** Adjacent images form a compact gallery that opens at the image you selected.
|
||||
- **Read while the reply streams.** Markdown settles into its final styling as text arrives, wide tables stay usable, and motion-sensitive indicators respect system accessibility settings.
|
||||
- Kotlin source checks reject `removeFirst()` and `removeLast()` list calls in favor of explicit indexed removal.
|
||||
- Release CI scans the final minified APK DEX, catching incompatible calls introduced by transitive dependencies or build-tool changes.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
- App version: **1.4.1** (versionCode **23**).
|
||||
- Realtime Agent improvements pair with relay plugin **1.4.1**.
|
||||
- App version: **1.4.3** (versionCode **25**).
|
||||
- No relay plugin update is required for localization support.
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
+2
-2
@@ -101,7 +101,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**What the middleware can do (near-term, ships via install.sh).** New aiohttp middleware in `hermes_relay_bootstrap/_command_middleware.py`, installed at the same `_PatchedApplication.__setitem__` hook as the current route injection so it lands before `AppRunner.setup()` freezes the app. Filters by `request.path in ("/v1/runs", "/v1/chat/completions")` — zero-cost fast path for everything else. On chat paths: parses the body, lazy-imports `GATEWAY_KNOWN_COMMANDS` + `resolve_command()` + `gateway_help_lines()` from `hermes_cli.commands`, and splits on command type:
|
||||
- **Stateless commands** (`/help`, `/commands`, and any others the upstream Option B PR ends up supporting without router state) — actually dispatch, emit a synthetic SSE stream matching the runs handler's existing event shape so the Android client at `HermesApiClient.kt:655-715` renders it as a normal assistant turn.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (post-PR-#8556) or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
|
||||
**On no match** (unknown command, cli-only command, or plain text): falls through to `handler(request)` unchanged. Fork-detects the same way the existing injection does — if the upstream preprocessor PR lands first, the middleware no-ops.
|
||||
|
||||
@@ -109,7 +109,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**Files.** New `hermes_relay_bootstrap/_command_middleware.py` (~150 LOC), one-line append in `_patch.py` inside `_maybe_register_routes`, stdlib `unittest` coverage in `plugin/tests/test_bootstrap_command_middleware.py` mirroring the existing `test_bootstrap_patch.py` harness. Mirrors the upstream Option B PR exactly so the two can be reviewed side-by-side.
|
||||
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (post PR #8556).** Once PR #8556 merges and `/api/sessions/{id}/chat/stream` ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`. Blocked on #8556 landing.
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (unblocked by PR #33134).** Since `/api/sessions/{id}/chat/stream` now ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless and statefulness lives on `/api/sessions/*`.
|
||||
|
||||
## Future — v0.5+
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ dependencies {
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Core
|
||||
implementation(libs.core.ktx)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
info@axiom-labs.dev
|
||||
@@ -1,12 +1,9 @@
|
||||
v1.4.1 - Chat that keeps up
|
||||
v1.4.3 - Language picker
|
||||
|
||||
Chat
|
||||
* Follow background work from a live process strip; its result appears automatically in the same conversation.
|
||||
* Reopen while an answer runs: partial text, thinking, tool progress, and approvals return.
|
||||
Language
|
||||
* Open Settings > Appearance to choose System default, English, or Simplified Chinese.
|
||||
* Your choice stays synchronized with Android's per-app language setting.
|
||||
* Android 12 and lower now persist the same in-app selection.
|
||||
|
||||
Voice
|
||||
* Speak commands to pause, resume, cancel, repeat a result, or start Standard voice chat.
|
||||
* Pick Hands-free, Low latency, Careful tools, or Quiet presets.
|
||||
|
||||
Polish
|
||||
* Multi-image galleries plus smoother streaming Markdown and long tables.
|
||||
Compatibility
|
||||
* Release builds now reject collection APIs that can crash on older Android versions.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
v1.4.3 - 应用内语言选择
|
||||
|
||||
语言
|
||||
* 在“设置 > 外观”中选择跟随系统、English 或简体中文。
|
||||
* 选择会与 Android 的应用语言设置保持同步。
|
||||
* Android 12 及更早版本也会保存应用内选择。
|
||||
|
||||
兼容性
|
||||
* 发布构建现在会拦截可能导致旧版 Android 崩溃的集合 API。
|
||||
@@ -29,6 +29,7 @@
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.HermesRelay">
|
||||
@@ -39,7 +40,7 @@
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
tools:ignore="LockedOrientationActivity"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:configChanges="uiMode|fontScale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
@@ -48,6 +49,17 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- AppCompat persists in-app language choices on Android 12 and lower.
|
||||
Android 13+ stores the same selection in the platform LocaleManager. -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.4.3",
|
||||
"title": "Language switching inside the app",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Language at your fingertips",
|
||||
"bullets": [
|
||||
"Choose System default, English, or Simplified Chinese from Settings → Appearance without leaving Hermes-Relay.",
|
||||
"The picker stays synchronized with Android's per-app language setting and persists the choice on Android 12 and lower.",
|
||||
"Release builds reject collection APIs that can crash on Android versions before API 35."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.2",
|
||||
"title": "Simplified Chinese and scalable localization",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Simplified Chinese throughout the app",
|
||||
"bullets": [
|
||||
"Use onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, and accessibility labels in Simplified Chinese across both product flavors.",
|
||||
"Switch between English and Simplified Chinese through Android's per-app language settings on supported versions, or follow the device language elsewhere."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Localization built to grow",
|
||||
"bullets": [
|
||||
"Automated catalog checks protect resource, plural, and format-argument parity, while contributor docs and translated entry points make another language easier to add safely.",
|
||||
"Connection scan and queued-message counts now use locale-aware Android plurals."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.1",
|
||||
"title": "Chat that keeps up",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
v1.4.1 - Chat that keeps up
|
||||
v1.4.3 - Language picker
|
||||
|
||||
Chat
|
||||
* Follow background work from a live process strip; its result appears automatically in the same conversation.
|
||||
* Reopen while an answer runs: partial text, thinking, tool progress, and approvals return.
|
||||
Language
|
||||
* Open Settings > Appearance to choose System default, English, or Simplified Chinese.
|
||||
* Your choice stays synchronized with Android's per-app language setting.
|
||||
* Android 12 and lower now persist the same in-app selection.
|
||||
|
||||
Voice
|
||||
* Speak commands to pause, resume, cancel, repeat a result, or start Standard voice chat.
|
||||
* Pick Hands-free, Low latency, Careful tools, or Quiet presets.
|
||||
|
||||
Polish
|
||||
* Multi-image galleries plus smoother streaming Markdown and long tables.
|
||||
Compatibility
|
||||
* Release builds now reject collection APIs that can crash on older Android versions.
|
||||
|
||||
@@ -8,13 +8,13 @@ import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
@@ -24,7 +24,7 @@ import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val connectionViewModel: ConnectionViewModel by viewModels()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
@@ -25,7 +26,7 @@ import kotlin.math.sqrt
|
||||
* writes them directly to an AudioTrack so the Android Studio dev build can
|
||||
* hear provider output without waiting for an encoded file.
|
||||
*/
|
||||
class RealtimePcmPlayer(context: Context? = null) {
|
||||
class RealtimePcmPlayer(private val context: Context? = null) {
|
||||
private val trackLock = Any()
|
||||
private val writeLock = Any()
|
||||
private val audioManager =
|
||||
@@ -449,7 +450,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Realtime audio started",
|
||||
title = context?.getString(R.string.audio_diag_started) ?: "Realtime audio started",
|
||||
detail = "First sample reached the speaker after ${ttfaMs}ms.",
|
||||
)
|
||||
}
|
||||
@@ -489,7 +490,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio not starting",
|
||||
title = context?.getString(R.string.audio_diag_not_starting) ?: "Realtime audio not starting",
|
||||
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
|
||||
"(${mediaVolumeSummaryLocked()}).",
|
||||
)
|
||||
@@ -587,7 +588,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio stream gap",
|
||||
title = context?.getString(R.string.audio_diag_stream_gap) ?: "Realtime audio stream gap",
|
||||
detail = reason,
|
||||
)
|
||||
}
|
||||
@@ -603,7 +604,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime voice volume muted",
|
||||
title = context?.getString(R.string.audio_diag_volume_muted) ?: "Realtime voice volume muted",
|
||||
detail = "Media volume is 0/${maxVolume ?: "?"}.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ class AutoDisableWorker(private val context: Context) {
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Bridge auto-disabled")
|
||||
.setContentText("Paused after idle — tap to re-enable in the Bridge tab.")
|
||||
.setContentTitle(context.getString(R.string.bridge_notification_auto_disabled_title))
|
||||
.setContentText(context.getString(R.string.bridge_notification_auto_disabled_body))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(
|
||||
"Hermes bridge was idle for too long, so device control has been turned off " +
|
||||
"automatically. Open the Bridge tab to turn it back on if you still need it."
|
||||
|
||||
@@ -373,8 +373,8 @@ class BridgeForegroundService : Service() {
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes agent has device control")
|
||||
.setContentText("Bridge is active — tap Disable to stop at any time.")
|
||||
.setContentTitle(getString(R.string.bridge_notification_control_title))
|
||||
.setContentText(getString(R.string.bridge_notification_control_body))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(
|
||||
"The Hermes agent can currently read the screen and perform " +
|
||||
"actions on your behalf through the accessibility service. " +
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
/** Languages exposed by the in-app picker and Android's per-app language UI. */
|
||||
enum class AppLanguage(val languageTag: String) {
|
||||
SYSTEM_DEFAULT(""),
|
||||
ENGLISH("en"),
|
||||
SIMPLIFIED_CHINESE("zh-Hans"),
|
||||
;
|
||||
|
||||
fun toLocaleList(): LocaleListCompat = if (languageTag.isEmpty()) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(languageTag)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLanguageTags(languageTags: String): AppLanguage {
|
||||
val primaryTag = languageTags
|
||||
.substringBefore(',')
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: return SYSTEM_DEFAULT
|
||||
val locale = Locale.forLanguageTag(primaryTag)
|
||||
|
||||
return when (locale.language.lowercase(Locale.ROOT)) {
|
||||
"en" -> ENGLISH
|
||||
"zh" -> {
|
||||
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
|
||||
locale.script.isEmpty() ||
|
||||
locale.country.equals("CN", ignoreCase = true) ||
|
||||
locale.country.equals("SG", ignoreCase = true)
|
||||
if (simplified) SIMPLIFIED_CHINESE else SYSTEM_DEFAULT
|
||||
}
|
||||
else -> SYSTEM_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
@@ -306,7 +307,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Insecure relay mode enabled",
|
||||
title = context?.getString(R.string.conn_diag_insecure_mode) ?: "Insecure relay mode enabled",
|
||||
detail = "ws:// connections are allowed",
|
||||
)
|
||||
}
|
||||
@@ -332,7 +333,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay route selected",
|
||||
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
|
||||
endpointRole = resolved.role,
|
||||
url = resolved.relay.url,
|
||||
)
|
||||
@@ -342,7 +343,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Using configured relay URL",
|
||||
title = context?.getString(R.string.conn_diag_using_configured_url) ?: "Using configured relay URL",
|
||||
detail = "No resolver winner",
|
||||
url = url,
|
||||
)
|
||||
@@ -368,7 +369,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket blocked",
|
||||
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
|
||||
detail = "ws:// is disabled",
|
||||
url = url,
|
||||
)
|
||||
@@ -379,7 +380,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket URL invalid",
|
||||
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
|
||||
detail = "URL must start with ws:// or wss://",
|
||||
url = url,
|
||||
)
|
||||
@@ -408,14 +409,14 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Opening insecure relay socket",
|
||||
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
} else {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Opening relay socket",
|
||||
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
}
|
||||
@@ -757,7 +758,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket disconnect requested",
|
||||
title = context?.getString(R.string.conn_diag_disconnect_requested) ?: "Relay socket disconnect requested",
|
||||
url = serverUrl,
|
||||
)
|
||||
webSocket?.close(1000, "Client disconnect")
|
||||
@@ -884,7 +885,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket connected",
|
||||
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
|
||||
url = url,
|
||||
)
|
||||
|
||||
@@ -944,7 +945,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay socket closed",
|
||||
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
|
||||
detail = "code=$code reason=$reason",
|
||||
url = url,
|
||||
)
|
||||
@@ -963,7 +964,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket failed",
|
||||
title = context?.getString(R.string.conn_diag_failed) ?: "Relay socket failed",
|
||||
detail = listOfNotNull(
|
||||
t.javaClass.simpleName,
|
||||
t.message,
|
||||
@@ -1012,7 +1013,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Session,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect skipped",
|
||||
title = context?.getString(R.string.conn_diag_reconnect_skipped) ?: "Relay reconnect skipped",
|
||||
detail = "No paired session or pending pair code",
|
||||
url = serverUrl,
|
||||
)
|
||||
@@ -1036,7 +1037,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect delayed",
|
||||
title = context?.getString(R.string.conn_diag_reconnect_delayed) ?: "Relay reconnect delayed",
|
||||
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
@@ -1049,7 +1050,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect slow-polling",
|
||||
title = context?.getString(R.string.conn_diag_reconnect_slow_poll) ?: "Relay reconnect slow-polling",
|
||||
detail = "Server unreachable for a while; retrying every ${SLOW_POLL_BACKOFF_MS / 1000}s until it recovers (a network change reconnects immediately)",
|
||||
url = url,
|
||||
)
|
||||
@@ -1061,7 +1062,7 @@ class ConnectionManager(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay reconnect scheduled",
|
||||
title = context?.getString(R.string.conn_diag_reconnect_scheduled) ?: "Relay reconnect scheduled",
|
||||
detail = "Retrying in ${ms / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
@@ -48,6 +50,9 @@ class RelayHttpClient(
|
||||
* paired). Lets [mediaUrlConfigured] check fetch-readiness without
|
||||
* suspending; mirrors what [sessionTokenProvider] resolves. */
|
||||
private val pairedTokenSnapshot: () -> String? = { null },
|
||||
/** Application context for localized string resources. Nullable for
|
||||
* backwards-compat with call sites that don't need localization. */
|
||||
private val context: Context? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -904,7 +909,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay URL invalid",
|
||||
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
|
||||
detail = e.message,
|
||||
url = relayUrl,
|
||||
)
|
||||
@@ -934,7 +939,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "HTTP ${response.code}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -948,7 +953,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Empty response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -965,7 +970,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Non-JSON response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -979,7 +984,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "status=${status ?: "missing"}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -993,7 +998,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Missing version field",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -1010,7 +1015,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay health ok",
|
||||
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
|
||||
detail = "version=$version clients=$clients sessions=$sessions",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -1023,7 +1028,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health timeout",
|
||||
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
|
||||
detail = "No HTTP response in 3s",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -1034,7 +1039,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay connection refused",
|
||||
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
|
||||
detail = e.message,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -1045,7 +1050,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = e.message ?: "Network error",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
@@ -1056,7 +1061,7 @@ class RelayHttpClient(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay health failed",
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = e.message ?: e.javaClass.simpleName,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.network.relay
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
@@ -542,7 +543,7 @@ class RelayVoiceClient(
|
||||
getRealtimeProviderOptionsAt(
|
||||
providerId = providerId,
|
||||
pathPrefix = "/voice/realtime-agent/providers",
|
||||
label = "Realtime agent provider options",
|
||||
label = context.getString(R.string.voice_diag_agent_provider_options),
|
||||
)
|
||||
|
||||
suspend fun validateRealtimeAgentProvider(
|
||||
@@ -557,7 +558,7 @@ class RelayVoiceClient(
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
pathPrefix = "/voice/realtime-agent/providers",
|
||||
label = "Realtime agent provider validation",
|
||||
label = context.getString(R.string.voice_diag_agent_provider_validation),
|
||||
)
|
||||
|
||||
suspend fun updateRealtimeAgentConfig(
|
||||
@@ -574,7 +575,7 @@ class RelayVoiceClient(
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
path = "/voice/realtime-agent/config",
|
||||
label = "Realtime agent config update",
|
||||
label = context.getString(R.string.voice_diag_agent_config_update),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -879,7 +880,7 @@ class RelayVoiceClient(
|
||||
if (resumeAttempted.get()) {
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Voice handoff failed",
|
||||
label = context.getString(R.string.voice_diag_handoff_failed),
|
||||
detail = message,
|
||||
active = false,
|
||||
)
|
||||
@@ -904,7 +905,7 @@ class RelayVoiceClient(
|
||||
if (resume) {
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Trying voice route",
|
||||
label = context.getString(R.string.voice_diag_trying_route),
|
||||
route = routeLabel(currentWsBase),
|
||||
active = true,
|
||||
)
|
||||
@@ -930,7 +931,7 @@ class RelayVoiceClient(
|
||||
)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Resume sent",
|
||||
label = context.getString(R.string.voice_diag_resume_sent),
|
||||
route = routeLabel(webSocket.request().url.toString()),
|
||||
active = true,
|
||||
)
|
||||
@@ -1016,7 +1017,7 @@ class RelayVoiceClient(
|
||||
requestRouteProbeOnce("Voice output", t.message, routeProbeRequested)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Waiting for route",
|
||||
label = context.getString(R.string.voice_diag_waiting_for_route),
|
||||
detail = t.message,
|
||||
route = routeLabel(webSocket.request().url.toString()),
|
||||
active = true,
|
||||
@@ -1030,7 +1031,7 @@ class RelayVoiceClient(
|
||||
requestRouteProbeOnce("Voice output", t.message, routeProbeRequested)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Connection changed",
|
||||
label = context.getString(R.string.voice_diag_connection_changed),
|
||||
detail = t.message,
|
||||
route = routeLabel(webSocket.request().url.toString()),
|
||||
active = true,
|
||||
@@ -1058,7 +1059,7 @@ class RelayVoiceClient(
|
||||
requestRouteProbeOnce("Voice output", "Closed $code $reason", routeProbeRequested)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Waiting for route",
|
||||
label = context.getString(R.string.voice_diag_waiting_for_route),
|
||||
detail = "Closed $code $reason",
|
||||
route = routeLabel(webSocket.request().url.toString()),
|
||||
active = true,
|
||||
@@ -1076,7 +1077,7 @@ class RelayVoiceClient(
|
||||
requestRouteProbeOnce("Voice output", "Closed $code $reason", routeProbeRequested)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Connection changed",
|
||||
label = context.getString(R.string.voice_diag_connection_changed),
|
||||
detail = "Closed $code $reason",
|
||||
route = routeLabel(webSocket.request().url.toString()),
|
||||
active = true,
|
||||
@@ -2365,7 +2366,7 @@ class RelayVoiceClient(
|
||||
)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Route changed",
|
||||
label = context.getString(R.string.voice_diag_route_changed),
|
||||
previousRoute = routeLabel(previousWsBase),
|
||||
nextRoute = routeLabel(nextWsBase),
|
||||
route = routeLabel(nextWsBase),
|
||||
@@ -2830,7 +2831,7 @@ class RelayVoiceClient(
|
||||
when (event.type) {
|
||||
"voice.session.resumed" -> onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Voice reconnected",
|
||||
label = context.getString(R.string.voice_diag_reconnected),
|
||||
detail = surface,
|
||||
route = route,
|
||||
active = false,
|
||||
@@ -2840,7 +2841,7 @@ class RelayVoiceClient(
|
||||
)
|
||||
"voice.replay.started" -> onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Replaying missed audio",
|
||||
label = context.getString(R.string.voice_diag_replaying_audio),
|
||||
route = route,
|
||||
active = true,
|
||||
transitionRevision = transitionRevision,
|
||||
@@ -2848,7 +2849,7 @@ class RelayVoiceClient(
|
||||
)
|
||||
"voice.replay.done" -> onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Caught up",
|
||||
label = context.getString(R.string.voice_diag_caught_up),
|
||||
detail = surface,
|
||||
route = route,
|
||||
active = false,
|
||||
@@ -2858,7 +2859,7 @@ class RelayVoiceClient(
|
||||
)
|
||||
"voice.session.resume_failed" -> onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
label = "Resume rejected",
|
||||
label = context.getString(R.string.voice_diag_resume_rejected),
|
||||
detail = event.message,
|
||||
route = route,
|
||||
active = false,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
@@ -85,6 +87,12 @@ class EndpointResolver(
|
||||
* tests feed a mutable clock to exercise the 30-second TTL.
|
||||
*/
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
/**
|
||||
* Application context for localized string resources. When null the
|
||||
* resolver falls back to hardcoded English strings — this is the
|
||||
* expected path for plain JVM tests.
|
||||
*/
|
||||
private val context: Context? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -190,7 +198,7 @@ class EndpointResolver(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Endpoint selected",
|
||||
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
|
||||
detail = "priority=$priority",
|
||||
endpointRole = winner.role,
|
||||
url = winner.relay.url,
|
||||
@@ -203,7 +211,7 @@ class EndpointResolver(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "No reachable endpoint",
|
||||
title = context?.getString(R.string.endpoint_diag_no_reachable) ?: "No reachable endpoint",
|
||||
detail = "${candidates.size} configured route(s) failed health probes",
|
||||
)
|
||||
return null
|
||||
@@ -288,7 +296,7 @@ class EndpointResolver(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Endpoint probe invalid",
|
||||
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
|
||||
detail = "Invalid API URL",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
@@ -312,10 +320,15 @@ class EndpointResolver(
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
fastClient.newCall(request).execute().use { resp ->
|
||||
val ok = resp.isSuccessful
|
||||
val probeTitle = if (ok) {
|
||||
context?.getString(R.string.endpoint_diag_probe_ok) ?: "Endpoint probe ok"
|
||||
} else {
|
||||
context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed"
|
||||
}
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
|
||||
title = if (ok) "Endpoint probe ok" else "Endpoint probe failed",
|
||||
title = probeTitle,
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
@@ -332,7 +345,7 @@ class EndpointResolver(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
@@ -345,7 +358,7 @@ class EndpointResolver(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
@@ -359,7 +372,7 @@ class EndpointResolver(
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe failed",
|
||||
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
|
||||
+2
-2
@@ -150,8 +150,8 @@ class GatewayKeepAliveService : Service() {
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes connection active")
|
||||
.setContentText("Keeping your connection to Hermes open in the background.")
|
||||
.setContentTitle(getString(R.string.gateway_keepalive_title))
|
||||
.setContentText(getString(R.string.gateway_keepalive_body))
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
@@ -235,7 +235,7 @@ object NotificationTriggerPromptNotifier {
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(context, notificationId(entry), tapIntent, pendingFlags)
|
||||
|
||||
val title = "Ask Hermes about this?"
|
||||
val title = context.getString(R.string.notification_trigger_prompt_title)
|
||||
val source = entry.title?.takeIf { it.isNotBlank() } ?: entry.packageName
|
||||
val body = entry.text?.takeIf { it.isNotBlank() }
|
||||
?: "Rule matched: ${rule.summary()}"
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
@@ -67,6 +68,7 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.CrashReportGate
|
||||
import com.hermesandroid.relay.ui.components.DemoModeBanner
|
||||
import com.hermesandroid.relay.ui.components.DemoUnavailableContent
|
||||
@@ -1066,11 +1068,19 @@ fun RelayApp() {
|
||||
bridgePrimaryReturnLabel = null
|
||||
}
|
||||
|
||||
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let { "Return to $it" }
|
||||
val bridgeReturnLabelResId = when (bridgePrimaryReturnLabel) {
|
||||
"Chat" -> R.string.bridge_return_chat_label
|
||||
"Manage" -> R.string.bridge_return_manage_label
|
||||
else -> R.string.bridge_return_default_label
|
||||
}
|
||||
val bridgeReturnDisplayLabel = stringResource(bridgeReturnLabelResId)
|
||||
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let {
|
||||
stringResource(R.string.bridge_return_title_format, bridgeReturnDisplayLabel)
|
||||
}
|
||||
val bridgeReturnSubtitle = when (bridgePrimaryReturnLabel) {
|
||||
"Chat" -> "Back to conversation"
|
||||
"Manage" -> "Back to management"
|
||||
else -> "Back to previous tab"
|
||||
"Chat" -> stringResource(R.string.bridge_return_chat_subtitle)
|
||||
"Manage" -> stringResource(R.string.bridge_return_manage_subtitle)
|
||||
else -> stringResource(R.string.bridge_return_default_subtitle)
|
||||
}
|
||||
val bridgeReturnAction: (() -> Unit)? = bridgePrimaryReturnRoute?.let { route ->
|
||||
{
|
||||
@@ -1343,6 +1353,7 @@ fun RelayApp() {
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
cacheDir = hydrateContext.cacheDir,
|
||||
context = hydrateContext,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1350,13 +1361,17 @@ fun RelayApp() {
|
||||
// so voice/chat/settings screens can call showHumanError from their
|
||||
// error-collector LaunchedEffects without threading state downwards.
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val profilesUpdatedLabel = stringResource(R.string.relay_app_profiles_updated)
|
||||
val reconnectingRelayLabel = stringResource(R.string.relay_app_reconnecting)
|
||||
val renameFailedLabel = stringResource(R.string.relay_app_rename_failed)
|
||||
val revokeOnlyActiveLabel = stringResource(R.string.relay_app_revoke_only_active)
|
||||
|
||||
// Relay-pushed `profiles.updated` announcements. AuthManager
|
||||
// filters out idempotent pushes (same names + same count), so
|
||||
// this only fires when the profile list actually changed.
|
||||
LaunchedEffect(connectionViewModel) {
|
||||
connectionViewModel.profilesUpdatedEvents.collect {
|
||||
UiMessageBus.success("Profiles updated")
|
||||
UiMessageBus.success(profilesUpdatedLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1576,7 +1591,7 @@ fun RelayApp() {
|
||||
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
|
||||
val routeLabel = activeEndpoint?.displayLabel()
|
||||
?: activeConnection?.label
|
||||
?: "no route"
|
||||
?: stringResource(R.string.status_no_route)
|
||||
val transportStatus = resolveChatTransportStatus(
|
||||
streamingEndpoint = streamingEndpoint,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
@@ -1587,7 +1602,8 @@ fun RelayApp() {
|
||||
} else {
|
||||
routeLabel
|
||||
}
|
||||
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() } ?: "default"
|
||||
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.status_profile_default)
|
||||
val displayProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
@@ -1595,11 +1611,12 @@ fun RelayApp() {
|
||||
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
|
||||
?: AgentDisplay.displayModelName(displayProfile?.model)
|
||||
?: AgentDisplay.displayModelName(serverModelName)
|
||||
?: "model pending"
|
||||
?: stringResource(R.string.status_model_pending)
|
||||
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
|
||||
"safety: ${if (unattendedEnabled) "unattended" else "on"}"
|
||||
if (unattendedEnabled) stringResource(R.string.status_safety_unattended)
|
||||
else stringResource(R.string.status_safety_on)
|
||||
} else {
|
||||
"profile: $profileLabel"
|
||||
stringResource(R.string.status_profile_format, profileLabel)
|
||||
}
|
||||
val openConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route) {
|
||||
@@ -1714,6 +1731,8 @@ fun RelayApp() {
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
|
||||
val screenChatLabel = stringResource(R.string.screen_chat_label)
|
||||
|
||||
ChatScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -1754,7 +1773,7 @@ fun RelayApp() {
|
||||
onNavigateToBridge = {
|
||||
rememberBridgeReturn(
|
||||
route = Screen.Chat.route(openAgentSheet = false),
|
||||
label = "Chat",
|
||||
label = screenChatLabel,
|
||||
)
|
||||
navController.navigate(Screen.Bridge.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
@@ -1792,10 +1811,11 @@ fun RelayApp() {
|
||||
// so show a friendly demo empty state instead of
|
||||
// attempting a sign-in / fetch.
|
||||
DemoUnavailableContent(
|
||||
feature = "Manage",
|
||||
feature = stringResource(R.string.demo_feature_manage),
|
||||
onConnect = exitDemoToConnect,
|
||||
)
|
||||
} else {
|
||||
val screenManageLabel = stringResource(R.string.screen_manage_label)
|
||||
DashboardManagementScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToConnections = {
|
||||
@@ -1811,7 +1831,7 @@ fun RelayApp() {
|
||||
onNavigateToBridge = {
|
||||
rememberBridgeReturn(
|
||||
route = Screen.Manage.route,
|
||||
label = "Manage",
|
||||
label = screenManageLabel,
|
||||
)
|
||||
navController.navigate(Screen.Bridge.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
@@ -1843,8 +1863,8 @@ fun RelayApp() {
|
||||
)
|
||||
} else {
|
||||
PowerFeatureGateScreen(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
title = stringResource(R.string.power_gate_terminal_title),
|
||||
summary = stringResource(R.string.power_gate_terminal_summary),
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
@@ -1856,8 +1876,8 @@ fun RelayApp() {
|
||||
composable(Screen.Bridge.route) {
|
||||
if (coldStartAuthState !is AuthState.Paired) {
|
||||
PowerFeatureGateScreen(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
title = stringResource(R.string.power_gate_bridge_title),
|
||||
summary = stringResource(R.string.power_gate_bridge_summary),
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
@@ -1870,7 +1890,9 @@ fun RelayApp() {
|
||||
connectionViewModel = connectionViewModel,
|
||||
returnTitle = bridgeReturnTitle,
|
||||
returnSubtitle = bridgeReturnSubtitle,
|
||||
returnLabel = bridgePrimaryReturnLabel ?: "Back",
|
||||
returnLabel = bridgePrimaryReturnLabel?.let {
|
||||
stringResource(bridgeReturnLabelResId)
|
||||
} ?: stringResource(R.string.bridge_return_default_label),
|
||||
onReturn = bridgeReturnAction,
|
||||
onNavigateToBridgeSafety = {
|
||||
navController.navigate(Screen.BridgeSafetySettings.route)
|
||||
@@ -1904,7 +1926,9 @@ fun RelayApp() {
|
||||
connectionViewModel = connectionViewModel,
|
||||
returnTitle = bridgeReturnTitle,
|
||||
returnSubtitle = bridgeReturnSubtitle,
|
||||
returnLabel = bridgePrimaryReturnLabel ?: "Back",
|
||||
returnLabel = bridgePrimaryReturnLabel?.let {
|
||||
stringResource(bridgeReturnLabelResId)
|
||||
} ?: stringResource(R.string.bridge_return_default_label),
|
||||
onReturn = bridgeReturnAction,
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
@@ -2034,7 +2058,7 @@ fun RelayApp() {
|
||||
// Voice runs through the live server (transcribe /
|
||||
// synthesize) — show the demo empty state offline.
|
||||
DemoUnavailableContent(
|
||||
feature = "Voice",
|
||||
feature = stringResource(R.string.screen_voice_label),
|
||||
onConnect = exitDemoToConnect,
|
||||
)
|
||||
} else {
|
||||
@@ -2157,8 +2181,8 @@ fun RelayApp() {
|
||||
)
|
||||
} else {
|
||||
PowerFeatureGateScreen(
|
||||
title = "Relay sessions",
|
||||
summary = "Review and revoke devices paired with this relay.",
|
||||
title = stringResource(R.string.screen_relay_sessions_label),
|
||||
summary = stringResource(R.string.power_gate_relay_sessions_summary),
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
@@ -2233,14 +2257,14 @@ fun RelayApp() {
|
||||
onBack = { navController.popBackStack() },
|
||||
onReconnect = {
|
||||
connectionViewModel.connectRelay()
|
||||
UiMessageBus.status("Reconnecting to relay…")
|
||||
UiMessageBus.status(reconnectingRelayLabel)
|
||||
},
|
||||
onRename = { id, newLabel ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.renameConnection(id, newLabel)
|
||||
.onFailure { err ->
|
||||
snackbarHostState.showSnackbar(
|
||||
err.message ?: "Rename failed",
|
||||
err.message ?: renameFailedLabel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2255,9 +2279,7 @@ fun RelayApp() {
|
||||
connectionSwitchScope.launch {
|
||||
val result = connectionViewModel.revokeConnection(id)
|
||||
if (result.isFailure) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"Only the active connection can be revoked right now",
|
||||
)
|
||||
snackbarHostState.showSnackbar(revokeOnlyActiveLabel)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2429,8 +2451,8 @@ fun RelayApp() {
|
||||
?: Screen.ProfileInspector.SECTION_CONFIG
|
||||
if (coldStartAuthState !is AuthState.Paired) {
|
||||
PowerFeatureGateScreen(
|
||||
title = "Profile Inspector",
|
||||
summary = "Inspect relay-backed profile config, SOUL, memory files, and skills.",
|
||||
title = stringResource(R.string.screen_profile_inspector_label),
|
||||
summary = stringResource(R.string.power_gate_profile_inspector_summary),
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
@@ -2559,13 +2581,13 @@ fun RelayApp() {
|
||||
.padding(bottom = 120.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Hermes-Relay",
|
||||
text = stringResource(R.string.app_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = RelayRefresh.Paper.copy(alpha = 0.92f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "agent interface",
|
||||
text = stringResource(R.string.agent_interface),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = RelayRefresh.Muted.copy(alpha = 0.72f),
|
||||
letterSpacing = 2.sp
|
||||
|
||||
+251
-153
@@ -55,12 +55,14 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
@@ -130,29 +132,29 @@ fun ActiveCardStandardStatusSection(
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
label = stringResource(R.string.active_section_api_server),
|
||||
isConnected = apiReachable,
|
||||
isProbing = apiHealth == ConnectionViewModel.HealthStatus.Probing,
|
||||
statusText = when {
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking…"
|
||||
apiReachable -> "Reachable"
|
||||
else -> "Unreachable"
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.active_section_checking)
|
||||
apiReachable -> stringResource(R.string.active_section_reachable)
|
||||
else -> stringResource(R.string.active_section_unreachable)
|
||||
},
|
||||
onClick = onOpenApiInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Dashboard",
|
||||
label = stringResource(R.string.active_section_dashboard),
|
||||
isConnected = dashboardStatus?.reachable == true && !dashboardSignInRequired,
|
||||
statusText = when {
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Not configured"
|
||||
dashboardStatus == null -> "Not checked"
|
||||
!dashboardStatus.reachable -> "Unreachable"
|
||||
dashboardSignInRequired -> "Sign-in required"
|
||||
dashboardStatus.authenticated == true -> "Signed in"
|
||||
dashboardStatus.authRequired == false -> "Available"
|
||||
else -> "Available"
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> stringResource(R.string.active_section_not_configured)
|
||||
dashboardStatus == null -> stringResource(R.string.active_section_not_checked)
|
||||
!dashboardStatus.reachable -> stringResource(R.string.active_section_unreachable)
|
||||
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
|
||||
dashboardStatus.authenticated == true -> stringResource(R.string.active_section_signed_in)
|
||||
dashboardStatus.authRequired == false -> stringResource(R.string.active_section_available)
|
||||
else -> stringResource(R.string.active_section_available)
|
||||
},
|
||||
onClick = onOpenDashboard,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -165,13 +167,13 @@ fun ActiveCardStandardStatusSection(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Dashboard controls need a sign-in for this route.",
|
||||
text = stringResource(R.string.active_section_dashboard_sign_in_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onOpenDashboard) {
|
||||
Text("Sign in")
|
||||
Text(stringResource(R.string.active_section_sign_in))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,18 +196,22 @@ fun ActiveCardRelayStatusSection(
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
|
||||
val connectedLabel = stringResource(R.string.conn_info_connected)
|
||||
|
||||
// ADR 24: relayRowState carries both the phase and the active endpoint
|
||||
// role. statusText appends " · <Role>" when the resolver has picked one.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
label = stringResource(R.string.active_section_relay),
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
statusText = relayRowState.statusText(connectedLabel = connectedLabel),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
reconnectingRelayToast,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
@@ -215,15 +221,21 @@ fun ActiveCardRelayStatusSection(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Pre-resolve strings for Session status
|
||||
val pairedLabel = stringResource(R.string.conn_info_paired)
|
||||
val pairingLabel = stringResource(R.string.conn_info_pairing)
|
||||
val unpairedLabel = stringResource(R.string.conn_info_unpaired)
|
||||
val failedReasonLabel = stringResource(R.string.active_section_failed_reason)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Session",
|
||||
label = stringResource(R.string.active_section_session),
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
is AuthState.Paired -> pairedLabel
|
||||
is AuthState.Pairing -> pairingLabel
|
||||
is AuthState.Unpaired -> unpairedLabel
|
||||
is AuthState.Failed -> failedReasonLabel.format((authState as AuthState.Failed).reason)
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -261,38 +273,39 @@ fun ActiveCardFeaturesSection(
|
||||
activeConnection?.routeCandidates.orEmpty().any { it.hasSecureProxy() }
|
||||
|
||||
val apiValue = when {
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
|
||||
apiReachable -> "Ready"
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> "Missing"
|
||||
else -> "Offline"
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.active_section_checking)
|
||||
apiReachable -> stringResource(R.string.active_section_ready)
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> stringResource(R.string.active_section_missing)
|
||||
else -> stringResource(R.string.active_section_offline)
|
||||
}
|
||||
val apiTone = when (apiValue) {
|
||||
"Ready" -> CapabilityTone.Good
|
||||
"Offline", "Missing" -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Neutral
|
||||
val apiTone = when {
|
||||
apiReachable -> CapabilityTone.Good
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> CapabilityTone.Neutral
|
||||
else -> CapabilityTone.Warning
|
||||
}
|
||||
|
||||
val dashboardValue = when {
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Missing"
|
||||
dashboardStatus == null -> "Unchecked"
|
||||
!dashboardStatus.reachable -> "Offline"
|
||||
dashboardSignInRequired -> "Sign in"
|
||||
dashboardStatus.authenticated == true -> "Signed in"
|
||||
else -> "Available"
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> stringResource(R.string.active_section_missing)
|
||||
dashboardStatus == null -> stringResource(R.string.active_section_unchecked)
|
||||
!dashboardStatus.reachable -> stringResource(R.string.active_section_offline)
|
||||
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in)
|
||||
dashboardStatus.authenticated == true -> stringResource(R.string.active_section_signed_in)
|
||||
else -> stringResource(R.string.active_section_available)
|
||||
}
|
||||
val dashboardTone = when (dashboardValue) {
|
||||
"Signed in", "Available" -> CapabilityTone.Good
|
||||
"Sign in" -> CapabilityTone.Info
|
||||
"Offline", "Missing" -> CapabilityTone.Warning
|
||||
val dashboardTone = when {
|
||||
dashboardStatus?.authenticated == true -> CapabilityTone.Good
|
||||
dashboardStatus?.reachable == true && !dashboardSignInRequired -> CapabilityTone.Good
|
||||
dashboardSignInRequired -> CapabilityTone.Info
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() || dashboardStatus?.reachable != true -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Neutral
|
||||
}
|
||||
|
||||
val voiceValue = when (standardVoiceAvailability) {
|
||||
StandardVoiceAvailability.Ready -> "Ready"
|
||||
StandardVoiceAvailability.SignInRequired -> "Sign in"
|
||||
StandardVoiceAvailability.Unsupported -> "Unsupported"
|
||||
StandardVoiceAvailability.Unreachable -> "Offline"
|
||||
StandardVoiceAvailability.Unknown -> "Checking"
|
||||
StandardVoiceAvailability.Ready -> stringResource(R.string.active_section_ready)
|
||||
StandardVoiceAvailability.SignInRequired -> stringResource(R.string.active_section_sign_in)
|
||||
StandardVoiceAvailability.Unsupported -> stringResource(R.string.active_section_unsupported)
|
||||
StandardVoiceAvailability.Unreachable -> stringResource(R.string.active_section_offline)
|
||||
StandardVoiceAvailability.Unknown -> stringResource(R.string.active_section_checking)
|
||||
}
|
||||
val voiceTone = when (standardVoiceAvailability) {
|
||||
StandardVoiceAvailability.Ready -> CapabilityTone.Good
|
||||
@@ -303,11 +316,11 @@ fun ActiveCardFeaturesSection(
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayEnabled -> "Disabled"
|
||||
!relayConfigured -> "Optional"
|
||||
relayReady -> "Ready"
|
||||
relayUiState == RelayUiState.Stale -> "Reconnect"
|
||||
else -> "Configured"
|
||||
!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
|
||||
@@ -317,20 +330,28 @@ fun ActiveCardFeaturesSection(
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
!relayEnabled -> "Disabled"
|
||||
authState is AuthState.Paired -> "Ready"
|
||||
relayConfigured -> "Pair Relay"
|
||||
else -> "Optional"
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
}
|
||||
val terminalTone = when (terminalValue) {
|
||||
"Ready" -> CapabilityTone.Good
|
||||
"Pair Relay" -> CapabilityTone.Info
|
||||
val terminalTone = when {
|
||||
authState is AuthState.Paired -> CapabilityTone.Good
|
||||
relayConfigured && authState !is AuthState.Paired -> CapabilityTone.Info
|
||||
else -> CapabilityTone.Neutral
|
||||
}
|
||||
|
||||
val proxyValue = if (secureProxyAdvertised) "Available" else "Not advertised"
|
||||
val proxyValue = if (secureProxyAdvertised) stringResource(R.string.active_section_available) else stringResource(R.string.active_section_not_advertised)
|
||||
val proxyTone = if (secureProxyAdvertised) CapabilityTone.Good else CapabilityTone.Neutral
|
||||
|
||||
// Pre-resolve labels for CapabilityRow
|
||||
val hermesApiLabel = stringResource(R.string.active_section_hermes_api)
|
||||
val dashboardLabel = stringResource(R.string.active_section_dashboard)
|
||||
val hermesVoiceLabel = stringResource(R.string.active_section_hermes_voice)
|
||||
val relayToolsLabel = stringResource(R.string.active_section_relay_tools)
|
||||
val terminalLabel = stringResource(R.string.active_section_terminal)
|
||||
val secureProxyLabel = stringResource(R.string.active_section_secure_proxy)
|
||||
|
||||
// Lighter than the old six-filled-tile grid: one subtle grouped surface
|
||||
// with a status dot + value per capability, dividers between rows. The
|
||||
// header/glance pills used to duplicate API/Dashboard/Voice/Relay state;
|
||||
@@ -342,21 +363,21 @@ fun ActiveCardFeaturesSection(
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
|
||||
CapabilityRow(
|
||||
label = "Hermes API",
|
||||
label = hermesApiLabel,
|
||||
value = apiValue,
|
||||
tone = apiTone,
|
||||
onClick = onOpenApiInfo,
|
||||
)
|
||||
CapabilityDivider()
|
||||
CapabilityRow(
|
||||
label = "Dashboard",
|
||||
label = dashboardLabel,
|
||||
value = dashboardValue,
|
||||
tone = dashboardTone,
|
||||
onClick = onOpenDashboard,
|
||||
)
|
||||
CapabilityDivider()
|
||||
CapabilityRow(
|
||||
label = "Hermes voice",
|
||||
label = hermesVoiceLabel,
|
||||
value = voiceValue,
|
||||
tone = voiceTone,
|
||||
onClick = if (standardVoiceAvailability ==
|
||||
@@ -369,21 +390,21 @@ fun ActiveCardFeaturesSection(
|
||||
)
|
||||
CapabilityDivider()
|
||||
CapabilityRow(
|
||||
label = "Relay tools",
|
||||
label = relayToolsLabel,
|
||||
value = relayValue,
|
||||
tone = relayTone,
|
||||
onClick = onOpenRelayInfo,
|
||||
)
|
||||
CapabilityDivider()
|
||||
CapabilityRow(
|
||||
label = "Terminal",
|
||||
label = terminalLabel,
|
||||
value = terminalValue,
|
||||
tone = terminalTone,
|
||||
onClick = onOpenSessionInfo,
|
||||
)
|
||||
CapabilityDivider()
|
||||
CapabilityRow(
|
||||
label = "Secure proxy",
|
||||
label = secureProxyLabel,
|
||||
value = proxyValue,
|
||||
tone = proxyTone,
|
||||
)
|
||||
@@ -499,7 +520,7 @@ fun ActiveCardAdvancedSection(
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
SettingsExpandableCard(
|
||||
title = "Advanced",
|
||||
title = stringResource(R.string.active_section_advanced),
|
||||
expanded = expanded,
|
||||
onToggle = { expanded = !expanded },
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -557,6 +578,41 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
|
||||
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
|
||||
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
|
||||
val cannotReachApiToast = stringResource(R.string.active_section_cannot_reach_api)
|
||||
|
||||
// Pre-resolve other strings
|
||||
val apiServerUrlLabel = stringResource(R.string.active_section_api_server_url_label)
|
||||
val apiServerUrlPlaceholder = stringResource(R.string.active_section_api_server_url_placeholder)
|
||||
val apiKeyOptionalLabel = stringResource(R.string.active_section_api_key_optional)
|
||||
val apiKeyAlreadySetPlaceholder = stringResource(R.string.active_section_api_key_already_set)
|
||||
val apiKeyNotConfiguredPlaceholder = stringResource(R.string.active_section_api_key_not_configured)
|
||||
val apiKeyStoredHint = stringResource(R.string.active_section_api_key_stored_hint)
|
||||
val apiKeyNeededHint = stringResource(R.string.active_section_api_key_needed_hint)
|
||||
val hideDesc = stringResource(R.string.active_section_hide)
|
||||
val showDesc = stringResource(R.string.active_section_show)
|
||||
val saveAndTestText = stringResource(R.string.active_section_save_and_test)
|
||||
val relayUrlText = stringResource(R.string.active_section_relay_url)
|
||||
val autoRelayUrlText = stringResource(R.string.active_section_auto_relay_url)
|
||||
val manualOverrideText = stringResource(R.string.active_section_manual_override)
|
||||
val relayOptionalForVoiceText = stringResource(R.string.active_section_relay_optional_for_voice)
|
||||
val useAutoRelayUrlText = stringResource(R.string.active_section_use_auto_relay_url)
|
||||
val useCustomRelayUrlText = stringResource(R.string.active_section_use_custom_relay_url)
|
||||
val relayUrlOverrideLabel = stringResource(R.string.active_section_relay_url_override)
|
||||
val relayUrlOverridePlaceholder = stringResource(R.string.active_section_relay_url_override_placeholder)
|
||||
val relayUrlOverrideHint = stringResource(R.string.active_section_relay_url_override_hint)
|
||||
val voiceReadyViaHermesApiText = stringResource(R.string.active_section_voice_ready_via_hermes_api)
|
||||
val voiceReadyViaRelayText = stringResource(R.string.active_section_voice_ready_via_relay)
|
||||
val voiceRouteNeedsReviewText = stringResource(R.string.active_section_voice_route_needs_review)
|
||||
val testRelayText = stringResource(R.string.active_section_test_relay)
|
||||
val disconnectText = stringResource(R.string.active_section_disconnect)
|
||||
val probingHealthText = stringResource(R.string.active_section_probing_health)
|
||||
val reachableRelayVersionText = stringResource(R.string.active_section_reachable_relay_version)
|
||||
val unreachableRelayText = stringResource(R.string.active_section_unreachable_relay)
|
||||
|
||||
LaunchedEffect(apiUrlInput, relayOverrideVisible, autoRelayUrl) {
|
||||
if (!relayOverrideVisible && autoRelayUrl != null && relayUrlInput != autoRelayUrl) {
|
||||
relayUrlInput = autoRelayUrl
|
||||
@@ -567,8 +623,8 @@ private fun ManualUrlSubsection(
|
||||
OutlinedTextField(
|
||||
value = apiUrlInput,
|
||||
onValueChange = { apiUrlInput = it },
|
||||
label = { Text("API Server URL") },
|
||||
placeholder = { Text("http://your-server:8642") },
|
||||
label = { Text(apiServerUrlLabel) },
|
||||
placeholder = { Text(apiServerUrlPlaceholder) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -576,19 +632,19 @@ private fun ManualUrlSubsection(
|
||||
OutlinedTextField(
|
||||
value = apiKeyInput,
|
||||
onValueChange = { apiKeyInput = it },
|
||||
label = { Text("API Key (optional)") },
|
||||
label = { Text(apiKeyOptionalLabel) },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (apiKeyPresent) "•••• already set (leave blank to keep)"
|
||||
else "Leave empty if not configured",
|
||||
if (apiKeyPresent) apiKeyAlreadySetPlaceholder
|
||||
else apiKeyNotConfiguredPlaceholder,
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
if (apiKeyPresent && apiKeyInput.isBlank()) {
|
||||
"A key is already stored — leave blank to keep it, or type to replace"
|
||||
apiKeyStoredHint
|
||||
} else {
|
||||
"Only needed if Hermes is configured with API_SERVER_KEY"
|
||||
apiKeyNeededHint
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -603,7 +659,7 @@ private fun ManualUrlSubsection(
|
||||
Icon(
|
||||
imageVector = if (apiKeyVisible) Icons.Filled.VisibilityOff
|
||||
else Icons.Filled.Visibility,
|
||||
contentDescription = if (apiKeyVisible) "Hide" else "Show",
|
||||
contentDescription = if (apiKeyVisible) hideDesc else showDesc,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -635,13 +691,13 @@ private fun ManualUrlSubsection(
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
if (result.voiceRoute == "standard") {
|
||||
"API and Hermes voice reachable"
|
||||
apiHermesVoiceReachableToast
|
||||
} else {
|
||||
"API and relay voice reachable"
|
||||
apiRelayVoiceReachableToast
|
||||
}
|
||||
result.apiReachable ->
|
||||
"API reachable; voice route needs review"
|
||||
else -> "Cannot reach API server"
|
||||
apiReachableVoiceReviewToast
|
||||
else -> cannotReachApiToast
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
@@ -649,7 +705,7 @@ private fun ManualUrlSubsection(
|
||||
},
|
||||
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
|
||||
) {
|
||||
Text("Save & Test")
|
||||
Text(saveAndTestText)
|
||||
}
|
||||
if (isTestingApi) {
|
||||
CircularProgressIndicator(
|
||||
@@ -664,20 +720,20 @@ private fun ManualUrlSubsection(
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Relay URL",
|
||||
text = relayUrlText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = if (autoRelayUrl != null && !relayOverrideVisible) {
|
||||
"Auto: $autoRelayUrl"
|
||||
autoRelayUrlText.format(autoRelayUrl)
|
||||
} else {
|
||||
"Manual override"
|
||||
manualOverrideText
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Relay is optional for voice. Hermes voice uses the Hermes API; Relay voice uses this route when selected or needed.",
|
||||
text = relayOptionalForVoiceText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -691,7 +747,7 @@ private fun ManualUrlSubsection(
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(if (relayOverrideVisible) "Use auto relay URL" else "Use custom relay URL")
|
||||
Text(if (relayOverrideVisible) useAutoRelayUrlText else useCustomRelayUrlText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,11 +759,11 @@ private fun ManualUrlSubsection(
|
||||
// Stale reachability results belong to the prior URL.
|
||||
connectionViewModel.clearRelayReachableResult()
|
||||
},
|
||||
label = { Text("Relay URL override") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
label = { Text(relayUrlOverrideLabel) },
|
||||
placeholder = { Text(relayUrlOverridePlaceholder) },
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text("Only needed when the optional Relay route cannot be auto-derived")
|
||||
Text(relayUrlOverrideHint)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -722,12 +778,12 @@ private fun ManualUrlSubsection(
|
||||
Text(
|
||||
text = if (result.voiceConfigReachable) {
|
||||
if (result.voiceRoute == "standard") {
|
||||
"Voice ready via Hermes API"
|
||||
voiceReadyViaHermesApiText
|
||||
} else {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
voiceReadyViaRelayText.format(result.relayUrl ?: "relay")
|
||||
}
|
||||
} else {
|
||||
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
voiceRouteNeedsReviewText.format(result.voiceConfigError ?: stringResource(R.string.active_section_voice_config_probe_failed))
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
@@ -751,13 +807,13 @@ private fun ManualUrlSubsection(
|
||||
enabled = (if (relayOverrideVisible) relayUrlInput else autoRelayUrl.orEmpty()).isNotBlank() &&
|
||||
relayReachable !is ConnectionViewModel.RelayReachable.Probing,
|
||||
) {
|
||||
Text("Test Relay")
|
||||
Text(testRelayText)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { connectionViewModel.disconnectRelay() },
|
||||
enabled = relayConnectionState != ConnectionState.Disconnected,
|
||||
) {
|
||||
Text("Disconnect")
|
||||
Text(disconnectText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,15 +828,16 @@ private fun ManualUrlSubsection(
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
text = "Probing /health…",
|
||||
text = probingHealthText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
is ConnectionViewModel.RelayReachable.Ok -> {
|
||||
val sessionsSuffix = if (r.sessions == 1) "" else stringResource(R.string.active_section_sessions_suffix)
|
||||
Text(
|
||||
text = "✓ Reachable — hermes-relay v${r.version} (${r.clients} client, ${r.sessions} session${if (r.sessions == 1) "" else "s"})",
|
||||
text = reachableRelayVersionText.format(r.version, r.clients, r.sessions, sessionsSuffix),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF4CAF50),
|
||||
)
|
||||
@@ -792,7 +849,7 @@ private fun ManualUrlSubsection(
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "✗ ${humanErr.title}",
|
||||
text = unreachableRelayText.format(humanErr.title),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -824,6 +881,11 @@ private fun InsecureToggleSubsection(
|
||||
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
|
||||
// Pre-resolve strings
|
||||
val plainConnectionNotEncrypted = stringResource(R.string.active_section_plain_connection_not_encrypted)
|
||||
val allowPlainConnections = stringResource(R.string.active_section_allow_plain_connections)
|
||||
val enableWsHttpForDev = stringResource(R.string.active_section_enable_ws_http_for_dev)
|
||||
|
||||
if (isInsecureConnection && relayConnectionState == ConnectionState.Connected) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -839,7 +901,7 @@ private fun InsecureToggleSubsection(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Plain connection — traffic is not encrypted",
|
||||
text = plainConnectionNotEncrypted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -853,11 +915,11 @@ private fun InsecureToggleSubsection(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Allow plain (unencrypted) connections",
|
||||
text = allowPlainConnections,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Enable ws:// and http:// for local dev/testing only",
|
||||
text = enableWsHttpForDev,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -910,6 +972,23 @@ private fun ManualPairingCodeSubsection(
|
||||
var connectAttempt by remember { mutableStateOf(0) }
|
||||
var explainerExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Pre-resolve strings for non-composable contexts (LaunchedEffect, UiMessageBus)
|
||||
val pairedSuccessfullyToast = stringResource(R.string.active_section_paired_successfully)
|
||||
val noResponseFromRelayToast = stringResource(R.string.active_section_no_response_from_relay)
|
||||
val pairingCodeCopiedToast = stringResource(R.string.active_section_pairing_code_copied)
|
||||
val commandCopiedToast = stringResource(R.string.active_section_command_copied)
|
||||
val hermesPairCommandLabel = stringResource(R.string.active_section_hermes_pair_command)
|
||||
val copyPairingCodeDesc = stringResource(R.string.conn_info_copy_pairing_code)
|
||||
val generateNewCodeDesc = stringResource(R.string.active_section_generate_new_code)
|
||||
val copyHermesPairCommandDesc = stringResource(R.string.active_section_copy_hermes_pair_command)
|
||||
val connectingText = stringResource(R.string.active_section_connecting)
|
||||
val connectText = stringResource(R.string.active_section_connect)
|
||||
val relayUrlNotSetText = stringResource(R.string.active_section_relay_url_not_set)
|
||||
val hideExplanationText = stringResource(R.string.active_section_hide_explanation)
|
||||
val howDoesThisWorkText = stringResource(R.string.active_section_how_does_this_work)
|
||||
val manualPairingExplanation = stringResource(R.string.active_section_manual_pairing_explanation)
|
||||
val pairingCodeLabel = stringResource(R.string.conn_info_pairing_code)
|
||||
|
||||
LaunchedEffect(connectAttempt) {
|
||||
if (connectAttempt == 0) return@LaunchedEffect
|
||||
try {
|
||||
@@ -919,7 +998,7 @@ private fun ManualPairingCodeSubsection(
|
||||
}
|
||||
connectInProgress = false
|
||||
when (terminal) {
|
||||
is AuthState.Paired -> UiMessageBus.success("Paired successfully")
|
||||
is AuthState.Paired -> UiMessageBus.success(pairedSuccessfullyToast)
|
||||
is AuthState.Failed -> {
|
||||
val human = classifyError(
|
||||
IllegalStateException(terminal.reason),
|
||||
@@ -932,7 +1011,7 @@ private fun ManualPairingCodeSubsection(
|
||||
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
connectInProgress = false
|
||||
val human = classifyError(
|
||||
java.io.IOException("No response from relay"),
|
||||
java.io.IOException(noResponseFromRelayToast),
|
||||
context = "pair",
|
||||
)
|
||||
snackbarHost.showHumanError(human)
|
||||
@@ -942,20 +1021,25 @@ private fun ManualPairingCodeSubsection(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-resolve other UI strings
|
||||
val manualPairingCodeTitle = stringResource(R.string.active_section_manual_pairing_code_title)
|
||||
val manualPairingCodeDesc = stringResource(R.string.active_section_manual_pairing_code_desc)
|
||||
val step1CopyCode = stringResource(R.string.active_section_step_1_copy_code)
|
||||
val step2RunCommand = stringResource(R.string.active_section_step_2_run_command)
|
||||
val step3TapConnect = stringResource(R.string.active_section_step_3_tap_connect)
|
||||
|
||||
Text(
|
||||
text = "Manual pairing code (fallback)",
|
||||
text = manualPairingCodeTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "Use this when you can't scan the pairing QR. " +
|
||||
"Follow the three steps — they're meant to be done in order on " +
|
||||
"whatever machine you have shell access to.",
|
||||
text = manualPairingCodeDesc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Step 1 — display + copy + regenerate
|
||||
ManualPairStep(number = 1, title = "Copy the code below") {
|
||||
ManualPairStep(number = 1, title = step1CopyCode) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -973,27 +1057,27 @@ private fun ManualPairingCodeSubsection(
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("Pairing code", pairingCode)),
|
||||
ClipEntry(ClipData.newPlainText(pairingCodeLabel, pairingCode)),
|
||||
)
|
||||
UiMessageBus.info("Pairing code copied")
|
||||
UiMessageBus.info(pairingCodeCopiedToast)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy pairing code",
|
||||
contentDescription = copyPairingCodeDesc,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { connectionViewModel.regeneratePairingCode() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Generate new code",
|
||||
contentDescription = generateNewCodeDesc,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 — host command
|
||||
ManualPairStep(number = 2, title = "On the host running Hermes-Relay, run:") {
|
||||
ManualPairStep(number = 2, title = step2RunCommand) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
@@ -1016,16 +1100,16 @@ private fun ManualPairingCodeSubsection(
|
||||
val cmd = "hermes pair --register-code $pairingCode"
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
|
||||
ClipEntry(ClipData.newPlainText(hermesPairCommandLabel, cmd)),
|
||||
)
|
||||
UiMessageBus.info("Command copied")
|
||||
UiMessageBus.info(commandCopiedToast)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy hermes pair command",
|
||||
contentDescription = copyHermesPairCommandDesc,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -1034,7 +1118,7 @@ private fun ManualPairingCodeSubsection(
|
||||
}
|
||||
|
||||
// Step 3 — Connect button + relay-URL prerequisite check
|
||||
ManualPairStep(number = 3, title = "Come back here and tap Connect") {
|
||||
ManualPairStep(number = 3, title = step3TapConnect) {
|
||||
val canConnect = !connectInProgress &&
|
||||
relayUrl.isNotBlank() &&
|
||||
pairingCode.isNotBlank()
|
||||
@@ -1061,14 +1145,14 @@ private fun ManualPairingCodeSubsection(
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("Connecting…")
|
||||
Text(connectingText)
|
||||
} else {
|
||||
Text("Connect")
|
||||
Text(connectText)
|
||||
}
|
||||
}
|
||||
if (relayUrl.isBlank()) {
|
||||
Text(
|
||||
text = "Relay URL not set — open the Manual URL section above to set it first.",
|
||||
text = relayUrlNotSetText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -1082,24 +1166,13 @@ private fun ManualPairingCodeSubsection(
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (explainerExpanded) "Hide explanation" else "How does this work?",
|
||||
text = if (explainerExpanded) hideExplanationText else howDoesThisWorkText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (explainerExpanded) {
|
||||
Text(
|
||||
text = "This is a fallback for when you can't scan the pairing QR " +
|
||||
"— for example, no camera, the host can't render a QR, or you " +
|
||||
"only have SSH access from a single device. The canonical flow " +
|
||||
"is the QR scan from `/hermes-relay-pair` or `hermes pair`.\n\n" +
|
||||
"How it works: the phone generates a 6-character code locally. " +
|
||||
"You paste that code into the host's `hermes pair --register-code` " +
|
||||
"command, which pre-registers it with the relay. When you tap " +
|
||||
"Connect here, the phone presents the same code to the relay " +
|
||||
"and gets a long-lived session token in return.\n\n" +
|
||||
"Bridge / device-control is gated by the master toggle on the " +
|
||||
"Bridge tab, NOT by this pairing code. Pairing only authorizes " +
|
||||
"the relay session.",
|
||||
text = manualPairingExplanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -1136,6 +1209,13 @@ fun ActiveCardSecurityPosture(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Pre-resolve strings
|
||||
val tailscaleDetectedText = stringResource(R.string.active_section_tailscale_detected)
|
||||
val sessionTokenHardwareKeystoreText = stringResource(R.string.active_section_session_token_hardware_keystore)
|
||||
val relaySessionsLabel = stringResource(R.string.active_section_relay_sessions)
|
||||
val activeSessionsOnServerText = stringResource(R.string.active_section_active_sessions_on_server)
|
||||
val managePhonesConnectText = stringResource(R.string.active_section_manage_phones_connect)
|
||||
|
||||
if (isTailscaleDetected) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1148,7 +1228,7 @@ fun ActiveCardSecurityPosture(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Tailscale detected",
|
||||
text = tailscaleDetectedText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF2E7D32),
|
||||
)
|
||||
@@ -1167,7 +1247,7 @@ fun ActiveCardSecurityPosture(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Session token stored in hardware keystore",
|
||||
text = sessionTokenHardwareKeystoreText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -1184,14 +1264,14 @@ fun ActiveCardSecurityPosture(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Relay sessions",
|
||||
text = relaySessionsLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = if (pairedDevices.isNotEmpty()) {
|
||||
"${pairedDevices.size} active sessions on this server"
|
||||
activeSessionsOnServerText.format(pairedDevices.size)
|
||||
} else {
|
||||
"Manage which phones can connect"
|
||||
managePhonesConnectText
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -1253,22 +1333,42 @@ fun ActiveCardRoutesSection(
|
||||
val probeCameUpEmpty = activeEndpoint == null &&
|
||||
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Done &&
|
||||
routeProbeStatus.winner == null
|
||||
|
||||
// Pre-resolve strings for route status labels
|
||||
val checkingRoutesText = stringResource(R.string.active_section_checking_routes)
|
||||
val noRouteReachableText = stringResource(R.string.active_section_no_route_reachable)
|
||||
val resolvingText = stringResource(R.string.active_section_resolving)
|
||||
val usingSavedUrlText = stringResource(R.string.active_section_using_saved_url)
|
||||
|
||||
val activeRouteLabel = when {
|
||||
activeEndpoint != null -> activeEndpoint!!.displayLabel()
|
||||
isRouteProbing -> "Checking routes…"
|
||||
probeCameUpEmpty -> "No route reachable"
|
||||
else -> "Resolving"
|
||||
isRouteProbing -> checkingRoutesText
|
||||
probeCameUpEmpty -> noRouteReachableText
|
||||
else -> resolvingText
|
||||
}
|
||||
val activeRouteHost = activeEndpoint?.api?.url
|
||||
?: "Using saved URL: ${connection.apiServerUrl.ifBlank { connection.relayUrl }}"
|
||||
?: usingSavedUrlText.format(connection.apiServerUrl.ifBlank { connection.relayUrl })
|
||||
|
||||
// Pre-resolve other UI strings
|
||||
val chooseHowPhoneReachesText = stringResource(R.string.active_section_choose_how_phone_reaches)
|
||||
val currentRouteText = stringResource(R.string.active_section_current_route)
|
||||
val noRoutesAnsweredProbeText = stringResource(R.string.active_section_no_routes_answered_probe)
|
||||
val tailscaleRouteNotActiveText = stringResource(R.string.active_section_tailscale_route_not_active)
|
||||
val connectPhoneInTailscaleText = stringResource(R.string.active_section_connect_phone_in_tailscale)
|
||||
val openTailscaleText = stringResource(R.string.active_section_open_tailscale)
|
||||
val checkingText = stringResource(R.string.active_section_checking)
|
||||
val recheckText = stringResource(R.string.active_section_recheck)
|
||||
val phoneOnTailscaleNoRouteText = stringResource(R.string.active_section_phone_on_tailscale_no_route)
|
||||
val addServerTailscaleUrlText = stringResource(R.string.active_section_add_server_tailscale_url)
|
||||
val addTailscaleRouteText = stringResource(R.string.active_section_add_tailscale_route)
|
||||
val autoText = stringResource(R.string.active_section_auto)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Choose how this phone reaches Hermes. Features stay separate " +
|
||||
"from the selected route.",
|
||||
text = chooseHowPhoneReachesText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -1287,7 +1387,7 @@ fun ActiveCardRoutesSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Current: $activeRouteLabel",
|
||||
text = currentRouteText.format(activeRouteLabel),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (probeCameUpEmpty) {
|
||||
MaterialTheme.colorScheme.error
|
||||
@@ -1311,8 +1411,7 @@ fun ActiveCardRoutesSection(
|
||||
)
|
||||
if (probeCameUpEmpty) {
|
||||
Text(
|
||||
text = "None of the saved routes answered a health probe. " +
|
||||
"Expand the routes below for per-route reasons.",
|
||||
text = noRoutesAnsweredProbeText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -1331,12 +1430,12 @@ fun ActiveCardRoutesSection(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Tailscale route is not active on this phone",
|
||||
text = tailscaleRouteNotActiveText,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Connect this phone in Tailscale, then re-check routes.",
|
||||
text = connectPhoneInTailscaleText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
@@ -1351,7 +1450,7 @@ fun ActiveCardRoutesSection(
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text("Open Tailscale")
|
||||
Text(openTailscaleText)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
@@ -1359,7 +1458,7 @@ fun ActiveCardRoutesSection(
|
||||
enabled = !isRouteProbing,
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
Text(if (isRouteProbing) checkingText else recheckText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1380,13 +1479,12 @@ fun ActiveCardRoutesSection(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Phone is on Tailscale — no Tailscale route yet",
|
||||
text = phoneOnTailscaleNoRouteText,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Add your server's Tailscale URL so Hermes keeps " +
|
||||
"working when this phone leaves the server's network.",
|
||||
text = addServerTailscaleUrlText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
@@ -1397,7 +1495,7 @@ fun ActiveCardRoutesSection(
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text("Add Tailscale route")
|
||||
Text(addTailscaleRouteText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1411,7 +1509,7 @@ fun ActiveCardRoutesSection(
|
||||
onClick = { connectionViewModel.probeNow() },
|
||||
enabled = !isRouteProbing,
|
||||
) {
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
Text(if (isRouteProbing) checkingText else recheckText)
|
||||
}
|
||||
if (preferredRole != null || manualSwitchActive) {
|
||||
TextButton(
|
||||
@@ -1420,7 +1518,7 @@ fun ActiveCardRoutesSection(
|
||||
preferredRole = null
|
||||
},
|
||||
) {
|
||||
Text("Auto")
|
||||
Text(autoText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import java.io.File
|
||||
|
||||
@@ -42,7 +44,7 @@ fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Agent icon",
|
||||
text = stringResource(R.string.agent_icon_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -61,23 +63,23 @@ fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
|
||||
if (!path.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(path),
|
||||
contentDescription = "Agent icon",
|
||||
contentDescription = stringResource(R.string.agent_icon_title),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { launcher.launch(arrayOf("image/*")) }) {
|
||||
Text(if (iconPath.isNullOrBlank()) "Set image" else "Change")
|
||||
Text(if (iconPath.isNullOrBlank()) stringResource(R.string.agent_icon_set) else stringResource(R.string.agent_icon_change))
|
||||
}
|
||||
if (!iconPath.isNullOrBlank()) {
|
||||
TextButton(onClick = { connectionViewModel.clearProfileIcon() }) {
|
||||
Text("Clear")
|
||||
Text(stringResource(R.string.agent_icon_clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Shown beside this profile's name in chat. Stays on this device — never sent to Hermes.",
|
||||
text = stringResource(R.string.agent_icon_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -72,12 +72,14 @@ 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.res.stringResource
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
|
||||
// --- Text-flow tuning constants -------------------------------------------
|
||||
//
|
||||
@@ -453,7 +455,7 @@ private fun CleanModeComposer(
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = "Message",
|
||||
text = stringResource(R.string.agent_text_placeholder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = RelayRefresh.Dim,
|
||||
)
|
||||
@@ -469,7 +471,7 @@ private fun CleanModeComposer(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
contentDescription = stringResource(R.string.agent_text_send_cd),
|
||||
tint = if (canSend) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
@@ -550,8 +552,9 @@ fun CleanChatMode(
|
||||
|
||||
BackHandler(enabled = true) { onExit() }
|
||||
|
||||
val sphereDescLabel = stringResource(R.string.agent_text_sphere_desc)
|
||||
val sphereDescription = remember(sphereState) {
|
||||
"Agent ${sphereState.name.lowercase()}"
|
||||
"$sphereDescLabel ${sphereState.name.lowercase()}"
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -589,7 +592,7 @@ fun CleanChatMode(
|
||||
IconButton(onClick = onExit) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Exit clean mode",
|
||||
contentDescription = stringResource(R.string.agent_text_exit_clean),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -48,6 +49,7 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
@@ -308,7 +310,7 @@ private fun GalleryImageFailure(description: String, modifier: Modifier) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.BrokenImage,
|
||||
contentDescription = "Couldn't load $description",
|
||||
contentDescription = stringResource(R.string.attachment_load_failed_a11y, description),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("LocalContextGetResourceValueCall")
|
||||
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
@@ -97,6 +99,8 @@ import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.BlurMode
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -167,18 +171,18 @@ fun BlurredMedia(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.VisibilityOff,
|
||||
contentDescription = "Sensitive content",
|
||||
contentDescription = stringResource(R.string.attach_viewer_cd_sensitive),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Sensitive",
|
||||
text = stringResource(R.string.attachment_sensitive),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White,
|
||||
)
|
||||
if (revealOnTap) {
|
||||
Text(
|
||||
text = "Tap to reveal",
|
||||
text = stringResource(R.string.attachment_tap_reveal),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
)
|
||||
@@ -322,7 +326,7 @@ fun AttachmentViewer(
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { "Attachment" }
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
|
||||
|
||||
// --- One shared Share / Save / Open-externally action set ----------
|
||||
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
|
||||
@@ -331,7 +335,7 @@ fun AttachmentViewer(
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
viewerToast(context, "Couldn't read this file")
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
return@launch
|
||||
}
|
||||
action(bytes)
|
||||
@@ -354,13 +358,13 @@ fun AttachmentViewer(
|
||||
}
|
||||
when (result) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
viewerToast(context, "Saved to ${result.location}")
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
viewerToast(context, "Save failed: ${result.message}")
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -626,7 +630,7 @@ private fun MediaViewerToolbar(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onClose, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close")
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.attach_viewer_cd_close))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
@@ -648,13 +652,13 @@ private fun MediaViewerToolbar(
|
||||
enabled = actionsEnabled && !busy,
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, contentDescription = "Open externally")
|
||||
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
|
||||
}
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = "Share")
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = "Save")
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,7 +710,7 @@ private fun ImageBody(
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().zoomable(),
|
||||
)
|
||||
failed -> CenteredNotice("Couldn't load this image")
|
||||
failed -> CenteredNotice(stringResource(R.string.attach_viewer_load_failed))
|
||||
else -> CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -722,7 +726,7 @@ private fun VideoBody(attachment: Attachment) {
|
||||
val uri = rememberPlayableUri(attachment)
|
||||
|
||||
if (uri == null) {
|
||||
CenteredNotice("Preparing video…", spinner = true)
|
||||
CenteredNotice(stringResource(R.string.attach_viewer_preparing_video), spinner = true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -780,7 +784,7 @@ private fun VideoBody(attachment: Attachment) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (muted) Icons.Filled.VolumeOff else Icons.Filled.VolumeUp,
|
||||
contentDescription = if (muted) "Unmute" else "Mute",
|
||||
contentDescription = if (muted) stringResource(R.string.attach_viewer_cd_unmute) else stringResource(R.string.attach_viewer_cd_mute),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -797,7 +801,7 @@ private fun AudioBody(attachment: Attachment) {
|
||||
val uri = rememberPlayableUri(attachment)
|
||||
|
||||
if (uri == null) {
|
||||
CenteredNotice("Preparing audio…", spinner = true)
|
||||
CenteredNotice(stringResource(R.string.attach_viewer_preparing_audio), spinner = true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -859,7 +863,7 @@ private fun AudioBody(attachment: Attachment) {
|
||||
AmplitudeMeter(amplitude = amplitude, active = isPlaying)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = attachment.fileName ?: "Audio",
|
||||
text = attachment.fileName ?: stringResource(R.string.attachment_audio_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
@@ -938,6 +942,8 @@ private fun PdfBody(attachment: Attachment) {
|
||||
var pdfError by remember(attachment.cachedUri, attachment.content) { mutableStateOf<String?>(null) }
|
||||
var widthPx by remember { mutableStateOf(0) }
|
||||
|
||||
val pdfFailedText = stringResource(R.string.attach_viewer_pdf_failed)
|
||||
|
||||
LaunchedEffect(attachment.cachedUri, attachment.content) {
|
||||
val opened = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
@@ -946,14 +952,14 @@ private fun PdfBody(attachment: Attachment) {
|
||||
PdfDoc(PdfRenderer(pfd), pfd, Mutex())
|
||||
}.getOrNull()
|
||||
}
|
||||
if (opened == null) pdfError = "Couldn't open this PDF" else doc = opened
|
||||
if (opened == null) pdfError = pdfFailedText else doc = opened
|
||||
}
|
||||
DisposableEffect(doc) { onDispose { doc?.close() } }
|
||||
|
||||
val current = doc
|
||||
when {
|
||||
pdfError != null -> CenteredNotice(pdfError!!)
|
||||
current == null -> CenteredNotice("Rendering PDF…", spinner = true)
|
||||
current == null -> CenteredNotice(stringResource(R.string.attach_viewer_rendering_pdf), spinner = true)
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -1001,7 +1007,7 @@ private fun PdfPage(doc: PdfDoc, index: Int, widthPx: Int) {
|
||||
if (bmp != null) {
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
contentDescription = "Page ${index + 1}",
|
||||
contentDescription = stringResource(R.string.attach_viewer_page_label, index + 1),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
@@ -1037,7 +1043,7 @@ private fun TextBody(attachment: Attachment) {
|
||||
|
||||
val body = text
|
||||
when {
|
||||
body == null -> CenteredNotice("Loading…", spinner = true)
|
||||
body == null -> CenteredNotice(stringResource(R.string.attach_viewer_loading), spinner = true)
|
||||
else -> SelectionContainer(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -1066,19 +1072,19 @@ private fun GenericBody(attachment: Attachment, onOpenExternal: () -> Unit) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = attachment.fileName ?: "File",
|
||||
text = attachment.fileName ?: stringResource(R.string.attach_viewer_file_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "No in-app preview for this type.",
|
||||
text = stringResource(R.string.attachment_no_preview),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
androidx.compose.material3.OutlinedButton(onClick = onOpenExternal) {
|
||||
Text("Open externally")
|
||||
Text(stringResource(R.string.attachment_open_ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1116,7 +1122,7 @@ private fun PlaybackControls(
|
||||
IconButton(onClick = onPlayPause, colors = tint) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
contentDescription = if (isPlaying) stringResource(R.string.attach_viewer_cd_pause) else stringResource(R.string.attach_viewer_cd_play),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -36,8 +36,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.BridgeActivityEntry
|
||||
@@ -89,14 +91,14 @@ fun BridgeActivityLog(
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(
|
||||
text = "Activity Log",
|
||||
text = stringResource(R.string.bal_activity_log),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (entries.isNotEmpty()) {
|
||||
TextButton(onClick = onClear) { Text("Clear") }
|
||||
TextButton(onClick = onClear) { Text(stringResource(R.string.bal_clear)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +106,7 @@ fun BridgeActivityLog(
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
Text(
|
||||
text = "No bridge commands yet. Every tap, type, and " +
|
||||
"screenshot the agent performs will show up here.",
|
||||
text = stringResource(R.string.bal_no_activity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -162,7 +163,7 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
|
||||
if (entry.thumbnailToken != null) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.InsertPhoto,
|
||||
contentDescription = "Has screenshot",
|
||||
contentDescription = stringResource(R.string.bal_has_screenshot),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
@@ -175,13 +176,13 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Full timestamp: ${formatFullTime(entry.timestampMs)}",
|
||||
text = stringResource(R.string.bal_full_timestamp, formatFullTime(entry.timestampMs)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
text = "Status: ${entry.status.name}",
|
||||
text = stringResource(R.string.bal_status_label, entry.status.name),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -199,7 +200,7 @@ private fun ActivityRow(entry: BridgeActivityEntry) {
|
||||
// label so the expand affordance still communicates the
|
||||
// shape of the future feature.
|
||||
Text(
|
||||
text = "Screenshot token: ${entry.thumbnailToken}",
|
||||
text = stringResource(R.string.bal_screenshot_token, entry.thumbnailToken),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace
|
||||
|
||||
@@ -33,9 +33,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
|
||||
/**
|
||||
@@ -61,6 +63,7 @@ fun BridgeMasterToggle(
|
||||
onToggle: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "Agent Control",
|
||||
// Note: label default is intentionally a literal — it's resolved from the caller, not from stringResource
|
||||
// Called when the user taps the switch to enable but accessibility
|
||||
// hasn't been granted yet. The default no-op keeps the v0.4 behaviour
|
||||
// for callers that don't wire this up; BridgeScreen hooks a snackbar
|
||||
@@ -81,17 +84,15 @@ fun BridgeMasterToggle(
|
||||
val isSideloadLabel = label.contains("Agent", ignoreCase = true)
|
||||
val subtitle = if (isSideloadLabel) {
|
||||
if (enabled) {
|
||||
"Master switch — agent can read screen and act via the " +
|
||||
"sub-features below."
|
||||
stringResource(R.string.bmt_master_switch_on)
|
||||
} else {
|
||||
"Master switch — off. All bridge features (unattended, " +
|
||||
"commands, voice intents) are inactive."
|
||||
stringResource(R.string.bmt_master_switch_off)
|
||||
}
|
||||
} else {
|
||||
if (enabled) {
|
||||
"Master switch — bridge is providing screen content to chat."
|
||||
stringResource(R.string.bmt_master_switch_on_googleplay)
|
||||
} else {
|
||||
"Master switch — off. Bridge is not reading screen content."
|
||||
stringResource(R.string.bmt_master_switch_off_googleplay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +126,7 @@ fun BridgeMasterToggle(
|
||||
IconButton(onClick = { showExplain = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = "What does this do?",
|
||||
contentDescription = stringResource(R.string.bmt_what_does_this_do),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
@@ -149,7 +150,7 @@ fun BridgeMasterToggle(
|
||||
|
||||
if (!accessibilityGranted) {
|
||||
Text(
|
||||
text = "Grant the Accessibility Service permission below to enable.",
|
||||
text = stringResource(R.string.bmt_grant_accessibility),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
@@ -159,22 +160,22 @@ fun BridgeMasterToggle(
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
StatusInlineRow(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
label = "Device",
|
||||
label = stringResource(R.string.bmt_device),
|
||||
value = status.deviceName
|
||||
)
|
||||
StatusInlineRow(
|
||||
icon = Icons.Filled.BatteryFull,
|
||||
label = "Battery",
|
||||
label = stringResource(R.string.bmt_battery),
|
||||
value = status.batteryPercent?.let { "$it%" } ?: "—"
|
||||
)
|
||||
StatusInlineRow(
|
||||
icon = Icons.Filled.ScreenLockPortrait,
|
||||
label = "Screen",
|
||||
value = if (status.screenOn) "ON" else "OFF"
|
||||
label = stringResource(R.string.bmt_screen),
|
||||
value = if (status.screenOn) stringResource(R.string.bmt_on) else stringResource(R.string.bmt_off)
|
||||
)
|
||||
StatusInlineRow(
|
||||
icon = Icons.Filled.Smartphone,
|
||||
label = "Current app",
|
||||
label = stringResource(R.string.bmt_current_app),
|
||||
value = status.currentApp ?: "—"
|
||||
)
|
||||
}
|
||||
@@ -184,40 +185,29 @@ fun BridgeMasterToggle(
|
||||
if (showExplain) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExplain = false },
|
||||
title = { Text("About Agent Control") },
|
||||
title = { Text(stringResource(R.string.bmt_about_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Agent Control lets your Hermes agent read what's on " +
|
||||
"your screen and interact with apps on your behalf " +
|
||||
"(tap, type, scroll, screenshot).",
|
||||
stringResource(R.string.bmt_about_body1),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"This uses Android's Accessibility Service API, which " +
|
||||
"is the same permission screen readers use. You must " +
|
||||
"enable it in Android Settings before this switch works.",
|
||||
stringResource(R.string.bmt_about_body2),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"While this is on, a 'Hermes has device control' " +
|
||||
"notification stays in your notification shade — " +
|
||||
"that's tied to this master switch, not to any " +
|
||||
"sub-feature (like Unattended Access), and goes " +
|
||||
"away the moment you turn this off.",
|
||||
stringResource(R.string.bmt_about_body3),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"You can turn Agent Control off at any time from this " +
|
||||
"screen or by disabling the service in Android " +
|
||||
"Settings. All bridge commands are logged in the " +
|
||||
"Activity Log below.",
|
||||
stringResource(R.string.bmt_about_body4),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showExplain = false }) { Text("Got it") }
|
||||
TextButton(onClick = { showExplain = false }) { Text(stringResource(R.string.bmt_got_it)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -254,7 +244,7 @@ private fun MasterPill() {
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = "MASTER",
|
||||
text = stringResource(R.string.bmt_master),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
|
||||
+42
-40
@@ -44,9 +44,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.BridgePermissionStatus
|
||||
|
||||
/**
|
||||
@@ -117,13 +119,13 @@ fun BridgePermissionChecklist(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Permissions",
|
||||
text = stringResource(R.string.bpc_permissions),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Tap a row to grant or open Android Settings · Tap Test to verify.",
|
||||
text = stringResource(R.string.bpc_permissions_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -132,16 +134,16 @@ fun BridgePermissionChecklist(
|
||||
|
||||
// ── Core bridge (required, both flavors) ──────────────────────
|
||||
TierHeader(
|
||||
label = "Core bridge",
|
||||
subtitle = "Required for the agent to read and act on screen content.",
|
||||
label = stringResource(R.string.bpc_core_bridge),
|
||||
subtitle = stringResource(R.string.bpc_core_bridge_desc),
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Accessibility,
|
||||
title = "Accessibility Service",
|
||||
title = stringResource(R.string.bpc_accessibility),
|
||||
subtitle = if (BuildFlavor.isSideload)
|
||||
"Read screen content, dispatch taps/types"
|
||||
stringResource(R.string.bpc_accessibility_desc_sideload)
|
||||
else
|
||||
"Read screen content for chat context",
|
||||
stringResource(R.string.bpc_accessibility_desc_googleplay),
|
||||
granted = status.accessibilityServiceEnabled,
|
||||
onClick = { openAccessibilitySettings(context) },
|
||||
onTest = onTestAccessibility,
|
||||
@@ -153,11 +155,11 @@ fun BridgePermissionChecklist(
|
||||
if (BuildFlavor.isSideload) {
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.ScreenShare,
|
||||
title = "Screen Capture",
|
||||
title = stringResource(R.string.bpc_screen_capture),
|
||||
subtitle = if (status.screenCapturePermitted)
|
||||
"Granted for this session — agent can take screenshots"
|
||||
stringResource(R.string.bpc_screen_capture_granted)
|
||||
else
|
||||
"Tap to grant — agent needs this for /screenshot",
|
||||
stringResource(R.string.bpc_screen_capture_not_granted),
|
||||
granted = status.screenCapturePermitted,
|
||||
onClick = onRequestScreenCapture,
|
||||
onTest = onTestScreenCapture,
|
||||
@@ -170,8 +172,8 @@ fun BridgePermissionChecklist(
|
||||
if (BuildFlavor.isSideload) {
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.PictureInPicture,
|
||||
title = "Display over other apps",
|
||||
subtitle = "Status overlay while bridge is active",
|
||||
title = stringResource(R.string.bpc_overlay),
|
||||
subtitle = stringResource(R.string.bpc_overlay_desc),
|
||||
granted = status.overlayPermitted,
|
||||
onClick = { openOverlaySettings(context) },
|
||||
onTest = onTestOverlay,
|
||||
@@ -180,11 +182,11 @@ fun BridgePermissionChecklist(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notifications",
|
||||
title = stringResource(R.string.bpc_notifications),
|
||||
subtitle = if (status.notificationsPermitted)
|
||||
"Bridge service notification can display"
|
||||
stringResource(R.string.bpc_notifications_granted)
|
||||
else
|
||||
"Required for the bridge foreground service indicator",
|
||||
stringResource(R.string.bpc_notifications_not_granted),
|
||||
granted = status.notificationsPermitted,
|
||||
onClick = onRequestNotifications,
|
||||
)
|
||||
@@ -193,13 +195,13 @@ fun BridgePermissionChecklist(
|
||||
// ── Notification companion (optional, both flavors) ─────────────
|
||||
TierSpacer()
|
||||
TierHeader(
|
||||
label = "Notification companion",
|
||||
subtitle = "Optional. Lets the agent see incoming notifications for summaries and replies.",
|
||||
label = stringResource(R.string.bpc_notification_companion),
|
||||
subtitle = stringResource(R.string.bpc_notification_companion_desc),
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notification Listener",
|
||||
subtitle = "Read notifications for agent summaries",
|
||||
title = stringResource(R.string.bpc_notification_listener),
|
||||
subtitle = stringResource(R.string.bpc_notification_listener_desc),
|
||||
granted = status.notificationListenerPermitted,
|
||||
onClick = { openNotificationListenerSettings(context) },
|
||||
onTest = onTestNotificationListener,
|
||||
@@ -209,21 +211,21 @@ fun BridgePermissionChecklist(
|
||||
// ── Voice & camera (optional, both flavors) ────────────────────
|
||||
TierSpacer()
|
||||
TierHeader(
|
||||
label = "Voice & camera",
|
||||
subtitle = "Required when you use voice mode or attach camera media.",
|
||||
label = stringResource(R.string.bpc_voice_camera),
|
||||
subtitle = stringResource(R.string.bpc_voice_camera_desc),
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Mic,
|
||||
title = "Microphone",
|
||||
subtitle = "Required for voice mode (record + transcribe).",
|
||||
title = stringResource(R.string.bpc_microphone),
|
||||
subtitle = stringResource(R.string.bpc_microphone_desc),
|
||||
granted = status.microphonePermitted,
|
||||
onClick = onRequestMicrophone,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.CameraAlt,
|
||||
title = "Camera",
|
||||
subtitle = "Required to attach photos taken in-app.",
|
||||
title = stringResource(R.string.bpc_camera),
|
||||
subtitle = stringResource(R.string.bpc_camera_desc),
|
||||
granted = status.cameraPermitted,
|
||||
onClick = onRequestCamera,
|
||||
optional = true,
|
||||
@@ -233,37 +235,37 @@ fun BridgePermissionChecklist(
|
||||
if (BuildFlavor.isSideload) {
|
||||
TierSpacer()
|
||||
TierHeader(
|
||||
label = "Sideload features",
|
||||
subtitle = "Optional. Powers contact lookup, SMS, dialer, and location tools.",
|
||||
label = stringResource(R.string.bpc_sideload_features),
|
||||
subtitle = stringResource(R.string.bpc_sideload_features_desc),
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Contacts,
|
||||
title = "Contacts",
|
||||
subtitle = "Resolve names to phone numbers (android_search_contacts).",
|
||||
title = stringResource(R.string.bpc_contacts),
|
||||
subtitle = stringResource(R.string.bpc_contacts_desc),
|
||||
granted = status.contactsPermitted,
|
||||
onClick = onRequestContacts,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Sms,
|
||||
title = "SMS",
|
||||
subtitle = "Send text messages directly (android_send_sms).",
|
||||
title = stringResource(R.string.bpc_sms),
|
||||
subtitle = stringResource(R.string.bpc_sms_desc),
|
||||
granted = status.smsPermitted,
|
||||
onClick = onRequestSms,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.Call,
|
||||
title = "Phone",
|
||||
subtitle = "Place calls directly without opening the dialer (android_call).",
|
||||
title = stringResource(R.string.bpc_phone),
|
||||
subtitle = stringResource(R.string.bpc_phone_desc),
|
||||
granted = status.phonePermitted,
|
||||
onClick = onRequestPhone,
|
||||
optional = true,
|
||||
)
|
||||
PermissionRow(
|
||||
icon = Icons.Filled.LocationOn,
|
||||
title = "Location",
|
||||
subtitle = "Last-known GPS fix for context-aware queries (android_location).",
|
||||
title = stringResource(R.string.bpc_location),
|
||||
subtitle = stringResource(R.string.bpc_location_desc),
|
||||
granted = status.locationPermitted,
|
||||
onClick = onRequestLocation,
|
||||
optional = true,
|
||||
@@ -329,7 +331,7 @@ private fun OptionalBadge() {
|
||||
// badge drops to the next line cleanly when space is tight, instead
|
||||
// of compressing awkwardly in-line.
|
||||
Text(
|
||||
text = "Optional",
|
||||
text = stringResource(R.string.bpc_optional),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
@@ -403,7 +405,7 @@ private fun PermissionRow(
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "Test",
|
||||
text = stringResource(R.string.bpc_test),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
@@ -412,9 +414,9 @@ private fun PermissionRow(
|
||||
// Optional rows that are *not* granted use a neutral tint instead of
|
||||
// error red so users don't perceive them as urgent action items.
|
||||
val (statusTint, statusDescription) = when {
|
||||
granted -> Color(0xFF4CAF50) to "Granted"
|
||||
optional -> MaterialTheme.colorScheme.onSurfaceVariant to "Not granted (optional)"
|
||||
else -> MaterialTheme.colorScheme.error to "Not granted"
|
||||
granted -> Color(0xFF4CAF50) to stringResource(R.string.bpc_granted)
|
||||
optional -> MaterialTheme.colorScheme.onSurfaceVariant to stringResource(R.string.bpc_not_granted_optional)
|
||||
else -> MaterialTheme.colorScheme.error to stringResource(R.string.bpc_not_granted)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (granted) Icons.Filled.CheckCircle
|
||||
|
||||
+8
-6
@@ -23,7 +23,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.BridgeSafetySettings
|
||||
@@ -90,7 +92,7 @@ fun BridgeSafetySummaryCard(
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Safety",
|
||||
text = stringResource(R.string.bssc_safety),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
@@ -98,21 +100,21 @@ fun BridgeSafetySummaryCard(
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Manage",
|
||||
contentDescription = stringResource(R.string.bssc_manage),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
SafetySummaryRow(
|
||||
label = "Apps the agent can't touch",
|
||||
label = stringResource(R.string.bssc_blocked_apps),
|
||||
value = "${settings.blocklist.size}",
|
||||
)
|
||||
SafetySummaryRow(
|
||||
label = "Words that always ask first",
|
||||
label = stringResource(R.string.bssc_destructive_verbs),
|
||||
value = "${settings.destructiveVerbs.size}",
|
||||
)
|
||||
SafetySummaryRow(
|
||||
label = "Turns itself off when idle",
|
||||
label = stringResource(R.string.bssc_auto_disable),
|
||||
value = if (autoDisableAtMs != null) {
|
||||
val remainMs = (autoDisableAtMs - nowMs).coerceAtLeast(0L)
|
||||
val remainMin = (remainMs / 60_000L).toInt()
|
||||
@@ -124,7 +126,7 @@ fun BridgeSafetySummaryCard(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "These guardrails keep Hermes in bounds. Tap to adjust.",
|
||||
text = stringResource(R.string.bssc_guardrails_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -14,9 +14,11 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
|
||||
/**
|
||||
@@ -53,7 +55,7 @@ fun BridgeStatusCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Status",
|
||||
text = stringResource(R.string.bsc_status),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
@@ -64,7 +66,7 @@ fun BridgeStatusCard(
|
||||
isConnecting = false,
|
||||
)
|
||||
Text(
|
||||
text = if (isConnected) "Connected" else "Disconnected",
|
||||
text = if (isConnected) stringResource(R.string.bsc_connected) else stringResource(R.string.bsc_disconnected),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isConnected) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -75,22 +77,21 @@ fun BridgeStatusCard(
|
||||
|
||||
if (status == null) {
|
||||
Text(
|
||||
text = "Bridge runtime not yet reporting status. Enable " +
|
||||
"Agent Control above to begin receiving device telemetry.",
|
||||
text = stringResource(R.string.bsc_no_status),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
StatusKeyValue("Device", status.deviceName)
|
||||
StatusKeyValue(stringResource(R.string.bmt_device), status.deviceName)
|
||||
StatusKeyValue(
|
||||
"Battery",
|
||||
status.batteryPercent?.let { "$it%" } ?: "Unknown"
|
||||
stringResource(R.string.bmt_battery),
|
||||
status.batteryPercent?.let { "$it%" } ?: stringResource(R.string.bsc_unknown)
|
||||
)
|
||||
StatusKeyValue("Screen", if (status.screenOn) "ON" else "OFF")
|
||||
StatusKeyValue("Current app", status.currentApp ?: "—")
|
||||
StatusKeyValue(stringResource(R.string.bmt_screen), if (status.screenOn) stringResource(R.string.bmt_on) else stringResource(R.string.bmt_off))
|
||||
StatusKeyValue(stringResource(R.string.bmt_current_app), status.currentApp ?: "—")
|
||||
StatusKeyValue(
|
||||
"Accessibility service",
|
||||
if (status.accessibilityEnabled) "Enabled" else "Disabled"
|
||||
stringResource(R.string.bsc_accessibility_service),
|
||||
if (status.accessibilityEnabled) stringResource(R.string.bsc_enabled) else stringResource(R.string.bsc_disabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("LocalContextGetResourceValueCall")
|
||||
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
@@ -31,10 +33,12 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -152,6 +156,10 @@ fun ChatImageViewer(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
val cdClose = stringResource(R.string.cd_close_viewer)
|
||||
val errorMsg = context.getString(R.string.image_viewer_error)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
@@ -159,7 +167,7 @@ fun ChatImageViewer(
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, "Couldn't load this image")
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
@@ -168,8 +176,10 @@ fun ChatImageViewer(
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = "Share")
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
@@ -177,13 +187,13 @@ fun ChatImageViewer(
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, "Couldn't load this image")
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, "Saved to ${result.location}")
|
||||
toast(context, savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
@@ -192,17 +202,17 @@ fun ChatImageViewer(
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, "Save failed: ${result.message}")
|
||||
toast(context, failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = "Save")
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close")
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
@@ -213,7 +215,7 @@ fun ChatInputBar(
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "Live voice conversation",
|
||||
text = stringResource(R.string.chat_input_live_voice_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
@@ -291,15 +293,15 @@ fun ChatInputBar(
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = { attachMenuExpanded = true },
|
||||
onClickLabel = "Add attachment",
|
||||
onClickLabel = stringResource(R.string.chat_input_add_attachment),
|
||||
onLongClick = onLongPressAttach,
|
||||
onLongClickLabel = "Browse commands",
|
||||
onLongClickLabel = stringResource(R.string.chat_input_browse_commands),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "Add attachment; hold for commands",
|
||||
contentDescription = stringResource(R.string.chat_input_add_attachment_hold),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -308,7 +310,7 @@ fun ChatInputBar(
|
||||
onDismissRequest = { attachMenuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Photos") },
|
||||
text = { Text(stringResource(R.string.chat_input_photos)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.PhotoLibrary, contentDescription = null)
|
||||
},
|
||||
@@ -318,7 +320,7 @@ fun ChatInputBar(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Files") },
|
||||
text = { Text(stringResource(R.string.chat_input_files)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.InsertDriveFile, contentDescription = null)
|
||||
},
|
||||
@@ -328,7 +330,7 @@ fun ChatInputBar(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Camera") },
|
||||
text = { Text(stringResource(R.string.chat_input_camera)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.PhotoCamera, contentDescription = null)
|
||||
},
|
||||
@@ -338,7 +340,7 @@ fun ChatInputBar(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Paste image") },
|
||||
text = { Text(stringResource(R.string.chat_input_paste_image)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.ContentPaste, contentDescription = null)
|
||||
},
|
||||
@@ -393,7 +395,7 @@ fun ChatInputBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send message",
|
||||
contentDescription = stringResource(R.string.chat_input_send_message),
|
||||
tint = if (enabled) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -403,8 +405,8 @@ fun ChatInputBar(
|
||||
IconButton(onClick = onVoice) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = if (voiceReady) "Start voice conversation"
|
||||
else "Voice conversation — setup needed",
|
||||
contentDescription = if (voiceReady) stringResource(R.string.chat_input_start_voice)
|
||||
else stringResource(R.string.chat_input_voice_setup_needed),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
@@ -431,7 +433,7 @@ fun ChatInputBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = "Stop streaming",
|
||||
contentDescription = stringResource(R.string.chat_input_stop_streaming),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
@@ -444,7 +446,7 @@ fun ChatInputBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Steer the response",
|
||||
contentDescription = stringResource(R.string.chat_input_steer_response),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
@@ -456,7 +458,7 @@ fun ChatInputBar(
|
||||
Box {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Queue message",
|
||||
contentDescription = stringResource(R.string.chat_input_queue_message),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Icon(
|
||||
|
||||
@@ -43,9 +43,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
import com.hermesandroid.relay.R
|
||||
/**
|
||||
* A slash command entry — built-in, personality, server skill, or (on the
|
||||
* gateway transport) a server-catalog command from `commands.catalog`.
|
||||
@@ -140,11 +141,11 @@ fun CommandPalette(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Commands",
|
||||
text = stringResource(R.string.command_palette_header),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "${filtered.size} available",
|
||||
text = stringResource(R.string.command_palette_count, filtered.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -159,7 +160,7 @@ fun CommandPalette(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
placeholder = { Text("Search commands...") },
|
||||
placeholder = { Text(stringResource(R.string.command_palette_search)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
@@ -172,7 +173,7 @@ fun CommandPalette(
|
||||
IconButton(onClick = { searchQuery = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Clear search",
|
||||
contentDescription = stringResource(R.string.command_palette_cd_clear),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
@@ -202,7 +203,7 @@ fun CommandPalette(
|
||||
FilterChip(
|
||||
selected = selectedCategory == null,
|
||||
onClick = { selectedCategory = null },
|
||||
label = { Text("All") },
|
||||
label = { Text(stringResource(R.string.command_palette_filter_all)) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
@@ -233,8 +234,8 @@ fun CommandPalette(
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (categoriesExpanded) "Show less"
|
||||
else "Show all (${categories.size})",
|
||||
text = if (categoriesExpanded) stringResource(R.string.command_palette_show_less)
|
||||
else stringResource(R.string.command_palette_show_all, categories.size),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
@@ -283,7 +284,7 @@ fun CommandPalette(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No commands match your search",
|
||||
text = stringResource(R.string.command_palette_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -24,9 +24,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
|
||||
@Composable
|
||||
@@ -36,16 +38,18 @@ fun CompactToolCall(
|
||||
) {
|
||||
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
isPreparing -> "preparing"
|
||||
else -> "running"
|
||||
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
|
||||
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
|
||||
isPreparing -> stringResource(R.string.tool_preparing_a11y)
|
||||
else -> stringResource(R.string.tool_progress_status_running)
|
||||
}
|
||||
|
||||
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
|
||||
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
|
||||
String.format("%.1fs", seconds)
|
||||
} else null
|
||||
val durationDescription = duration?.let { stringResource(R.string.tool_duration_a11y, it) }.orEmpty()
|
||||
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -53,7 +57,7 @@ fun CompactToolCall(
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.semantics {
|
||||
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
|
||||
contentDescription = toolDescription
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -91,7 +95,7 @@ fun CompactToolCall(
|
||||
).value
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreHoriz,
|
||||
contentDescription = "Preparing",
|
||||
contentDescription = stringResource(R.string.tool_preparing_a11y),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)
|
||||
)
|
||||
@@ -106,7 +110,7 @@ fun CompactToolCall(
|
||||
toolCall.success == true -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Completed",
|
||||
contentDescription = stringResource(R.string.tool_completed_a11y),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@@ -114,7 +118,7 @@ fun CompactToolCall(
|
||||
else -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Failed",
|
||||
contentDescription = stringResource(R.string.tool_failed_a11y),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
|
||||
@@ -12,8 +12,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Compact top-bar chip showing the currently-active Hermes connection.
|
||||
@@ -40,7 +42,7 @@ fun ConnectionChip(
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Switch connection",
|
||||
contentDescription = stringResource(R.string.cd_switch_connection),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+243
-160
File diff suppressed because it is too large
Load Diff
+19
-22
@@ -25,6 +25,8 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurity
|
||||
@@ -79,7 +81,7 @@ fun ConnectionSecuritySheet(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Connection security",
|
||||
text = stringResource(R.string.security_sheet_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
@@ -93,8 +95,7 @@ fun ConnectionSecuritySheet(
|
||||
|
||||
if (security.surfaces.isEmpty()) {
|
||||
Text(
|
||||
text = "No active route yet. Connect to a server to see how each " +
|
||||
"part of the connection is protected.",
|
||||
text = stringResource(R.string.security_sheet_no_route),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -104,14 +105,17 @@ fun ConnectionSecuritySheet(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = explainer(security.level),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val explainerTextRes = explainerResOrNull(security.level)
|
||||
if (explainerTextRes != null) {
|
||||
Text(
|
||||
text = stringResource(explainerTextRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = { uriHandler.openUri(LEARN_MORE_URL) }) {
|
||||
Text("Learn about connection security →")
|
||||
Text(stringResource(R.string.security_sheet_learn_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,17 +149,10 @@ private fun SurfaceSecurityRow(surface: SurfaceSecurity) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun explainer(level: ConnectionSecurityLevel): String = when (level) {
|
||||
ConnectionSecurityLevel.Tls ->
|
||||
"Encrypted with TLS. The server's certificate is pinned on first connect."
|
||||
ConnectionSecurityLevel.Overlay ->
|
||||
"Encrypted by your overlay network (e.g. Tailscale/WireGuard), not TLS. " +
|
||||
"Cert pinning applies only to TLS routes."
|
||||
ConnectionSecurityLevel.Mixed ->
|
||||
"Some parts of this connection are encrypted and some are plain. The app " +
|
||||
"prefers a secure route when one is reachable."
|
||||
ConnectionSecurityLevel.Plain ->
|
||||
"Not encrypted. Only safe on a network you fully trust — anyone in between " +
|
||||
"could read this traffic."
|
||||
ConnectionSecurityLevel.Unknown -> ""
|
||||
private fun explainerResOrNull(level: ConnectionSecurityLevel): Int? = when (level) {
|
||||
ConnectionSecurityLevel.Tls -> R.string.security_sheet_explain_tls
|
||||
ConnectionSecurityLevel.Overlay -> R.string.security_sheet_explain_overlay
|
||||
ConnectionSecurityLevel.Mixed -> R.string.security_sheet_explain_mixed
|
||||
ConnectionSecurityLevel.Plain -> R.string.security_sheet_explain_plain
|
||||
ConnectionSecurityLevel.Unknown -> null
|
||||
}
|
||||
|
||||
+9
-9
@@ -23,8 +23,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
|
||||
/**
|
||||
@@ -61,7 +63,7 @@ fun ConnectionSwitcherSheet(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Switch connection",
|
||||
text = stringResource(R.string.conn_switcher_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
@@ -70,7 +72,7 @@ fun ConnectionSwitcherSheet(
|
||||
// Defensive: the legacy migration should always seed connection 0,
|
||||
// but fall back to a Manage-only state if the list is empty.
|
||||
Text(
|
||||
text = "No connections yet",
|
||||
text = stringResource(R.string.conn_switcher_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
@@ -79,7 +81,7 @@ fun ConnectionSwitcherSheet(
|
||||
onClick = onManageConnections,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Manage connections…")
|
||||
Text(stringResource(R.string.conn_switcher_manage))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
@@ -105,7 +107,7 @@ fun ConnectionSwitcherSheet(
|
||||
onClick = onManageConnections,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Manage connections…")
|
||||
Text(stringResource(R.string.conn_switcher_manage))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,11 +121,9 @@ private fun ConnectionRow(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = if (connection.pairedAt == null) {
|
||||
"$hostname • Hermes"
|
||||
} else {
|
||||
"$hostname • Paired"
|
||||
}
|
||||
val hermesStatus = stringResource(R.string.conn_info_hostname_hermes, hostname)
|
||||
val pairedStatus = stringResource(R.string.conn_info_hostname_paired, hostname)
|
||||
val statusLine = if (connection.pairedAt == null) hermesStatus else pairedStatus
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,9 +35,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -120,13 +122,15 @@ private fun ContextMeterBarContent(
|
||||
}
|
||||
val label = "$percent%$tokenSuffix"
|
||||
|
||||
val contextUsedDesc = stringResource(R.string.cd_context_used, percent)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.semantics {
|
||||
contentDescription = "Context $percent% used" +
|
||||
contentDescription = contextUsedDesc +
|
||||
if (tokenSuffix.isNotEmpty()) ", ${fmtTokens(usedTokens!!)} of ${fmtTokens(maxTokens!!)} tokens" else ""
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -158,7 +162,7 @@ private fun ContextMeterBarContent(
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = "View injected context",
|
||||
contentDescription = stringResource(R.string.cd_view_injected),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
|
||||
@@ -38,12 +38,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.CrashReport
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
@@ -111,7 +113,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Hermes-Relay closed unexpectedly",
|
||||
text = stringResource(R.string.crash_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
@@ -119,8 +121,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "The last session crashed. Sending this report helps get it fixed — " +
|
||||
"nothing is sent automatically.",
|
||||
text = stringResource(R.string.crash_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -155,13 +156,13 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Crash report copied")
|
||||
},
|
||||
) { Text("Copy") }
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
@@ -180,7 +181,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text("Share") }
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy the FULL report first; the URL only carries the
|
||||
@@ -194,7 +195,7 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text("Report") }
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
/**
|
||||
@@ -76,7 +78,7 @@ fun DemoModeBanner(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Demo mode — sample data, not connected. Connect →",
|
||||
text = stringResource(R.string.dmb_demo_mode),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = on,
|
||||
@@ -125,19 +127,19 @@ fun DemoUnavailableContent(
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Text(
|
||||
text = "This is a demo",
|
||||
text = stringResource(R.string.dmb_this_is_demo),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Connect your Hermes server to use $feature.",
|
||||
text = stringResource(R.string.dmb_connect_feature, feature),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Button(onClick = onConnect) {
|
||||
Text("Connect")
|
||||
Text(stringResource(R.string.dmb_connect))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-9
@@ -35,6 +35,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Phase 3 — safety-rails `bridge-safety-rails`
|
||||
@@ -106,7 +108,7 @@ fun DestructiveVerbConfirmDialog(
|
||||
tint = Color(0xFFFFA726),
|
||||
)
|
||||
Text(
|
||||
text = "Confirm destructive action",
|
||||
text = stringResource(R.string.destructive_confirm_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@@ -135,7 +137,7 @@ fun DestructiveVerbConfirmDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = fullText.ifBlank { "(no text)" },
|
||||
text = fullText.ifBlank { stringResource(R.string.destructive_no_text) },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -143,7 +145,7 @@ fun DestructiveVerbConfirmDialog(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "If you didn't expect this, tap Deny.",
|
||||
text = stringResource(R.string.destructive_confirm_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -184,7 +186,7 @@ fun DestructiveVerbConfirmDialog(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onDeny,
|
||||
) {
|
||||
Text("Deny")
|
||||
Text(stringResource(R.string.destructive_confirm_deny))
|
||||
}
|
||||
// Allow is intentionally lower-emphasis (amber caution, not a
|
||||
// loud red CTA) so proceeding with a risky action never reads
|
||||
@@ -196,7 +198,7 @@ fun DestructiveVerbConfirmDialog(
|
||||
contentColor = Color(0xFFE65100),
|
||||
),
|
||||
) {
|
||||
Text("Allow this action")
|
||||
Text(stringResource(R.string.destructive_confirm_allow))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,12 +206,14 @@ fun DestructiveVerbConfirmDialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun labelFor(method: String): String = when (method) {
|
||||
"/tap_text" -> "Target text (tap)"
|
||||
"/type" -> "Text to type"
|
||||
else -> "Payload"
|
||||
"/tap_text" -> stringResource(R.string.destructive_label_tap_text)
|
||||
"/type" -> stringResource(R.string.destructive_label_type)
|
||||
else -> stringResource(R.string.destructive_label_payload)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun verbPhrase(method: String, verb: String): String {
|
||||
val action = when (method) {
|
||||
"/tap_text" -> "tap a button containing"
|
||||
@@ -236,7 +240,7 @@ fun BridgeStatusOverlayChip(unattended: Boolean = false) {
|
||||
// the device. Default red dot + "Hermes active" preserves the v0.4
|
||||
// appearance for the regular bridge-active path.
|
||||
val dotColor = if (unattended) Color(0xFFFFA000) else Color(0xFFE53935)
|
||||
val label = if (unattended) "Unattended ON" else "Hermes active"
|
||||
val label = if (unattended) stringResource(R.string.destructive_chip_unattended) else stringResource(R.string.destructive_chip_active)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,6 +41,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.util.DiagnosticIssuePrefill
|
||||
@@ -133,7 +135,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "No further detail captured for this entry.",
|
||||
text = stringResource(R.string.diagnostic_no_detail),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -146,7 +148,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
onValueChange = { expectation = it },
|
||||
label = { Text("What were you expecting to happen?") },
|
||||
supportingText = {
|
||||
Text("This is a routine log entry — telling us what looked wrong turns it into an answerable report.")
|
||||
Text(stringResource(R.string.diagnostic_routine_hint))
|
||||
},
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -159,13 +161,13 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, "Diagnostic copied")
|
||||
},
|
||||
) { Text("Copy") }
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
@@ -179,7 +181,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
toast(context, "Copied — no app found to share to")
|
||||
}
|
||||
},
|
||||
) { Text("Export") }
|
||||
) { Text(stringResource(R.string.common_export)) }
|
||||
Button(
|
||||
enabled = !expectationVisible || expectation.isNotBlank(),
|
||||
onClick = {
|
||||
@@ -207,7 +209,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
else "Copied — no browser found to open GitHub",
|
||||
)
|
||||
},
|
||||
) { Text("Report") }
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
@@ -43,7 +45,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
@Composable
|
||||
fun DiagnosticsLogPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Recent activity",
|
||||
title: String? = null,
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 8,
|
||||
showCategory: Boolean = false,
|
||||
@@ -51,6 +53,7 @@ fun DiagnosticsLogPanel(
|
||||
showSeverityFilter: Boolean = false,
|
||||
) {
|
||||
val entries by DiagnosticsLog.entries.collectAsState()
|
||||
val resolvedTitle = title ?: stringResource(R.string.diagnostics_title)
|
||||
|
||||
// Self-contained detail-view state — tapping a row opens DiagnosticDetailDialog.
|
||||
// No nav route; nothing to wire in RelayApp.
|
||||
@@ -74,13 +77,13 @@ fun DiagnosticsLogPanel(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
text = resolvedTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (showClear && entries.isNotEmpty()) {
|
||||
TextButton(onClick = { DiagnosticsLog.clear() }) {
|
||||
Text("Clear")
|
||||
Text(stringResource(R.string.diagnostics_clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +93,7 @@ fun DiagnosticsLogPanel(
|
||||
FilterChip(
|
||||
selected = severityFilter == null,
|
||||
onClick = { severityFilter = null },
|
||||
label = { Text("All") },
|
||||
label = { Text(stringResource(R.string.diagnostics_all)) },
|
||||
)
|
||||
DiagnosticSeverity.entries.forEach { sev ->
|
||||
FilterChip(
|
||||
@@ -104,7 +107,7 @@ fun DiagnosticsLogPanel(
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
Text(
|
||||
text = "No recent activity",
|
||||
text = stringResource(R.string.diagnostics_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -44,8 +44,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.SurfaceSecurityKind
|
||||
@@ -114,19 +116,25 @@ fun EndpointsCard(
|
||||
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
) {
|
||||
// Pre-resolve strings
|
||||
val noRoutesStoredText = stringResource(R.string.endpoints_no_routes_stored)
|
||||
val addRouteText = stringResource(R.string.endpoints_add_route)
|
||||
val resolvingText = stringResource(R.string.endpoints_resolving)
|
||||
val manualUntilDisconnectText = stringResource(R.string.endpoints_manual_until_disconnect)
|
||||
val preferredText = stringResource(R.string.endpoints_preferred)
|
||||
val automaticText = stringResource(R.string.endpoints_automatic)
|
||||
val currentRouteText = stringResource(R.string.endpoints_current_route)
|
||||
|
||||
if (endpoints.isEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "No route candidates stored for this connection yet. " +
|
||||
"Add a remote route (Tailscale, public URL) for automatic " +
|
||||
"switching when the phone leaves this network — or scan a " +
|
||||
"v3 pairing QR (Hermes 0.4.2+) if you use Relay.",
|
||||
text = noRoutesStoredText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (onAddRoute != null) {
|
||||
TextButton(onClick = onAddRoute) {
|
||||
Text("Add route")
|
||||
Text(addRouteText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,13 +147,20 @@ fun EndpointsCard(
|
||||
val manualSwitchActive = manualOverrideRole != null &&
|
||||
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
|
||||
|
||||
// Pre-resolve cancel manual switch string (needs preferredRole which may be null)
|
||||
val cancelManualSwitchText = stringResource(R.string.endpoints_cancel_manual_switch)
|
||||
val stopPreferringText = stringResource(R.string.endpoints_stop_preferring)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}" + when {
|
||||
manualSwitchActive -> " · manual (until disconnect)"
|
||||
manualOverrideRole != null -> " · preferred"
|
||||
else -> " · automatic"
|
||||
},
|
||||
text = currentRouteText.format(
|
||||
activeEndpoint?.displayLabel() ?: resolvingText,
|
||||
when {
|
||||
manualSwitchActive -> manualUntilDisconnectText
|
||||
manualOverrideRole != null -> preferredText
|
||||
else -> automaticText
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -175,22 +190,20 @@ fun EndpointsCard(
|
||||
if (onAddRoute != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Add route")
|
||||
Text(addRouteText)
|
||||
}
|
||||
}
|
||||
|
||||
if (manualSwitchActive) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onCancelUseNow, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Cancel manual switch (back to ${preferredRole ?: "automatic"})",
|
||||
)
|
||||
Text(cancelManualSwitchText.format(preferredRole ?: automaticText))
|
||||
}
|
||||
}
|
||||
if (preferredRole != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onClearPreferred, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Stop preferring $preferredRole (back to automatic)")
|
||||
Text(stopPreferringText.format(preferredRole))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +231,7 @@ private fun EndpointRow(
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
@@ -246,11 +260,11 @@ private fun EndpointRow(
|
||||
)
|
||||
SurfaceSecurityGlyph(kind = candidate.routeSecurityKind())
|
||||
if (isActive) {
|
||||
ActiveChip()
|
||||
ActiveChip(stringResource(R.string.endpoints_active))
|
||||
} else if (isPreferred) {
|
||||
PreferredChip()
|
||||
PreferredChip(stringResource(R.string.endpoints_preferred_chip))
|
||||
} else {
|
||||
FallbackChip()
|
||||
FallbackChip(stringResource(R.string.endpoints_fallback))
|
||||
}
|
||||
if (!candidate.isKnownRole()) {
|
||||
// Show the raw role for custom-VPN entries so users
|
||||
@@ -277,18 +291,18 @@ private fun EndpointRow(
|
||||
)
|
||||
when {
|
||||
isProbing -> Text(
|
||||
text = "Checking…",
|
||||
text = stringResource(R.string.endpoints_checking),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
outcome == null -> Unit // never probed — say nothing
|
||||
outcome.reachable -> Text(
|
||||
text = "Reachable",
|
||||
text = stringResource(R.string.endpoints_reachable),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
else -> Text(
|
||||
text = "Unreachable — ${outcome.detail ?: "no detail"}",
|
||||
text = stringResource(R.string.endpoints_unreachable, outcome.detail ?: stringResource(R.string.endpoints_unreachable_no_detail)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -304,14 +318,14 @@ private fun EndpointRow(
|
||||
// sticky "Prefer this route" lives in the menu below.
|
||||
if (!isActive) {
|
||||
TextButton(onClick = onUseNow) {
|
||||
Text("Use now")
|
||||
Text(stringResource(R.string.endpoints_use_now))
|
||||
}
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "Endpoint actions",
|
||||
contentDescription = stringResource(R.string.endpoints_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -322,13 +336,13 @@ private fun EndpointRow(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (isPreferred) "Stop preferring" else "Prefer this route",
|
||||
text = if (isPreferred) stringResource(R.string.endpoints_stop_preferring_menu) else stringResource(R.string.endpoints_prefer_this_route),
|
||||
)
|
||||
Text(
|
||||
text = if (isPreferred) {
|
||||
"Back to automatic choice"
|
||||
stringResource(R.string.endpoints_back_to_automatic)
|
||||
} else {
|
||||
"Always try this route first"
|
||||
stringResource(R.string.endpoints_always_try_first)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -341,26 +355,25 @@ private fun EndpointRow(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Probe now") },
|
||||
text = { Text(stringResource(R.string.endpoints_probe_now)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onProbeNow()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("View pin") },
|
||||
text = { Text(stringResource(R.string.endpoints_view_pin)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
scope.launch {
|
||||
pinDialogText = onViewPin(candidate)
|
||||
?: "No pin recorded yet — the phone will " +
|
||||
"record one on first TOFU connect."
|
||||
?: noPinRecordedText
|
||||
}
|
||||
},
|
||||
)
|
||||
if (onEdit != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit route") },
|
||||
text = { Text(stringResource(R.string.endpoints_edit_route)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onEdit()
|
||||
@@ -369,7 +382,7 @@ private fun EndpointRow(
|
||||
}
|
||||
if (onRemove != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove route") },
|
||||
text = { Text(stringResource(R.string.endpoints_remove_route)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
confirmRemove = true
|
||||
@@ -384,11 +397,10 @@ private fun EndpointRow(
|
||||
if (confirmRemove && onRemove != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRemove = false },
|
||||
title = { Text("Remove ${candidate.displayLabel()} route?") },
|
||||
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
|
||||
text = {
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
|
||||
"probed as a fallback. You can add it back any time.",
|
||||
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
@@ -398,10 +410,10 @@ private fun EndpointRow(
|
||||
confirmRemove = false
|
||||
onRemove()
|
||||
},
|
||||
) { Text("Remove") }
|
||||
) { Text(stringResource(R.string.endpoints_remove)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
|
||||
TextButton(onClick = { confirmRemove = false }) { Text(stringResource(R.string.endpoints_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -409,7 +421,7 @@ private fun EndpointRow(
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
title = { Text("TOFU pin · ${candidate.api.host}") },
|
||||
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) },
|
||||
text = {
|
||||
Text(
|
||||
text = body,
|
||||
@@ -418,14 +430,14 @@ private fun EndpointRow(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { pinDialogText = null }) { Text("Close") }
|
||||
TextButton(onClick = { pinDialogText = null }) { Text(stringResource(R.string.endpoints_close)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveChip() {
|
||||
private fun ActiveChip(label: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
@@ -441,7 +453,7 @@ private fun ActiveChip() {
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = "Active",
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -449,7 +461,7 @@ private fun ActiveChip() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferredChip() {
|
||||
private fun PreferredChip(label: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
@@ -457,7 +469,7 @@ private fun PreferredChip() {
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Preferred",
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFFB26A00),
|
||||
)
|
||||
@@ -471,7 +483,7 @@ private fun PreferredChip() {
|
||||
* as "available fallback" not "something is happening here".
|
||||
*/
|
||||
@Composable
|
||||
private fun FallbackChip() {
|
||||
private fun FallbackChip(label: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
@@ -484,7 +496,7 @@ private fun FallbackChip() {
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Fallback",
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -560,13 +572,16 @@ fun RouteEditorDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(if (original == null) "Add route" else "Edit route") },
|
||||
title = {
|
||||
Text(
|
||||
if (original == null) stringResource(R.string.endpoints_add_route_title)
|
||||
else stringResource(R.string.endpoints_edit_route_title)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "A fallback route the phone switches to when the " +
|
||||
"primary stops answering — e.g. your server's " +
|
||||
"Tailscale or public URL.",
|
||||
text = stringResource(R.string.endpoints_route_editor_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -574,25 +589,25 @@ fun RouteEditorDialog(
|
||||
FilterChip(
|
||||
selected = selectedRole == "tailscale",
|
||||
onClick = { selectedRole = "tailscale" },
|
||||
label = { Text("Tailscale") },
|
||||
label = { Text(stringResource(R.string.endpoints_tailscale)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == "public",
|
||||
onClick = { selectedRole = "public" },
|
||||
label = { Text("Public") },
|
||||
label = { Text(stringResource(R.string.endpoints_public)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == CUSTOM_ROLE,
|
||||
onClick = { selectedRole = CUSTOM_ROLE },
|
||||
label = { Text("Custom") },
|
||||
label = { Text(stringResource(R.string.endpoints_custom)) },
|
||||
)
|
||||
}
|
||||
if (selectedRole == CUSTOM_ROLE) {
|
||||
OutlinedTextField(
|
||||
value = customRole,
|
||||
onValueChange = { customRole = it },
|
||||
label = { Text("Route name") },
|
||||
placeholder = { Text("wireguard-home") },
|
||||
label = { Text(stringResource(R.string.endpoints_route_name)) },
|
||||
placeholder = { Text(stringResource(R.string.endpoints_route_name_placeholder)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -616,23 +631,21 @@ fun RouteEditorDialog(
|
||||
url = it
|
||||
errorText = null
|
||||
},
|
||||
label = { Text("API server URL or host") },
|
||||
placeholder = { Text("100.64.0.1 or http://host:8642") },
|
||||
label = { Text(stringResource(R.string.endpoints_api_url_host)) },
|
||||
placeholder = { Text(stringResource(R.string.endpoints_url_host_placeholder)) },
|
||||
singleLine = true,
|
||||
isError = errorText != null,
|
||||
supportingText = {
|
||||
val supportingBlank = stringResource(R.string.endpoints_url_supporting_blank)
|
||||
val supportingEnter = stringResource(R.string.endpoints_url_supporting_enter)
|
||||
Text(
|
||||
text = errorText ?: when {
|
||||
url.isBlank() ->
|
||||
"Use the API server port (8642 by default) — " +
|
||||
"not the dashboard's 9119. http:// is " +
|
||||
"assumed unless you type https://."
|
||||
supportingBlank
|
||||
previewCandidate != null ->
|
||||
"Will save: ${previewCandidate.api.url} — relay " +
|
||||
"and dashboard URLs are derived from the host"
|
||||
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
|
||||
else ->
|
||||
"Enter a host/IP or an http(s):// URL " +
|
||||
"(API port 8642, not dashboard 9119)"
|
||||
supportingEnter
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -655,10 +668,17 @@ fun RouteEditorDialog(
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
) {
|
||||
Text(
|
||||
if (saving) stringResource(R.string.endpoints_saving)
|
||||
else stringResource(R.string.endpoints_save)
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss, enabled = !saving) {
|
||||
Text(stringResource(R.string.endpoints_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
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.R
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel.SpecialKey
|
||||
|
||||
/**
|
||||
@@ -88,20 +90,20 @@ fun ExtraKeysToolbar(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ToolbarKey(label = "ESC", onClick = onEsc)
|
||||
ToolbarKey(label = "TAB", onClick = onTab)
|
||||
ToolbarKey(label = "CTRL", active = ctrlActive, onClick = onCtrlToggle)
|
||||
ToolbarKey(label = "ALT", active = altActive, onClick = onAltToggle)
|
||||
ToolbarKey(label = stringResource(R.string.extra_keys_esc), onClick = onEsc)
|
||||
ToolbarKey(label = stringResource(R.string.extra_keys_tab), onClick = onTab)
|
||||
ToolbarKey(label = stringResource(R.string.extra_keys_ctrl), active = ctrlActive, onClick = onCtrlToggle)
|
||||
ToolbarKey(label = stringResource(R.string.extra_keys_alt), active = altActive, onClick = onAltToggle)
|
||||
|
||||
// Clipboard + keyboard cluster — selecting/copying/pasting and
|
||||
// raising the soft keyboard are all unreliable through long-press
|
||||
// inside an Android WebView, so these explicit keys are the
|
||||
// dependable path.
|
||||
onCopy?.let { copy ->
|
||||
ToolbarKey(label = "COPY", onClick = copy)
|
||||
ToolbarKey(label = stringResource(R.string.extra_keys_copy), onClick = copy)
|
||||
}
|
||||
onPaste?.let { paste ->
|
||||
ToolbarKey(label = "PASTE", onClick = paste)
|
||||
ToolbarKey(label = stringResource(R.string.extra_keys_paste), onClick = paste)
|
||||
}
|
||||
onToggleKeyboard?.let { toggle ->
|
||||
ToolbarKey(label = "⌨", onClick = toggle) // show/hide soft keyboard
|
||||
|
||||
+10
-8
@@ -47,12 +47,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
|
||||
/**
|
||||
@@ -119,7 +121,7 @@ fun GatewayBackgroundProcessStrip(
|
||||
}
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Text(
|
||||
text = "Background · $displayedCount",
|
||||
text = stringResource(R.string.background_process_count, displayedCount),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -176,9 +178,9 @@ fun GatewayBackgroundProcessSheet(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Background processes", style = MaterialTheme.typography.titleLarge)
|
||||
Text(stringResource(R.string.background_processes_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
"Current chat · live output and recent results",
|
||||
stringResource(R.string.background_processes_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -187,7 +189,7 @@ fun GatewayBackgroundProcessSheet(
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh processes")
|
||||
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.background_processes_refresh_a11y))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,10 +324,10 @@ private fun GatewayProcessRow(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(if (stopping) "Stopping" else "Stop")
|
||||
Text(if (stopping) stringResource(R.string.background_processes_stopping) else stringResource(R.string.background_processes_stop))
|
||||
}
|
||||
} else if (onDismiss != null) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
|
||||
}
|
||||
if (output.isNotBlank()) {
|
||||
Icon(
|
||||
@@ -379,13 +381,13 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
|
||||
)
|
||||
failed -> Icon(
|
||||
Icons.Filled.ErrorOutline,
|
||||
contentDescription = "Failed",
|
||||
contentDescription = stringResource(R.string.tool_failed_a11y),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tint,
|
||||
)
|
||||
else -> Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = "Completed",
|
||||
contentDescription = stringResource(R.string.tool_completed_a11y),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tint,
|
||||
)
|
||||
|
||||
@@ -70,10 +70,12 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
@@ -134,6 +136,7 @@ fun HermesCardBubble(
|
||||
val accentColor = accentToColor(card.accent)
|
||||
val typeIcon = iconForType(card.type)
|
||||
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
|
||||
val cardDescription = stringResource(R.string.card_a11y, card.title ?: card.type)
|
||||
|
||||
// Expiry clock for timed asks. Ticks once a second while the deadline
|
||||
// is ahead; freezes after. Keyed on the deadline so a re-used card id
|
||||
@@ -155,7 +158,7 @@ fun HermesCardBubble(
|
||||
.widthIn(max = maxWidth)
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Card: ${card.title ?: card.type}"
|
||||
contentDescription = cardDescription
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
@@ -260,7 +263,7 @@ fun HermesCardBubble(
|
||||
expired -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ChoseRow(
|
||||
text = "Expired — not granted",
|
||||
text = stringResource(R.string.card_expired),
|
||||
icon = Icons.Filled.HourglassBottom,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -293,7 +296,7 @@ fun HermesCardBubble(
|
||||
val remainingSec = ((expiresAt - nowMillis) / 1000).coerceAtLeast(0)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "expires in %d:%02d".format(remainingSec / 60, remainingSec % 60),
|
||||
text = stringResource(R.string.card_expires_in, remainingSec / 60, remainingSec % 60),
|
||||
style = relayMetadataStyle(),
|
||||
color = if (remainingSec < 30) RelayRefresh.Amber
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -459,7 +462,7 @@ private fun CardInputSlot(
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Not stored in chat history",
|
||||
text = stringResource(R.string.card_not_stored),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
@@ -480,7 +483,7 @@ private fun CardInputSlot(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send answer",
|
||||
contentDescription = stringResource(R.string.card_send_answer_a11y),
|
||||
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
@@ -512,7 +515,7 @@ private fun CardInputSlot(
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) { Text("Submit", style = MaterialTheme.typography.labelMedium) }
|
||||
) { Text(stringResource(R.string.card_submit), style = MaterialTheme.typography.labelMedium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -538,7 +541,7 @@ private fun InlineAnswerField(
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = "Type an answer…",
|
||||
text = stringResource(R.string.card_answer_placeholder),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
|
||||
+32
-21
@@ -7,6 +7,8 @@ import android.graphics.pdf.PdfRenderer
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -153,7 +155,7 @@ private fun LoadingCard(
|
||||
val sizeHint = attachment.fileSize?.takeIf { it > 0 }
|
||||
?.let { " · ${formatBytes(it)}" } ?: ""
|
||||
Text(
|
||||
text = if (isManualCta) "Tap to download" else "Downloading…$sizeHint",
|
||||
text = if (isManualCta) stringResource(R.string.inbound_attach_tap_download) else stringResource(R.string.inbound_attach_downloading, sizeHint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -175,7 +177,7 @@ private fun LoadingCard(
|
||||
IconButton(onClick = onCancel, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Cancel download",
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_cancel),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
@@ -219,12 +221,12 @@ private fun FailedCard(
|
||||
Text(text = "\u26A0\uFE0F", style = MaterialTheme.typography.titleMedium)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = attachment.errorMessage ?: "Attachment failed",
|
||||
text = attachment.errorMessage ?: stringResource(R.string.inbound_attach_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Text(
|
||||
text = "Tap to retry",
|
||||
text = stringResource(R.string.inbound_attach_tap_retry),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
@@ -467,7 +469,7 @@ private fun FileCardRender(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = "Save",
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
@@ -500,7 +502,7 @@ fun AttachmentActionsMenu(
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Open externally") },
|
||||
text = { Text(stringResource(R.string.inbound_attach_open)) },
|
||||
leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
@@ -508,14 +510,14 @@ fun AttachmentActionsMenu(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Share") },
|
||||
text = { Text(stringResource(R.string.inbound_attach_share)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
scope.launch { shareAttachment(context, attachment) }
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save to device") },
|
||||
text = { Text(stringResource(R.string.inbound_attach_save)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
@@ -536,7 +538,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = "Save",
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
@@ -549,7 +551,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
private suspend fun shareAttachment(context: Context, attachment: Attachment) {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, "Couldn't read this file")
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
return
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
@@ -559,7 +561,7 @@ private suspend fun shareAttachment(context: Context, attachment: Attachment) {
|
||||
suspend fun saveAttachment(context: Context, attachment: Attachment) {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, "Couldn't read this file")
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
return
|
||||
}
|
||||
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
|
||||
@@ -569,13 +571,13 @@ suspend fun saveAttachment(context: Context, attachment: Attachment) {
|
||||
}
|
||||
when (result) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
attachmentToast(context, "Saved to ${result.location}")
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
attachmentToast(context, "Save failed: ${result.message}")
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,7 +593,7 @@ private suspend fun openAttachmentExternally(context: Context, attachment: Attac
|
||||
if (uri != null) {
|
||||
MediaSaver.open(context, uri, attachment.contentType)
|
||||
} else {
|
||||
attachmentToast(context, "Couldn't open this file")
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,16 +709,25 @@ private fun attachmentToast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun emojiAndLabelFor(
|
||||
mode: AttachmentRenderMode,
|
||||
contentType: String
|
||||
): Pair<String, String> = when (mode) {
|
||||
AttachmentRenderMode.IMAGE -> "\uD83D\uDDBC\uFE0F" to "Image"
|
||||
AttachmentRenderMode.VIDEO -> "\uD83C\uDFAC" to "Video"
|
||||
AttachmentRenderMode.AUDIO -> "\uD83C\uDFB5" to "Audio"
|
||||
AttachmentRenderMode.PDF -> "\uD83D\uDCC4" to "PDF"
|
||||
AttachmentRenderMode.TEXT -> "\uD83D\uDCDD" to contentTypeToLabel(contentType, fallback = "Text")
|
||||
AttachmentRenderMode.GENERIC -> "\uD83D\uDCCE" to contentTypeToLabel(contentType, fallback = "File")
|
||||
): Pair<String, String> {
|
||||
val imageLabel = stringResource(R.string.inbound_attach_type_image)
|
||||
val videoLabel = stringResource(R.string.inbound_attach_type_video)
|
||||
val audioLabel = stringResource(R.string.inbound_attach_type_audio)
|
||||
val pdfLabel = stringResource(R.string.inbound_attach_type_pdf)
|
||||
val textLabel = stringResource(R.string.inbound_attach_type_text)
|
||||
val fileLabel = stringResource(R.string.inbound_attach_type_file)
|
||||
return when (mode) {
|
||||
AttachmentRenderMode.IMAGE -> "\uD83D\uDDBC\uFE0F" to imageLabel
|
||||
AttachmentRenderMode.VIDEO -> "\uD83C\uDFAC" to videoLabel
|
||||
AttachmentRenderMode.AUDIO -> "\uD83C\uDFB5" to audioLabel
|
||||
AttachmentRenderMode.PDF -> "\uD83D\uDCC4" to pdfLabel
|
||||
AttachmentRenderMode.TEXT -> "\uD83D\uDCDD" to contentTypeToLabel(contentType, fallback = textLabel)
|
||||
AttachmentRenderMode.GENERIC -> "\uD83D\uDCCE" to contentTypeToLabel(contentType, fallback = fileLabel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentTypeToLabel(contentType: String, fallback: String): String {
|
||||
|
||||
@@ -16,8 +16,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
|
||||
/**
|
||||
@@ -45,66 +47,67 @@ fun InjectedContextSheet(
|
||||
.padding(start = 20.dp, end = 20.dp, bottom = 28.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "What the agent sees",
|
||||
text = stringResource(R.string.injected_context_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "The exact extra context prepended to your next turn, for " +
|
||||
"transparency. Transport: ${context.transport}.",
|
||||
text = stringResource(R.string.injected_context_subtitle, context.transport),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val personaTitle = stringResource(R.string.injected_context_persona_title)
|
||||
val phoneStatusTitle = stringResource(R.string.injected_context_phone_status_title)
|
||||
val mediaTitle = stringResource(R.string.injected_context_media_title)
|
||||
val relayTitle = stringResource(R.string.injected_context_relay_title)
|
||||
val turnTitle = stringResource(R.string.injected_context_turn_title)
|
||||
val personaServerSide = stringResource(R.string.injected_context_persona_server_side)
|
||||
val personaNotSet = stringResource(R.string.injected_context_persona_not_set)
|
||||
val phoneStatusNotSet = stringResource(R.string.injected_context_phone_status_not_set)
|
||||
val mediaRelayActive = stringResource(R.string.injected_context_media_relay_active)
|
||||
val mediaNoRelay = stringResource(R.string.injected_context_media_no_relay)
|
||||
val relayNotSet = stringResource(R.string.injected_context_relay_not_set)
|
||||
val turnNotSet = stringResource(R.string.injected_context_turn_not_set)
|
||||
|
||||
ContextSection(
|
||||
title = "Persona / profile",
|
||||
title = personaTitle,
|
||||
body = context.personaPrompt,
|
||||
emptyNote = if (context.personaOwnedServerSide) {
|
||||
"Added server-side by Hermes (profile soul + personality " +
|
||||
"overlay) — not sent from this device."
|
||||
personaServerSide
|
||||
} else {
|
||||
"No persona prompt is being sent — the server uses its " +
|
||||
"configured default."
|
||||
personaNotSet
|
||||
},
|
||||
)
|
||||
ContextSection(
|
||||
title = "Phone status",
|
||||
title = phoneStatusTitle,
|
||||
body = context.appContext,
|
||||
emptyNote = "No phone-status block — enable it in App Context settings.",
|
||||
emptyNote = phoneStatusNotSet,
|
||||
)
|
||||
ContextSection(
|
||||
title = "Media capability",
|
||||
title = mediaTitle,
|
||||
body = context.mediaCapability,
|
||||
emptyNote = if (context.relayMediaAvailable) {
|
||||
// Gateway path: media WORKS (client renders server-local images
|
||||
// via the relay) — just no injected hint, since the gateway has
|
||||
// no per-turn system slot. Say so, rather than "not set".
|
||||
"Relay route active — server-local images and files render " +
|
||||
"in-app via the relay (client-side). The gateway transport " +
|
||||
"has no system slot, so no hint is injected here, but media " +
|
||||
"still works."
|
||||
mediaRelayActive
|
||||
} else {
|
||||
"No relay route configured — the agent can't fetch server-local " +
|
||||
"images or files by path."
|
||||
mediaNoRelay
|
||||
},
|
||||
)
|
||||
ContextSection(
|
||||
title = "Relay context (server-side)",
|
||||
title = relayTitle,
|
||||
body = context.relayServerBlocks
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.joinToString("\n\n") { (name, text) ->
|
||||
"[$name]\n$text"
|
||||
},
|
||||
emptyNote = "No relay server-side context blocks are active, or the relay " +
|
||||
"context layer is disabled.",
|
||||
emptyNote = relayNotSet,
|
||||
)
|
||||
ContextSection(
|
||||
title = "This turn",
|
||||
title = turnTitle,
|
||||
body = context.interfaceContext,
|
||||
emptyNote = "Nothing extra for a typed turn. Voice turns add a " +
|
||||
"spoken-output hint here.",
|
||||
emptyNote = turnNotSet,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -23,8 +23,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* One-time acknowledgment dialog shown the first time the user flips
|
||||
@@ -50,10 +52,13 @@ fun InsecureConnectionAckDialog(
|
||||
) {
|
||||
var selectedReason by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val reasonLan = stringResource(R.string.insecure_ack_reason_lan)
|
||||
val reasonTailscale = stringResource(R.string.insecure_ack_reason_tailscale)
|
||||
val reasonDev = stringResource(R.string.insecure_ack_reason_dev)
|
||||
val reasonOptions = listOf(
|
||||
"lan_only" to "LAN only (trusted network)",
|
||||
"tailscale_vpn" to "Tailscale or VPN",
|
||||
"local_dev" to "Local development only",
|
||||
"lan_only" to reasonLan,
|
||||
"tailscale_vpn" to reasonTailscale,
|
||||
"local_dev" to reasonDev,
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
@@ -67,7 +72,7 @@ fun InsecureConnectionAckDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Allow insecure connections?",
|
||||
text = stringResource(R.string.insecure_ack_title),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
@@ -77,16 +82,12 @@ fun InsecureConnectionAckDialog(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Insecure mode lets this app connect over plain " +
|
||||
"ws:// and http://. Anyone on the network between " +
|
||||
"your phone and the server can read your chat " +
|
||||
"messages, session tokens, and terminal traffic.",
|
||||
text = stringResource(R.string.insecure_ack_body_1),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Only use this on networks you control. Pick " +
|
||||
"the reason that best describes your setup:",
|
||||
text = stringResource(R.string.insecure_ack_body_2),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
@@ -124,12 +125,12 @@ fun InsecureConnectionAckDialog(
|
||||
enabled = selectedReason != null,
|
||||
onClick = { selectedReason?.let(onConfirm) }
|
||||
) {
|
||||
Text("I understand")
|
||||
Text(stringResource(R.string.insecure_ack_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.insecure_ack_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -44,8 +44,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.UiMessage
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.ui.UiMessageSeverity
|
||||
@@ -197,7 +199,7 @@ private fun MessageBannerContent(
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = "Show recent messages",
|
||||
contentDescription = stringResource(R.string.cd_show_recent),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
@@ -229,7 +231,7 @@ private fun MessageBannerContent(
|
||||
if (index == 0) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = "Collapse",
|
||||
contentDescription = stringResource(R.string.cd_collapse),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -330,7 +331,7 @@ fun MessageBubble(
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
text = { Text(stringResource(R.string.msg_bubble_copy)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(message.content)
|
||||
@@ -338,7 +339,7 @@ fun MessageBubble(
|
||||
)
|
||||
if (onQuoteMessage != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Quote in reply") },
|
||||
text = { Text(stringResource(R.string.msg_bubble_quote)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.content)
|
||||
@@ -347,7 +348,7 @@ fun MessageBubble(
|
||||
}
|
||||
if (showEditAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit & resend") },
|
||||
text = { Text(stringResource(R.string.msg_bubble_edit)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onEditMessage(message)
|
||||
@@ -521,9 +522,9 @@ fun MessageBubble(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (recoveringAnswer) {
|
||||
"Reconnecting to your answer…"
|
||||
stringResource(R.string.msg_bubble_reconnecting)
|
||||
} else {
|
||||
"Still working…"
|
||||
stringResource(R.string.msg_bubble_still_working)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
@@ -550,10 +551,17 @@ fun MessageBubble(
|
||||
// message routed over the relay proactive channel). Null on every
|
||||
// ordinary chat message, which render nothing here.
|
||||
message.deliveryStatus?.takeIf { isUser }?.let { status ->
|
||||
val (label, alpha) = when (status) {
|
||||
MessageDeliveryStatus.SENDING -> "Sending…" to 0.5f
|
||||
MessageDeliveryStatus.DELIVERED -> "Delivered" to 0.5f
|
||||
MessageDeliveryStatus.FAILED -> "Not sent" to 0.7f
|
||||
val label = stringResource(
|
||||
when (status) {
|
||||
MessageDeliveryStatus.SENDING -> R.string.msg_bubble_sending
|
||||
MessageDeliveryStatus.DELIVERED -> R.string.msg_bubble_delivered
|
||||
MessageDeliveryStatus.FAILED -> R.string.msg_bubble_not_sent
|
||||
}
|
||||
)
|
||||
val alpha = when (status) {
|
||||
MessageDeliveryStatus.SENDING -> 0.5f
|
||||
MessageDeliveryStatus.DELIVERED -> 0.5f
|
||||
MessageDeliveryStatus.FAILED -> 0.7f
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
|
||||
@@ -37,8 +37,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Searchable model picker as a bottom sheet — a cleaner surface than the inline
|
||||
@@ -142,7 +144,7 @@ fun ModelPickerSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
placeholder = { Text("Search models or providers…") },
|
||||
placeholder = { Text(stringResource(R.string.model_picker_search)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
@@ -155,7 +157,7 @@ fun ModelPickerSheet(
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Clear search",
|
||||
contentDescription = stringResource(R.string.model_picker_cd_clear),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
@@ -202,7 +204,7 @@ fun ModelPickerSheet(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No models match your search",
|
||||
text = stringResource(R.string.model_picker_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -252,7 +254,7 @@ private fun ModelPickerRow(
|
||||
if (option.selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Selected",
|
||||
contentDescription = stringResource(R.string.cd_selected),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
|
||||
@@ -26,38 +26,38 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
enum class PowerFeatureGateStatus(
|
||||
val label: String,
|
||||
val actionLabel: String,
|
||||
val explanation: String,
|
||||
val labelRes: Int,
|
||||
val actionLabelRes: Int,
|
||||
val explanationRes: Int,
|
||||
) {
|
||||
RequiresPairing(
|
||||
label = "Requires pairing",
|
||||
actionLabel = "Pair to unlock",
|
||||
explanation = "This feature requires the Relay plugin. Make sure it is installed " +
|
||||
"and running on your Hermes server, then pair this device to unlock it.",
|
||||
labelRes = R.string.power_feature_requires_pairing_label,
|
||||
actionLabelRes = R.string.power_feature_requires_pairing_action,
|
||||
explanationRes = R.string.power_feature_requires_pairing_explain,
|
||||
),
|
||||
PairingExpired(
|
||||
label = "Pairing expired",
|
||||
actionLabel = "Pair again",
|
||||
explanation = "Your relay session is no longer accepted. Pair again to get a fresh grant.",
|
||||
labelRes = R.string.power_feature_pairing_expired_label,
|
||||
actionLabelRes = R.string.power_feature_pairing_expired_action,
|
||||
explanationRes = R.string.power_feature_pairing_expired_explain,
|
||||
),
|
||||
Unavailable(
|
||||
label = "Unavailable on this server",
|
||||
actionLabel = "View connection",
|
||||
explanation = "This Hermes server isn't exposing the Relay plugin (or it's an older " +
|
||||
"version). Install or update the Relay plugin on the server to use this feature.",
|
||||
labelRes = R.string.power_feature_unavailable_label,
|
||||
actionLabelRes = R.string.power_feature_unavailable_action,
|
||||
explanationRes = R.string.power_feature_unavailable_explain,
|
||||
),
|
||||
DashboardSignInRequired(
|
||||
label = "Dashboard sign-in required",
|
||||
actionLabel = "Open sign-in",
|
||||
explanation = "This standard dashboard feature needs a dashboard session before it can load.",
|
||||
labelRes = R.string.power_feature_dashboard_signin_label,
|
||||
actionLabelRes = R.string.power_feature_dashboard_signin_action,
|
||||
explanationRes = R.string.power_feature_dashboard_signin_explain,
|
||||
);
|
||||
|
||||
companion object {
|
||||
@@ -90,6 +90,7 @@ fun PowerFeatureGateScreen(
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
val backDesc = stringResource(R.string.power_feature_back_desc)
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
@@ -97,7 +98,7 @@ fun PowerFeatureGateScreen(
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
contentDescription = backDesc,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +135,9 @@ fun PowerFeatureGateCard(
|
||||
onPrimaryAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val resolvedLabel = stringResource(status.labelRes)
|
||||
val resolvedExplanation = stringResource(status.explanationRes)
|
||||
val resolvedActionLabel = stringResource(status.actionLabelRes)
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
@@ -161,7 +165,7 @@ fun PowerFeatureGateCard(
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = status.label,
|
||||
text = resolvedLabel,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -178,7 +182,7 @@ fun PowerFeatureGateCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = status.explanation,
|
||||
text = resolvedExplanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -187,7 +191,7 @@ fun PowerFeatureGateCard(
|
||||
onClick = onPrimaryAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(status.actionLabel)
|
||||
Text(resolvedActionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
|
||||
@@ -87,14 +89,14 @@ fun ProfileInspectorCard(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Inspect Agent",
|
||||
text = stringResource(R.string.profile_inspector_card_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = if (enabled) {
|
||||
"View config, SOUL, memory, skills"
|
||||
stringResource(R.string.profile_inspector_card_subtitle)
|
||||
} else {
|
||||
"No active agent"
|
||||
stringResource(R.string.profile_inspector_card_no_agent)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -66,6 +66,8 @@ import com.hermesandroid.relay.data.ApiEndpoint
|
||||
import com.hermesandroid.relay.data.DashboardEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -534,6 +536,9 @@ fun QrPairingScanner(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Pre-resolve strings used inside non-Composable contexts (CameraX
|
||||
// listeners, exception handlers, etc. don't have a Composable scope).
|
||||
val cameraErrorMsg = stringResource(R.string.qr_scanner_camera_error)
|
||||
// AtomicBoolean for thread-safe detection flag (accessed from camera executor thread)
|
||||
val hasDetected = remember { AtomicBoolean(false) }
|
||||
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||
@@ -618,12 +623,12 @@ fun QrPairingScanner(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close scanner",
|
||||
contentDescription = stringResource(R.string.qr_scanner_cd_close),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Scan Hermes QR",
|
||||
text = stringResource(R.string.qr_scanner_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
@@ -670,7 +675,7 @@ fun QrPairingScanner(
|
||||
cameraProviderFuture.get()
|
||||
} catch (t: Throwable) {
|
||||
Log.e("QrPairingScanner", "Camera provider init failed", t)
|
||||
cameraError = "Couldn't start the camera on this device."
|
||||
cameraError = cameraErrorMsg
|
||||
return@addListener
|
||||
}
|
||||
cameraProviderRef.value = cameraProvider
|
||||
@@ -758,7 +763,7 @@ fun QrPairingScanner(
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("QrPairingScanner", "Camera bind failed", e)
|
||||
cameraError = "Couldn't start the camera on this device."
|
||||
cameraError = cameraErrorMsg
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
|
||||
@@ -793,13 +798,13 @@ fun QrPairingScanner(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Scan a Hermes setup QR",
|
||||
text = stringResource(R.string.qr_scanner_instruction),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Relay plugin.",
|
||||
text = stringResource(R.string.qr_scanner_subtext),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
@@ -843,14 +848,14 @@ private fun CameraUnavailableCard(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "You can pair without the camera.",
|
||||
text = stringResource(R.string.qr_scanner_fallback_message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = onPairManually) {
|
||||
Text("Pair manually")
|
||||
Text(stringResource(R.string.qr_scanner_pair_manual))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -55,9 +56,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
@@ -65,18 +69,18 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private enum class SessionDrawerFilter(val label: String) {
|
||||
All("All"),
|
||||
Threads("Threads"),
|
||||
Pinned("Pinned"),
|
||||
Archive("Archive"),
|
||||
private enum class SessionDrawerFilter {
|
||||
All,
|
||||
Threads,
|
||||
Pinned,
|
||||
Archive,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: List<ChatSession>,
|
||||
currentSessionId: String?,
|
||||
scopeTitle: String = "Sessions",
|
||||
scopeTitle: String = "",
|
||||
scopeSubtitle: String? = null,
|
||||
isLoading: Boolean = false,
|
||||
isOpen: Boolean = true,
|
||||
@@ -204,7 +208,7 @@ fun SessionDrawerContent(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = scopeTitle,
|
||||
text = scopeTitle.ifBlank { stringResource(R.string.drawer_filter_sessions) },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -218,7 +222,7 @@ fun SessionDrawerContent(
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = "Filter by source",
|
||||
contentDescription = stringResource(R.string.drawer_filter_by_source),
|
||||
tint = if (presentSources.any { it in hiddenSources }) {
|
||||
RelayRefresh.Relay
|
||||
} else {
|
||||
@@ -232,7 +236,7 @@ fun SessionDrawerContent(
|
||||
onDismissRequest = { sourceFilterOpen = false },
|
||||
) {
|
||||
Text(
|
||||
text = "Show sources",
|
||||
text = stringResource(R.string.drawer_show_sources),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
@@ -299,7 +303,7 @@ fun SessionDrawerContent(
|
||||
IconButton(onClick = refresh, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = "Refresh sessions",
|
||||
contentDescription = stringResource(R.string.drawer_refresh_sessions),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
@@ -326,7 +330,7 @@ fun SessionDrawerContent(
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("New Chat")
|
||||
Text(stringResource(R.string.drawer_new_chat))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -338,7 +342,7 @@ fun SessionDrawerContent(
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text("Search sessions or id...") },
|
||||
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
@@ -357,11 +361,21 @@ fun SessionDrawerContent(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(text = item.label, style = relayMetadataStyle())
|
||||
Text(text = when (item) {
|
||||
SessionDrawerFilter.All -> stringResource(R.string.drawer_filter_all)
|
||||
SessionDrawerFilter.Threads -> stringResource(R.string.drawer_filter_threads)
|
||||
SessionDrawerFilter.Pinned -> stringResource(R.string.drawer_filter_pinned)
|
||||
SessionDrawerFilter.Archive -> stringResource(R.string.drawer_filter_archive)
|
||||
}, style = relayMetadataStyle())
|
||||
BetaChip()
|
||||
}
|
||||
} else {
|
||||
Text(text = item.label, style = relayMetadataStyle())
|
||||
Text(text = when (item) {
|
||||
SessionDrawerFilter.All -> stringResource(R.string.drawer_filter_all)
|
||||
SessionDrawerFilter.Threads -> stringResource(R.string.drawer_filter_threads)
|
||||
SessionDrawerFilter.Pinned -> stringResource(R.string.drawer_filter_pinned)
|
||||
SessionDrawerFilter.Archive -> stringResource(R.string.drawer_filter_archive)
|
||||
}, style = relayMetadataStyle())
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -380,7 +394,7 @@ fun SessionDrawerContent(
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("New Thread")
|
||||
Text(stringResource(R.string.drawer_new_thread))
|
||||
}
|
||||
}
|
||||
if (!autoTitlesSupported) {
|
||||
@@ -390,7 +404,7 @@ fun SessionDrawerContent(
|
||||
// rather than broken — rename is one tap away via ⋮. (issue #133)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Chats aren't auto-named on this connection — use ⋮ → Rename.",
|
||||
text = stringResource(R.string.drawer_chats_not_named),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -422,7 +436,7 @@ fun SessionDrawerContent(
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
text = "Loading sessions…",
|
||||
text = stringResource(R.string.drawer_loading_sessions),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -435,12 +449,12 @@ fun SessionDrawerContent(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = if (sessions.isEmpty()) "No sessions yet" else "No matching sessions",
|
||||
text = if (sessions.isEmpty()) stringResource(R.string.drawer_no_sessions) else stringResource(R.string.drawer_no_matching_sessions),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Start a conversation to see it here",
|
||||
text = stringResource(R.string.drawer_start_conversation),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -482,12 +496,12 @@ fun SessionDrawerContent(
|
||||
var threadName by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { newThreadDialog = false },
|
||||
title = { Text("New Thread") },
|
||||
title = { Text(stringResource(R.string.drawer_new_thread)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = threadName,
|
||||
onValueChange = { threadName = it },
|
||||
label = { Text("Thread name") },
|
||||
label = { Text(stringResource(R.string.drawer_thread_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -500,12 +514,12 @@ fun SessionDrawerContent(
|
||||
newThreadDialog = false
|
||||
}
|
||||
}) {
|
||||
Text("Create")
|
||||
Text(stringResource(R.string.drawer_create))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { newThreadDialog = false }) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.drawer_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -516,12 +530,12 @@ fun SessionDrawerContent(
|
||||
var newTitle by remember(session) { mutableStateOf(session.title ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renameDialogSession = null },
|
||||
title = { Text("Rename Session") },
|
||||
title = { Text(stringResource(R.string.drawer_rename_session)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
label = { Text("Title") },
|
||||
label = { Text(stringResource(R.string.drawer_title)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
@@ -533,12 +547,12 @@ fun SessionDrawerContent(
|
||||
}
|
||||
renameDialogSession = null
|
||||
}) {
|
||||
Text("Rename")
|
||||
Text(stringResource(R.string.drawer_rename))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { renameDialogSession = null }) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.drawer_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -548,21 +562,21 @@ fun SessionDrawerContent(
|
||||
deleteDialogSession?.let { session ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteDialogSession = null },
|
||||
title = { Text("Delete Session?") },
|
||||
title = { Text(stringResource(R.string.drawer_delete_session_title)) },
|
||||
text = {
|
||||
Text("This will permanently delete \"${session.title ?: "Untitled"}\" and its message history.")
|
||||
Text(stringResource(R.string.drawer_delete_session_prefix) + (session.title ?: stringResource(R.string.drawer_untitled)) + stringResource(R.string.drawer_delete_session_suffix))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onDeleteSession(session.sessionId)
|
||||
deleteDialogSession = null
|
||||
}) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
Text(stringResource(R.string.drawer_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteDialogSession = null }) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.drawer_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -583,6 +597,8 @@ private fun SessionItem(
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val context = LocalContext.current
|
||||
val untitledLabel = stringResource(R.string.drawer_untitled)
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
@@ -603,7 +619,7 @@ private fun SessionItem(
|
||||
.padding(end = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = session.title ?: "Untitled",
|
||||
text = session.title ?: untitledLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -633,7 +649,7 @@ private fun SessionItem(
|
||||
tint = RelayRefresh.Relay,
|
||||
)
|
||||
Text(
|
||||
text = "Thread",
|
||||
text = stringResource(R.string.drawer_thread),
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Relay,
|
||||
maxLines = 1,
|
||||
@@ -645,7 +661,7 @@ private fun SessionItem(
|
||||
sourceBadge(session.source)?.let { badge ->
|
||||
SourceChip(badge)
|
||||
}
|
||||
sessionTimestampText(session, locale)?.let { timestamp ->
|
||||
sessionTimestampText(session, locale, context)?.let { timestamp ->
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -657,7 +673,7 @@ private fun SessionItem(
|
||||
}
|
||||
if (session.messageCount > 0) {
|
||||
Text(
|
||||
text = "${session.messageCount} msgs",
|
||||
text = "${session.messageCount}" + stringResource(R.string.drawer_msgs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
@@ -673,7 +689,7 @@ private fun SessionItem(
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = if (pinned) "Unpin session" else "Pin session",
|
||||
contentDescription = if (pinned) stringResource(R.string.drawer_unpin_session) else stringResource(R.string.drawer_pin_session),
|
||||
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
@@ -686,7 +702,7 @@ private fun SessionItem(
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = "Session actions",
|
||||
contentDescription = stringResource(R.string.drawer_session_actions),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
@@ -696,7 +712,7 @@ private fun SessionItem(
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
text = { Text(stringResource(R.string.drawer_rename)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Edit, contentDescription = null)
|
||||
},
|
||||
@@ -706,7 +722,7 @@ private fun SessionItem(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (archived) "Restore" else "Archive") },
|
||||
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Archive,
|
||||
@@ -726,7 +742,7 @@ private fun SessionItem(
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = "Delete",
|
||||
text = stringResource(R.string.drawer_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
@@ -747,22 +763,22 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale): String? {
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
|
||||
val timestamp = session.activityTimestamp
|
||||
if (timestamp <= 0L) return null
|
||||
val hasDistinctActivity =
|
||||
session.lastActivityAt > 0L &&
|
||||
session.startTimestamp > 0L &&
|
||||
session.lastActivityAt != session.startTimestamp
|
||||
val prefix = if (hasDistinctActivity) "Active" else "Started"
|
||||
return "$prefix ${formatTimestamp(timestamp, locale)}"
|
||||
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
|
||||
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
|
||||
}
|
||||
|
||||
private fun formatTimestamp(millis: Long, locale: Locale): String {
|
||||
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
return when {
|
||||
diff < 60_000 -> "Just now"
|
||||
diff < 60_000 -> context.getString(R.string.drawer_just_now)
|
||||
diff < 3_600_000 -> "${diff / 60_000}m ago"
|
||||
diff < 86_400_000 -> SimpleDateFormat("h:mm a", locale).format(Date(millis))
|
||||
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
|
||||
|
||||
@@ -39,6 +39,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session-path summary — the glanceable "which transport/route am I on" view
|
||||
@@ -68,8 +70,7 @@ import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
* raw token NEVER reaches the user.
|
||||
*/
|
||||
internal data class SessionPathTransport(
|
||||
val friendlyName: String,
|
||||
val descriptor: String,
|
||||
val type: String,
|
||||
val icon: ImageVector?,
|
||||
/** True only for the gateway transport — gates the "Live thinking" chip. */
|
||||
val isGateway: Boolean,
|
||||
@@ -78,37 +79,48 @@ internal data class SessionPathTransport(
|
||||
internal fun sessionPathTransport(resolvedTransport: String): SessionPathTransport =
|
||||
when (resolvedTransport) {
|
||||
"gateway" -> SessionPathTransport(
|
||||
friendlyName = "Gateway",
|
||||
descriptor = "live thinking",
|
||||
type = "gateway",
|
||||
icon = Icons.Filled.Bolt,
|
||||
isGateway = true,
|
||||
)
|
||||
"sessions" -> SessionPathTransport(
|
||||
friendlyName = "Sessions API",
|
||||
descriptor = "streaming",
|
||||
type = "sessions",
|
||||
icon = null,
|
||||
isGateway = false,
|
||||
)
|
||||
"completions" -> SessionPathTransport(
|
||||
friendlyName = "Chat Completions",
|
||||
descriptor = "streaming",
|
||||
type = "completions",
|
||||
icon = null,
|
||||
isGateway = false,
|
||||
)
|
||||
"runs" -> SessionPathTransport(
|
||||
friendlyName = "Runs API",
|
||||
descriptor = "structured streaming",
|
||||
type = "runs",
|
||||
icon = null,
|
||||
isGateway = false,
|
||||
)
|
||||
else -> SessionPathTransport(
|
||||
friendlyName = "Direct chat",
|
||||
descriptor = "streaming",
|
||||
type = "other",
|
||||
icon = null,
|
||||
isGateway = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun transportFriendlyName(type: String): String = when (type) {
|
||||
"gateway" -> stringResource(R.string.session_path_gateway)
|
||||
"sessions" -> stringResource(R.string.session_path_sessions_api)
|
||||
"completions" -> stringResource(R.string.session_path_chat_completions)
|
||||
"runs" -> stringResource(R.string.session_path_runs_api)
|
||||
else -> stringResource(R.string.session_path_direct_chat)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun transportDescriptor(type: String): String = when (type) {
|
||||
"gateway" -> stringResource(R.string.session_path_live_thinking)
|
||||
"runs" -> stringResource(R.string.session_path_structured_streaming)
|
||||
else -> stringResource(R.string.session_path_streaming)
|
||||
}
|
||||
|
||||
/**
|
||||
* One capability the session may or may not expose, paired with whether it is
|
||||
* actually available right now. [available] is set strictly from a confirming
|
||||
@@ -116,7 +128,7 @@ internal fun sessionPathTransport(resolvedTransport: String): SessionPathTranspo
|
||||
* the detail list can show it muted instead of hiding it (never-hide principle).
|
||||
*/
|
||||
internal data class SessionCapability(
|
||||
val label: String,
|
||||
val type: String,
|
||||
val available: Boolean,
|
||||
val reason: String? = null,
|
||||
)
|
||||
@@ -134,6 +146,7 @@ internal data class SessionCapability(
|
||||
* - Voice → `voiceReady` (standard dashboard voice OR relay voice — whichever
|
||||
* the connection actually has).
|
||||
*/
|
||||
@Composable
|
||||
internal fun sessionCapabilities(
|
||||
transport: SessionPathTransport,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
@@ -144,52 +157,62 @@ internal fun sessionCapabilities(
|
||||
): List<SessionCapability> {
|
||||
val liveThinkingReason = when {
|
||||
transport.isGateway -> null
|
||||
gatewayAvailability == GatewayAvailability.Unknown -> "Checking gateway…"
|
||||
gatewayAvailability == GatewayAvailability.Unknown -> stringResource(R.string.session_path_checking_gateway)
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired ->
|
||||
"Sign in under Manage for the live-thinking gateway."
|
||||
else -> "Available on the gateway transport — this session streams over the API server."
|
||||
stringResource(R.string.session_path_signin_for_gateway)
|
||||
else -> stringResource(R.string.session_path_gateway_api_server)
|
||||
}
|
||||
return listOf(
|
||||
SessionCapability(
|
||||
label = "Live thinking",
|
||||
type = "live_thinking",
|
||||
available = transport.isGateway,
|
||||
reason = liveThinkingReason,
|
||||
),
|
||||
SessionCapability(
|
||||
label = "Media",
|
||||
type = "media",
|
||||
available = relayConnected,
|
||||
reason = when {
|
||||
relayConnected -> null
|
||||
relayConfigured -> "Relay paired but not connected."
|
||||
else -> "Pair the relay to send and receive media."
|
||||
relayConfigured -> stringResource(R.string.session_path_relay_not_connected)
|
||||
else -> stringResource(R.string.session_path_pair_relay_media)
|
||||
},
|
||||
),
|
||||
SessionCapability(
|
||||
label = "Terminal",
|
||||
type = "terminal",
|
||||
available = relayConnected,
|
||||
reason = when {
|
||||
relayConnected -> null
|
||||
relayConfigured -> "Relay paired but not connected."
|
||||
else -> "Pair the relay for terminal access."
|
||||
relayConfigured -> stringResource(R.string.session_path_relay_not_connected)
|
||||
else -> stringResource(R.string.session_path_pair_relay_terminal)
|
||||
},
|
||||
),
|
||||
SessionCapability(
|
||||
label = "Voice",
|
||||
type = "voice",
|
||||
available = voiceReady,
|
||||
reason = if (voiceReady) null else "Voice not ready on this connection.",
|
||||
reason = if (voiceReady) null else stringResource(R.string.session_path_voice_not_ready),
|
||||
),
|
||||
SessionCapability(
|
||||
label = "Threads",
|
||||
type = "threads",
|
||||
available = threadsActive,
|
||||
reason = if (threadsActive) {
|
||||
null
|
||||
} else {
|
||||
"Pair the relay and turn on “Let Hermes message me” so the agent can open Threads."
|
||||
stringResource(R.string.session_path_threads_pair_relay)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun capabilityLabel(type: String): String = when (type) {
|
||||
"live_thinking" -> stringResource(R.string.session_path_live_thinking)
|
||||
"media" -> stringResource(R.string.session_path_media)
|
||||
"terminal" -> stringResource(R.string.session_path_terminal)
|
||||
"voice" -> stringResource(R.string.session_path_voice)
|
||||
"threads" -> stringResource(R.string.session_path_threads)
|
||||
else -> type
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-visible session-path summary that anchors the Connection section:
|
||||
* a friendly transport + route line, then a strip of chips for capabilities
|
||||
@@ -226,13 +249,15 @@ internal fun SessionPathSummary(
|
||||
)
|
||||
}
|
||||
val routeSuffix = routeLabel?.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
|
||||
val friendlyName = transportFriendlyName(transport.type)
|
||||
val descriptor = transportDescriptor(transport.type)
|
||||
Text(
|
||||
text = buildString {
|
||||
append(transport.friendlyName)
|
||||
append(friendlyName)
|
||||
append(routeSuffix)
|
||||
if (transport.descriptor.isNotBlank()) {
|
||||
if (descriptor.isNotBlank()) {
|
||||
append(" — ")
|
||||
append(transport.descriptor)
|
||||
append(descriptor)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -255,16 +280,17 @@ internal fun SessionPathSummary(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
activeCaps.forEach { cap ->
|
||||
if (cap.label == "Threads") {
|
||||
val label = capabilityLabel(cap.type)
|
||||
if (cap.type == "threads") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
CapabilityChip(label = cap.label)
|
||||
CapabilityChip(label = label)
|
||||
BetaChip()
|
||||
}
|
||||
} else {
|
||||
CapabilityChip(label = cap.label)
|
||||
CapabilityChip(label = label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +333,8 @@ internal fun SessionPathDetails(
|
||||
serverCapabilities: ServerCapabilities,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val friendlyName = transportFriendlyName(transport.type)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
@@ -319,7 +347,7 @@ internal fun SessionPathDetails(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (expanded) "Hide session details" else "Session details",
|
||||
text = if (expanded) stringResource(R.string.session_path_hide_details) else stringResource(R.string.session_path_details),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -329,16 +357,16 @@ internal fun SessionPathDetails(
|
||||
} else {
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
},
|
||||
contentDescription = if (expanded) "Hide session details" else "Show session details",
|
||||
contentDescription = if (expanded) stringResource(R.string.session_path_hide_details_cd) else stringResource(R.string.session_path_show_details_cd),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
DetailRow(label = "Transport", value = transport.friendlyName)
|
||||
DetailRow(label = "Route", value = routeLabel?.takeIf { it.isNotBlank() } ?: "—")
|
||||
DetailChipRow(label = "Relay state") {
|
||||
DetailRow(label = stringResource(R.string.session_path_transport), value = friendlyName)
|
||||
DetailRow(label = stringResource(R.string.session_path_route), value = routeLabel?.takeIf { it.isNotBlank() } ?: "—")
|
||||
DetailChipRow(label = stringResource(R.string.session_path_relay_state)) {
|
||||
SessionPathConnectionChip(relayConnectionState)
|
||||
}
|
||||
|
||||
@@ -362,8 +390,8 @@ internal fun SessionPathDetails(
|
||||
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
|
||||
DetailRow(label = "API", value = apiServerUrl, monospace = true)
|
||||
DetailRow(label = "Relay", value = relayUrl, monospace = true)
|
||||
DetailRow(label = stringResource(R.string.session_path_api), value = apiServerUrl, monospace = true)
|
||||
DetailRow(label = stringResource(R.string.session_path_relay), value = relayUrl, monospace = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,20 +415,20 @@ internal fun TransportTierStepper(
|
||||
val status = remember(streamingEndpoint, gatewayAvailability, serverCapabilities) {
|
||||
resolveChatTransportStatus(streamingEndpoint, gatewayAvailability, serverCapabilities)
|
||||
}
|
||||
// basic → best, paired with whether THIS server exposes the tier.
|
||||
// basic -> best, paired with whether THIS server exposes the tier.
|
||||
val tiers = listOf(
|
||||
Triple(ChatTransportTier.Completions, "Chat Completions", serverCapabilities.portable),
|
||||
Triple(ChatTransportTier.Runs, "Runs API", serverCapabilities.runs),
|
||||
Triple(ChatTransportTier.Sessions, "Sessions API", serverCapabilities.sessionsChatStream),
|
||||
Triple(ChatTransportTier.Completions, stringResource(R.string.session_path_chat_completions), serverCapabilities.portable),
|
||||
Triple(ChatTransportTier.Runs, stringResource(R.string.session_path_runs_api), serverCapabilities.runs),
|
||||
Triple(ChatTransportTier.Sessions, stringResource(R.string.session_path_sessions_api), serverCapabilities.sessionsChatStream),
|
||||
Triple(
|
||||
ChatTransportTier.Gateway,
|
||||
"Gateway · live thinking",
|
||||
stringResource(R.string.session_path_gateway_live_thinking),
|
||||
gatewayAvailability == GatewayAvailability.Ready,
|
||||
),
|
||||
)
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Transport path · basic → best",
|
||||
text = stringResource(R.string.session_path_transport_path_ladder),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -470,9 +498,9 @@ private fun TransportTierRow(
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
isActive -> "active"
|
||||
available -> "available"
|
||||
else -> "not exposed by this server"
|
||||
isActive -> stringResource(R.string.session_path_active)
|
||||
available -> stringResource(R.string.session_path_available)
|
||||
else -> stringResource(R.string.session_path_not_exposed)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (isActive) {
|
||||
@@ -536,6 +564,7 @@ private fun DetailChipRow(label: String, chip: @Composable () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun CapabilityDetailRow(cap: SessionCapability) {
|
||||
val label = capabilityLabel(cap.type)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -543,7 +572,7 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (cap.available) Icons.Filled.Check else Icons.Filled.Remove,
|
||||
contentDescription = if (cap.available) "Available" else "Unavailable",
|
||||
contentDescription = if (cap.available) stringResource(R.string.session_path_available) else stringResource(R.string.session_path_unavailable),
|
||||
tint = if (cap.available) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
@@ -553,7 +582,7 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = cap.label,
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (cap.available) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
@@ -578,22 +607,22 @@ private fun CapabilityDetailRow(cap: SessionCapability) {
|
||||
private fun SessionPathConnectionChip(state: ConnectionState) {
|
||||
val (label, bg, fg) = when (state) {
|
||||
ConnectionState.Connected -> Triple(
|
||||
"Connected",
|
||||
stringResource(R.string.session_path_connected),
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
ConnectionState.Connecting -> Triple(
|
||||
"Connecting…",
|
||||
stringResource(R.string.session_path_connecting),
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
ConnectionState.Reconnecting -> Triple(
|
||||
"Reconnecting…",
|
||||
stringResource(R.string.session_path_reconnecting),
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
ConnectionState.Disconnected -> Triple(
|
||||
"Disconnected",
|
||||
stringResource(R.string.session_path_disconnected),
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
+19
-20
@@ -24,8 +24,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.annotation.StringRes
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
|
||||
/**
|
||||
@@ -83,7 +86,7 @@ fun SessionTtlPickerDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Keep this pairing for…",
|
||||
text = stringResource(R.string.ttl_title),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
@@ -93,17 +96,16 @@ fun SessionTtlPickerDialog(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Your phone will reconnect automatically during " +
|
||||
"this window. After it expires you'll need to re-pair.",
|
||||
text = stringResource(R.string.ttl_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Transport hint / Tailscale helper line
|
||||
val helperLine = when {
|
||||
isTailscaleDetected -> "Transport: Tailscale detected"
|
||||
transportHint.equals("wss", ignoreCase = true) -> "Transport: TLS (wss://)"
|
||||
transportHint.equals("ws", ignoreCase = true) -> "Transport: plain ws://"
|
||||
isTailscaleDetected -> stringResource(R.string.ttl_helper_tailscale)
|
||||
transportHint.equals("wss", ignoreCase = true) -> stringResource(R.string.ttl_helper_tls)
|
||||
transportHint.equals("ws", ignoreCase = true) -> stringResource(R.string.ttl_helper_plain)
|
||||
else -> null
|
||||
}
|
||||
if (helperLine != null) {
|
||||
@@ -134,7 +136,7 @@ fun SessionTtlPickerDialog(
|
||||
onClick = { selectedIndex = index }
|
||||
)
|
||||
Text(
|
||||
text = option.label,
|
||||
text = stringResource(option.labelRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
@@ -160,10 +162,7 @@ fun SessionTtlPickerDialog(
|
||||
.height(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "This device will stay paired until you " +
|
||||
"revoke it manually from Relay sessions. " +
|
||||
"Only choose this if you control the network " +
|
||||
"— LAN, Tailscale, VPN, or TLS.",
|
||||
text = stringResource(R.string.ttl_never_expire_warning),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -175,12 +174,12 @@ fun SessionTtlPickerDialog(
|
||||
TextButton(
|
||||
onClick = { onConfirm(options[selectedIndex].seconds) }
|
||||
) {
|
||||
Text("Pair")
|
||||
Text(stringResource(R.string.ttl_pair))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.ttl_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -190,16 +189,16 @@ fun SessionTtlPickerDialog(
|
||||
* A single entry in the TTL picker. `seconds == 0` means "never expire"
|
||||
* (wire contract alignment — matches `ttl_seconds: 0` on the QR payload).
|
||||
*/
|
||||
data class TtlOption(val label: String, val seconds: Long)
|
||||
data class TtlOption(@StringRes val labelRes: Int, val seconds: Long)
|
||||
|
||||
/** The canonical set of TTL options shown in the picker. */
|
||||
fun ttlPickerOptions(): List<TtlOption> = listOf(
|
||||
TtlOption("1 day", 24L * 60 * 60),
|
||||
TtlOption("7 days", 7L * 24 * 60 * 60),
|
||||
TtlOption("30 days", 30L * 24 * 60 * 60),
|
||||
TtlOption("90 days", 90L * 24 * 60 * 60),
|
||||
TtlOption("1 year", 365L * 24 * 60 * 60),
|
||||
TtlOption("Never expire", PairingPreferences.TTL_NEVER),
|
||||
TtlOption(R.string.ttl_option_1d, 24L * 60 * 60),
|
||||
TtlOption(R.string.ttl_option_7d, 7L * 24 * 60 * 60),
|
||||
TtlOption(R.string.ttl_option_30d, 30L * 24 * 60 * 60),
|
||||
TtlOption(R.string.ttl_option_90d, 90L * 24 * 60 * 60),
|
||||
TtlOption(R.string.ttl_option_1y, 365L * 24 * 60 * 60),
|
||||
TtlOption(R.string.ttl_option_never, PairingPreferences.TTL_NEVER),
|
||||
)
|
||||
|
||||
/** Fallback default when no previous selection and no QR hint — 30 days. */
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.BrandPalette
|
||||
import com.hermesandroid.relay.ui.theme.BrandPalettes
|
||||
|
||||
@@ -36,18 +39,44 @@ data class SphereReactivity(
|
||||
* renderer feeds it (the chat/voice [MorphingSphere] does not). */
|
||||
val gaze: Boolean = false,
|
||||
) {
|
||||
/** Human-readable capability list for the picker ("Voice · Tools"). */
|
||||
/** Ordered list of enabled capability flags (locale-independent so test
|
||||
* assertions stay stable). The UI maps each flag to a localized label via
|
||||
* [reactivityLabels]. */
|
||||
fun flags(): List<ReactivityFlag> = buildList {
|
||||
if (voice) add(ReactivityFlag.VOICE)
|
||||
if (tools) add(ReactivityFlag.TOOLS)
|
||||
if (intensity) add(ReactivityFlag.ACTIVITY)
|
||||
if (gaze) add(ReactivityFlag.GAZE)
|
||||
}
|
||||
|
||||
/** Legacy locale-independent summary for backward compat. Prefer [flags] +
|
||||
* [reactivityLabels] in UI code. */
|
||||
fun summary(): String {
|
||||
val on = buildList {
|
||||
if (voice) add("Voice")
|
||||
if (tools) add("Tools")
|
||||
if (intensity) add("Activity")
|
||||
if (gaze) add("Gaze")
|
||||
}
|
||||
val on = flags().map { it.name.lowercase().replaceFirstChar { c -> c.uppercase() } }
|
||||
return if (on.isEmpty()) "Static" else on.joinToString(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
/** Individual capability flags exposed by a [SphereReactivity]. Used as
|
||||
* locale-independent keys; the UI maps each to a localized label. */
|
||||
enum class ReactivityFlag { VOICE, TOOLS, ACTIVITY, GAZE }
|
||||
|
||||
/** Pre-composed localized summary string from a list of [ReactivityFlag],
|
||||
* joined with the middle-dot separator. */
|
||||
@androidx.compose.runtime.Composable
|
||||
fun reactivityLabels(flags: List<ReactivityFlag>): String {
|
||||
if (flags.isEmpty()) return stringResource(R.string.sphere_reactivity_static)
|
||||
val labels = flags.map { flag ->
|
||||
when (flag) {
|
||||
ReactivityFlag.VOICE -> stringResource(R.string.sphere_reactivity_voice)
|
||||
ReactivityFlag.TOOLS -> stringResource(R.string.sphere_reactivity_tools)
|
||||
ReactivityFlag.ACTIVITY -> stringResource(R.string.sphere_reactivity_activity)
|
||||
ReactivityFlag.GAZE -> stringResource(R.string.sphere_reactivity_gaze)
|
||||
}
|
||||
}
|
||||
return labels.joinToString(" · ")
|
||||
}
|
||||
|
||||
enum class SphereSkinSource { ADAPTIVE, BUILT_IN, USER }
|
||||
|
||||
@Immutable
|
||||
|
||||
@@ -42,6 +42,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.AppStats
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.viewmodel.VoiceStats
|
||||
@@ -79,22 +81,22 @@ fun StatsForNerds(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Overview",
|
||||
text = stringResource(R.string.stats_overview),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Row {
|
||||
if (expanded) {
|
||||
TextButton(onClick = { showResetDialog = true }) {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error,
|
||||
Text(stringResource(R.string.stats_reset), color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { expanded = !expanded }) {
|
||||
Text(if (expanded) "Collapse" else "Expand")
|
||||
Text(if (expanded) stringResource(R.string.stats_collapse) else stringResource(R.string.stats_expand))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
contentDescription = if (expanded) stringResource(R.string.stats_collapse_cd) else stringResource(R.string.stats_expand_cd),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
@@ -106,10 +108,13 @@ fun StatsForNerds(
|
||||
val tokensPerMsg = if (appStats.totalMessagesSent > 0)
|
||||
totalTokens / appStats.totalMessagesSent else 0L
|
||||
Text(
|
||||
text = "${appStats.totalMessagesSent} messages · " +
|
||||
"${formatTokenCount(totalTokens)} tokens" +
|
||||
(if (tokensPerMsg > 0) " (~${formatTokenCount(tokensPerMsg)}/msg)" else "") +
|
||||
" · ${appStats.sessionCount} sessions",
|
||||
text = stringResource(
|
||||
R.string.stats_summary_format,
|
||||
appStats.totalMessagesSent,
|
||||
formatTokenCount(totalTokens),
|
||||
if (tokensPerMsg > 0) stringResource(R.string.stats_tokens_per_msg, formatTokenCount(tokensPerMsg)) else "",
|
||||
appStats.sessionCount
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -126,7 +131,7 @@ fun StatsForNerds(
|
||||
// -- Response Time Chart (TTFT) --
|
||||
if (appStats.recentResponseTimesMs.isNotEmpty()) {
|
||||
ChartSection(
|
||||
title = "Time to First Token",
|
||||
title = stringResource(R.string.stats_time_to_first_token),
|
||||
data = appStats.recentResponseTimesMs,
|
||||
unit = "ms"
|
||||
)
|
||||
@@ -135,7 +140,7 @@ fun StatsForNerds(
|
||||
// -- Completion Time Chart --
|
||||
if (appStats.recentCompletionTimesMs.isNotEmpty()) {
|
||||
ChartSection(
|
||||
title = "Completion Time",
|
||||
title = stringResource(R.string.stats_completion_time),
|
||||
data = appStats.recentCompletionTimesMs,
|
||||
unit = "ms"
|
||||
)
|
||||
@@ -174,19 +179,19 @@ fun StatsForNerds(
|
||||
if (showResetDialog) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text("Reset Analytics?") },
|
||||
text = { Text("This will clear all recorded stats including token counts, response times, and stream history. This cannot be undone.") },
|
||||
title = { Text(stringResource(R.string.stats_reset_analytics_title)) },
|
||||
text = { Text(stringResource(R.string.stats_reset_analytics_body)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
AppAnalytics.resetAll()
|
||||
showResetDialog = false
|
||||
}) {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||
Text(stringResource(R.string.stats_reset_confirm), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDialog = false }) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.stats_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -221,9 +226,9 @@ private fun ChartSection(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
StatLabel("Min", formatMsWithSeconds(minVal))
|
||||
StatLabel("Avg", formatMsWithSeconds(avgVal))
|
||||
StatLabel("Max", formatMsWithSeconds(maxVal))
|
||||
StatLabel(stringResource(R.string.stats_min), formatMsWithSeconds(minVal))
|
||||
StatLabel(stringResource(R.string.stats_avg), formatMsWithSeconds(avgVal))
|
||||
StatLabel(stringResource(R.string.stats_max), formatMsWithSeconds(maxVal))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -326,7 +331,7 @@ private fun StatLabel(label: String, value: String) {
|
||||
private fun TokenUsageSection(stats: AppStats) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Token Usage",
|
||||
text = stringResource(R.string.stats_token_usage),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
@@ -334,7 +339,7 @@ private fun TokenUsageSection(stats: AppStats) {
|
||||
|
||||
// Current session
|
||||
Text(
|
||||
text = "Current Session",
|
||||
text = stringResource(R.string.stats_current_session),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -342,14 +347,14 @@ private fun TokenUsageSection(stats: AppStats) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TokenStat("In", formatTokenCount(stats.currentSessionTokensIn))
|
||||
TokenStat("Out", formatTokenCount(stats.currentSessionTokensOut))
|
||||
TokenStat("Total", formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
|
||||
TokenStat(stringResource(R.string.stats_in), formatTokenCount(stats.currentSessionTokensIn))
|
||||
TokenStat(stringResource(R.string.stats_out), formatTokenCount(stats.currentSessionTokensOut))
|
||||
TokenStat(stringResource(R.string.stats_total), formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
|
||||
}
|
||||
|
||||
// Lifetime
|
||||
Text(
|
||||
text = "Lifetime",
|
||||
text = stringResource(R.string.stats_lifetime),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -357,9 +362,9 @@ private fun TokenUsageSection(stats: AppStats) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TokenStat("In", formatTokenCount(stats.totalTokensIn))
|
||||
TokenStat("Out", formatTokenCount(stats.totalTokensOut))
|
||||
TokenStat("Total", formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
|
||||
TokenStat(stringResource(R.string.stats_in), formatTokenCount(stats.totalTokensIn))
|
||||
TokenStat(stringResource(R.string.stats_out), formatTokenCount(stats.totalTokensOut))
|
||||
TokenStat(stringResource(R.string.stats_total), formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,7 +393,7 @@ private fun TokenStat(label: String, value: String) {
|
||||
private fun ConnectionHealthSection(stats: AppStats) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Connection Health",
|
||||
text = stringResource(R.string.stats_connection_health),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
@@ -396,7 +401,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
|
||||
|
||||
if (stats.healthChecksTotal == 0) {
|
||||
Text(
|
||||
text = "No health checks recorded yet",
|
||||
text = stringResource(R.string.stats_no_health_checks),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -408,7 +413,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Success rate",
|
||||
text = stringResource(R.string.stats_success_rate),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -437,7 +442,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Avg latency",
|
||||
text = stringResource(R.string.stats_avg_latency),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -455,7 +460,7 @@ private fun ConnectionHealthSection(stats: AppStats) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Total checks",
|
||||
text = stringResource(R.string.stats_total_checks),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -478,7 +483,7 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Stream Stats",
|
||||
text = stringResource(R.string.stats_stream_stats),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
@@ -486,7 +491,7 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
|
||||
if (totalStreams == 0) {
|
||||
Text(
|
||||
text = "No streams recorded yet",
|
||||
text = stringResource(R.string.stats_no_streams),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -498,7 +503,7 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Success rate",
|
||||
text = stringResource(R.string.stats_success_rate),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -528,9 +533,9 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
StreamCounter("Completed", stats.streamsCompleted, MaterialTheme.colorScheme.primary)
|
||||
StreamCounter("Errored", stats.streamsErrored, MaterialTheme.colorScheme.error)
|
||||
StreamCounter("Cancelled", stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
StreamCounter(stringResource(R.string.stats_completed), stats.streamsCompleted, MaterialTheme.colorScheme.primary)
|
||||
StreamCounter(stringResource(R.string.stats_errored), stats.streamsErrored, MaterialTheme.colorScheme.error)
|
||||
StreamCounter(stringResource(R.string.stats_cancelled), stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
// Avg times
|
||||
@@ -541,7 +546,7 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Avg TTFT",
|
||||
text = stringResource(R.string.stats_avg_ttft),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -558,7 +563,7 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Peak TTFT",
|
||||
text = stringResource(R.string.stats_peak_ttft),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -578,7 +583,7 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Avg completion",
|
||||
text = stringResource(R.string.stats_avg_completion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -595,7 +600,7 @@ private fun StreamStatsSection(stats: AppStats) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Slowest",
|
||||
text = stringResource(R.string.stats_slowest),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -636,7 +641,7 @@ private fun StreamCounter(label: String, count: Int, color: Color) {
|
||||
private fun VoiceSection(stats: VoiceStats) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Voice",
|
||||
text = stringResource(R.string.stats_voice),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -644,85 +649,85 @@ private fun VoiceSection(stats: VoiceStats) {
|
||||
|
||||
// STT subsection
|
||||
Text(
|
||||
text = "STT",
|
||||
text = stringResource(R.string.stats_stt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Last transcript",
|
||||
label = stringResource(R.string.stats_last_transcript),
|
||||
value = truncate(stats.lastTranscript.ifBlank { "—" }, 80),
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Latency",
|
||||
label = stringResource(R.string.stats_latency),
|
||||
value = if (stats.sttCallCount == 0) "—" else formatMsWithSeconds(stats.lastSttLatencyMs),
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Uploaded",
|
||||
label = stringResource(R.string.stats_uploaded),
|
||||
value = formatByteCount(stats.sttBytesUploaded),
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Calls",
|
||||
label = stringResource(R.string.stats_calls),
|
||||
value = "${stats.sttCallCount}",
|
||||
)
|
||||
|
||||
// TTS subsection
|
||||
Text(
|
||||
text = "TTS",
|
||||
text = stringResource(R.string.stats_tts),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Last sentence",
|
||||
label = stringResource(R.string.stats_last_sentence),
|
||||
value = truncate(stats.lastSynthesizedSentence.ifBlank { "—" }, 80),
|
||||
)
|
||||
val avgTtsLatency = if (stats.recentTtsLatenciesMs.isNotEmpty()) {
|
||||
stats.recentTtsLatenciesMs.sum() / stats.recentTtsLatenciesMs.size
|
||||
} else 0L
|
||||
KeyValueRow(
|
||||
label = "Avg latency (last ${stats.recentTtsLatenciesMs.size.coerceAtLeast(0)})",
|
||||
label = stringResource(R.string.stats_avg_latency_last_format, stats.recentTtsLatenciesMs.size.coerceAtLeast(0)),
|
||||
value = if (stats.recentTtsLatenciesMs.isEmpty()) "—" else formatMsWithSeconds(avgTtsLatency),
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Response chunks",
|
||||
label = stringResource(R.string.stats_response_chunks),
|
||||
value = "${stats.currentResponseTtsChunks}",
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Last chunk gap",
|
||||
label = stringResource(R.string.stats_last_chunk_gap),
|
||||
value = if (stats.currentResponseTtsChunks <= 1) "—" else formatMsWithSeconds(stats.lastTtsChunkGapMs),
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Received",
|
||||
label = stringResource(R.string.stats_received),
|
||||
value = formatByteCount(stats.ttsBytesReceived),
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Calls",
|
||||
label = stringResource(R.string.stats_calls),
|
||||
value = "${stats.ttsCallCount}",
|
||||
)
|
||||
|
||||
// Barge-in + playback subsection
|
||||
Text(
|
||||
text = "Barge-in / Playback",
|
||||
text = stringResource(R.string.stats_barge_in_playback),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Barge-in events",
|
||||
label = stringResource(R.string.stats_barge_in_events),
|
||||
value = "${stats.bargeInCount}",
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "VAD threshold",
|
||||
label = stringResource(R.string.stats_vad_threshold),
|
||||
value = if (stats.vadThresholdMs <= 0) "—" else "${stats.vadThresholdMs} ms",
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Mode",
|
||||
label = stringResource(R.string.stats_mode),
|
||||
value = stats.interactionMode,
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Queue depth",
|
||||
label = stringResource(R.string.stats_queue_depth),
|
||||
value = "${stats.ttsQueueDepth}",
|
||||
)
|
||||
KeyValueRow(
|
||||
label = "Player",
|
||||
label = stringResource(R.string.stats_player),
|
||||
value = stats.playerState,
|
||||
)
|
||||
}
|
||||
@@ -734,7 +739,7 @@ private fun VoiceSection(stats: VoiceStats) {
|
||||
private fun ToolCallsSection(events: List<ToolCallEvent>) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Tool Calls",
|
||||
text = stringResource(R.string.stats_tool_calls),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -754,9 +759,9 @@ private fun ToolCallRow(event: ToolCallEvent, nowMs: Long) {
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
val statusLabel = when {
|
||||
!event.isComplete -> "running"
|
||||
event.success == true -> "ok"
|
||||
else -> "failed"
|
||||
!event.isComplete -> stringResource(R.string.stats_running)
|
||||
event.success == true -> stringResource(R.string.stats_ok)
|
||||
else -> stringResource(R.string.stats_failed)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(
|
||||
@@ -861,14 +866,15 @@ private fun truncate(text: String, limit: Int): String =
|
||||
if (text.length <= limit) text else "${text.take(limit - 1)}…"
|
||||
|
||||
/** Relative timestamp like "just now" / "3s ago" / "2m ago" / "1h ago". */
|
||||
@Composable
|
||||
private fun relativeTime(eventMs: Long, nowMs: Long): String {
|
||||
val deltaMs = (nowMs - eventMs).coerceAtLeast(0L)
|
||||
val seconds = deltaMs / 1000
|
||||
return when {
|
||||
seconds < 2 -> "just now"
|
||||
seconds < 60 -> "${seconds}s ago"
|
||||
seconds < 3600 -> "${seconds / 60}m ago"
|
||||
seconds < 86400 -> "${seconds / 3600}h ago"
|
||||
else -> "${seconds / 86400}d ago"
|
||||
seconds < 2 -> stringResource(R.string.stats_just_now)
|
||||
seconds < 60 -> stringResource(R.string.stats_seconds_ago, seconds)
|
||||
seconds < 3600 -> stringResource(R.string.stats_minutes_ago, seconds / 60)
|
||||
seconds < 86400 -> stringResource(R.string.stats_hours_ago, seconds / 3600)
|
||||
else -> stringResource(R.string.stats_days_ago, seconds / 86400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
@@ -79,7 +81,7 @@ fun SubagentLane(
|
||||
|
||||
val laneLabel = calls.firstNotNullOfOrNull { call ->
|
||||
call.taskLabel?.takeIf { it.isNotBlank() }
|
||||
} ?: "Agent ${taskIndex + 1}"
|
||||
} ?: stringResource(R.string.subagent_lane_label_fallback, taskIndex + 1)
|
||||
|
||||
// Lane duration: first child start → last child completion.
|
||||
val duration = run {
|
||||
@@ -90,12 +92,18 @@ fun SubagentLane(
|
||||
} else null
|
||||
}
|
||||
|
||||
val toolCountLabel = "${calls.size} tool${if (calls.size == 1) "" else "s"}"
|
||||
val toolCountLabel = stringResource(R.string.subagent_lane_tool_count, calls.size)
|
||||
val statusMeta = when {
|
||||
anyRunning -> "$runningCount running"
|
||||
anyRunning -> stringResource(R.string.subagent_lane_running_count, runningCount)
|
||||
duration != null -> "$toolCountLabel · $duration"
|
||||
else -> toolCountLabel
|
||||
}
|
||||
val laneA11y = stringResource(R.string.subagent_lane_a11y, laneLabel, statusMeta) +
|
||||
if (anyFailed) ", " + stringResource(R.string.cd_subagent_failed) else ""
|
||||
val completionLabel = stringResource(R.string.cd_subagent_completed)
|
||||
val collapseLabel = stringResource(R.string.cd_subagent_collapse)
|
||||
val expandLabel = stringResource(R.string.cd_subagent_expand)
|
||||
val failureLabel = stringResource(R.string.cd_subagent_failed)
|
||||
|
||||
var expanded by remember { mutableStateOf(!allComplete) }
|
||||
|
||||
@@ -111,8 +119,7 @@ fun SubagentLane(
|
||||
.padding(start = 8.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.semantics {
|
||||
contentDescription = "Subagent $laneLabel, $statusMeta" +
|
||||
if (anyFailed) ", failed" else ""
|
||||
contentDescription = laneA11y
|
||||
},
|
||||
) {
|
||||
// Guide rail
|
||||
@@ -165,7 +172,7 @@ fun SubagentLane(
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = if (anyFailed) Icons.Filled.Close else Icons.Filled.Check,
|
||||
contentDescription = if (anyFailed) "Failed" else "Completed",
|
||||
contentDescription = if (anyFailed) failureLabel else completionLabel,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = if (anyFailed) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
@@ -176,7 +183,7 @@ fun SubagentLane(
|
||||
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
contentDescription = if (expanded) collapseLabel else expandLabel,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -26,10 +26,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Inline search row that appears below the terminal top app bar.
|
||||
@@ -85,7 +87,7 @@ fun TerminalSearchBar(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text("Search scrollback") },
|
||||
placeholder = { Text(stringResource(R.string.term_search_placeholder)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
@@ -106,7 +108,7 @@ fun TerminalSearchBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = "Previous match",
|
||||
contentDescription = stringResource(R.string.term_search_cd_previous),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@@ -115,13 +117,13 @@ fun TerminalSearchBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = "Next match",
|
||||
contentDescription = stringResource(R.string.term_search_cd_next),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close search",
|
||||
contentDescription = stringResource(R.string.term_search_cd_close),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-26
@@ -43,6 +43,8 @@ import com.hermesandroid.relay.auth.PairedSession
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Bottom-sheet dialog showing full metadata for a single terminal tab plus
|
||||
@@ -110,9 +112,9 @@ fun TerminalSessionInfoSheet(
|
||||
Column {
|
||||
Text(
|
||||
text = if (tab.displayName.isNullOrBlank()) {
|
||||
"Tab ${tab.tabId}"
|
||||
stringResource(R.string.term_info_tab_format, tab.tabId)
|
||||
} else {
|
||||
"Tab ${tab.tabId} · ${tab.displayName}"
|
||||
stringResource(R.string.term_info_tab_name_format, tab.tabId, tab.displayName)
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -141,27 +143,27 @@ fun TerminalSessionInfoSheet(
|
||||
) {
|
||||
StatusChip(
|
||||
label = when {
|
||||
tab.attached -> "Attached"
|
||||
tab.attaching -> "Attaching…"
|
||||
tab.error != null -> "Error"
|
||||
!tab.userStarted -> "Not started"
|
||||
else -> "Detached"
|
||||
tab.attached -> stringResource(R.string.term_info_attached)
|
||||
tab.attaching -> stringResource(R.string.term_info_attaching)
|
||||
tab.error != null -> stringResource(R.string.term_info_error)
|
||||
!tab.userStarted -> stringResource(R.string.term_info_not_started)
|
||||
else -> stringResource(R.string.term_info_detached)
|
||||
},
|
||||
isPositive = tab.attached,
|
||||
)
|
||||
if (tab.tmuxAvailable) {
|
||||
StatusChip(label = "tmux", isPositive = true)
|
||||
StatusChip(label = stringResource(R.string.term_info_tmux), isPositive = true)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Per-tab metadata.
|
||||
InfoRow("PID", tab.pid?.toString() ?: "—")
|
||||
InfoRow("Shell", tab.shell ?: "—", monospace = true)
|
||||
InfoRow("Grid", "${tab.cols} × ${tab.rows}")
|
||||
InfoRow(stringResource(R.string.term_info_pid), tab.pid?.toString() ?: "—")
|
||||
InfoRow(stringResource(R.string.term_info_shell), tab.shell ?: "—", monospace = true)
|
||||
InfoRow(stringResource(R.string.term_info_grid), stringResource(R.string.term_info_grid_format, tab.cols, tab.rows))
|
||||
if (tab.error != null) {
|
||||
InfoRow("Last error", tab.error, valueColor = MaterialTheme.colorScheme.error)
|
||||
InfoRow(stringResource(R.string.term_info_last_error), tab.error, valueColor = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
@@ -172,7 +174,7 @@ fun TerminalSessionInfoSheet(
|
||||
// PairedDevicesScreen + ConnectionInfoSheet for visual parity.
|
||||
if (pairedSession != null) {
|
||||
Text(
|
||||
text = "Relay session",
|
||||
text = stringResource(R.string.term_info_relay_session),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -182,12 +184,12 @@ fun TerminalSessionInfoSheet(
|
||||
size = TransportSecuritySize.Row,
|
||||
)
|
||||
InfoRow(
|
||||
label = "Expires",
|
||||
label = stringResource(R.string.term_info_expires),
|
||||
value = formatExpiry(pairedSession.expiresAt),
|
||||
)
|
||||
if (pairedSession.grants.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Channel grants",
|
||||
text = stringResource(R.string.term_info_channel_grants),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -203,7 +205,7 @@ fun TerminalSessionInfoSheet(
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "No paired session",
|
||||
text = stringResource(R.string.term_info_no_paired_session),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -227,7 +229,7 @@ fun TerminalSessionInfoSheet(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Start session")
|
||||
Text(stringResource(R.string.term_info_start_session))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
@@ -237,7 +239,7 @@ fun TerminalSessionInfoSheet(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Reattach")
|
||||
Text(stringResource(R.string.term_info_reattach))
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
@@ -248,7 +250,7 @@ fun TerminalSessionInfoSheet(
|
||||
enabled = canCloseTab,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Close tab")
|
||||
Text(stringResource(R.string.term_info_close_tab))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +271,7 @@ fun TerminalSessionInfoSheet(
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Kill session")
|
||||
Text(stringResource(R.string.term_info_kill_session))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +280,7 @@ fun TerminalSessionInfoSheet(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
Text(stringResource(R.string.term_info_done))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,8 +305,8 @@ private fun RenameRow(
|
||||
value = draft,
|
||||
onValueChange = { draft = it.take(40) },
|
||||
singleLine = true,
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text("e.g. build, logs, claude") },
|
||||
label = { Text(stringResource(R.string.term_info_name)) },
|
||||
placeholder = { Text(stringResource(R.string.term_info_name_placeholder)) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { onCommit(draft) }),
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -313,7 +315,7 @@ private fun RenameRow(
|
||||
onClick = { onCommit(draft) },
|
||||
enabled = isDirty,
|
||||
) {
|
||||
Text(if (draft.isBlank() && currentName.isNotEmpty()) "Clear" else "Save")
|
||||
Text(if (draft.isBlank() && currentName.isNotEmpty()) stringResource(R.string.term_info_clear) else stringResource(R.string.term_info_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,6 +370,7 @@ private fun InfoRow(
|
||||
|
||||
@Composable
|
||||
private fun GrantChipLocal(channel: String, expiresAt: Long?) {
|
||||
val neverText = stringResource(R.string.term_info_never)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
@@ -378,7 +381,7 @@ private fun GrantChipLocal(channel: String, expiresAt: Long?) {
|
||||
text = buildString {
|
||||
append(channel)
|
||||
append(" · ")
|
||||
append(if (expiresAt == null) "never" else formatExpiryShort(expiresAt))
|
||||
append(if (expiresAt == null) neverText else formatExpiryShort(expiresAt))
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -386,8 +389,9 @@ private fun GrantChipLocal(channel: String, expiresAt: Long?) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatExpiry(epochSeconds: Long?): String {
|
||||
if (epochSeconds == null) return "Never"
|
||||
if (epochSeconds == null) return stringResource(R.string.term_info_never)
|
||||
return try {
|
||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
|
||||
.format(Date(epochSeconds * 1000L))
|
||||
|
||||
@@ -26,9 +26,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
|
||||
/**
|
||||
@@ -88,7 +90,7 @@ fun TerminalTabBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "New terminal tab",
|
||||
contentDescription = stringResource(R.string.cd_new_terminal_tab),
|
||||
tint = if (canAddMore) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
@@ -188,7 +190,7 @@ private fun TerminalTabChip(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close tab ${tab.tabId}",
|
||||
contentDescription = stringResource(R.string.cd_close_tab, tab.tabId),
|
||||
tint = fg,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
|
||||
@@ -38,10 +38,12 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.diagnostics.CheckStatus
|
||||
import com.hermesandroid.relay.diagnostics.StatusCheck
|
||||
@@ -95,7 +97,7 @@ fun TimelineView(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Timeline",
|
||||
text = stringResource(R.string.timeline_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -110,7 +112,7 @@ fun TimelineView(
|
||||
|
||||
if (buckets.isEmpty()) {
|
||||
Text(
|
||||
text = "No activity yet",
|
||||
text = stringResource(R.string.timeline_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -219,7 +221,7 @@ fun StatusCheckTimeline(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Status checks",
|
||||
text = stringResource(R.string.timeline_status_checks),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -238,7 +240,7 @@ fun StatusCheckTimeline(
|
||||
|
||||
if (checks.isEmpty()) {
|
||||
Text(
|
||||
text = "No checks yet — connect to a server to populate diagnostics.",
|
||||
text = stringResource(R.string.timeline_checks_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -361,7 +363,7 @@ private fun StatusCheckRow(
|
||||
|
||||
if (hasDetail) {
|
||||
Text(
|
||||
text = "Tap for log detail",
|
||||
text = stringResource(R.string.timeline_tap_detail),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
@@ -49,11 +49,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
|
||||
@Composable
|
||||
@@ -115,10 +117,10 @@ fun ToolProgressCard(
|
||||
|
||||
val toolIcon = toolIcon(toolCall.name)
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
isPreparing -> "preparing"
|
||||
else -> "running"
|
||||
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
|
||||
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
|
||||
isPreparing -> stringResource(R.string.tool_preparing_a11y)
|
||||
else -> stringResource(R.string.tool_progress_status_running)
|
||||
}
|
||||
|
||||
// Slow alpha breathe on the tool icon while preparing — an indeterminate
|
||||
@@ -137,12 +139,14 @@ fun ToolProgressCard(
|
||||
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
|
||||
String.format("%.1fs", seconds)
|
||||
} else null
|
||||
val durationDescription = duration?.let { stringResource(R.string.tool_duration_a11y, it) }.orEmpty()
|
||||
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription)
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
|
||||
contentDescription = toolDescription
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
@@ -239,7 +243,7 @@ fun ToolProgressCard(
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
toolCall.runId?.takeIf { it.isNotBlank() }?.let { runId ->
|
||||
Text(
|
||||
text = "Run: $runId",
|
||||
text = stringResource(R.string.tool_run_id, runId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -259,7 +263,7 @@ fun ToolProgressCard(
|
||||
toolCall.args?.let { args ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Arguments:",
|
||||
text = stringResource(R.string.tool_arguments),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -275,7 +279,7 @@ fun ToolProgressCard(
|
||||
val preview = compactToolDetail(error)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Error:",
|
||||
text = stringResource(R.string.tool_error),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
@@ -292,7 +296,7 @@ fun ToolProgressCard(
|
||||
val preview = compactToolDetail(result)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Result:",
|
||||
text = stringResource(R.string.tool_result),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
+24
-21
@@ -23,6 +23,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurityKind
|
||||
@@ -195,20 +197,21 @@ private fun RenderBadge(
|
||||
* Label copy uses "Plain" rather than "Insecure" — amber, not red, and
|
||||
* matches the UX pass where plaintext LAN is treated as informational.
|
||||
*/
|
||||
@Composable
|
||||
fun insecureReasonLabel(reason: String?, activeRole: String? = null): String {
|
||||
val roleLabel = when (activeRole?.lowercase()) {
|
||||
"lan" -> "Plain (on LAN)"
|
||||
"tailscale" -> "Plain (on Tailscale)"
|
||||
"public" -> "Plain (on public URL)"
|
||||
"lan" -> stringResource(R.string.transport_badge_insecure_lan)
|
||||
"tailscale" -> stringResource(R.string.transport_badge_insecure_tailscale)
|
||||
"public" -> stringResource(R.string.transport_badge_insecure_public)
|
||||
null, "" -> null
|
||||
else -> "Plain (on $activeRole)"
|
||||
else -> stringResource(R.string.transport_badge_insecure_role_format, activeRole)
|
||||
}
|
||||
if (roleLabel != null) return roleLabel
|
||||
return when (reason) {
|
||||
"lan_only" -> "Plain (LAN only)"
|
||||
"tailscale_vpn" -> "Plain (Tailscale)"
|
||||
"local_dev" -> "Plain (dev only)"
|
||||
else -> "Plain (no TLS)"
|
||||
"lan_only" -> stringResource(R.string.transport_badge_insecure_lan_only)
|
||||
"tailscale_vpn" -> stringResource(R.string.transport_badge_insecure_tailscale_vpn)
|
||||
"local_dev" -> stringResource(R.string.transport_badge_insecure_dev)
|
||||
else -> stringResource(R.string.transport_badge_insecure_no_tls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +223,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
|
||||
TransportSecurityState.AllSecure -> {
|
||||
val green = Color(0xFF2E7D32)
|
||||
BadgeAppearance(
|
||||
label = "Secure \u2014 TLS",
|
||||
label = stringResource(R.string.transport_badge_secure_tls),
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
@@ -229,7 +232,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
|
||||
// Amber (not red): secure fallback exists, so this is informational.
|
||||
val amber = Color(0xFFF9A825)
|
||||
BadgeAppearance(
|
||||
label = "Mixed \u2014 secure fallback available",
|
||||
label = stringResource(R.string.transport_badge_mixed),
|
||||
bg = amber.copy(alpha = 0.16f),
|
||||
fg = amber,
|
||||
)
|
||||
@@ -237,7 +240,7 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
|
||||
TransportSecurityState.AllInsecure -> {
|
||||
val red = MaterialTheme.colorScheme.error
|
||||
BadgeAppearance(
|
||||
label = "All routes plain \u2014 dev only",
|
||||
label = stringResource(R.string.transport_badge_plain),
|
||||
bg = red.copy(alpha = 0.16f),
|
||||
fg = red,
|
||||
)
|
||||
@@ -255,7 +258,7 @@ private fun resolveAppearance(
|
||||
if (isSecure) {
|
||||
val green = Color(0xFF2E7D32)
|
||||
return BadgeAppearance(
|
||||
label = "Secure (TLS)",
|
||||
label = stringResource(R.string.transport_badge_secure_tls_short),
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
@@ -320,35 +323,35 @@ private fun connSecAppearance(security: ConnectionSecurity): ConnSecAppearance {
|
||||
val red = MaterialTheme.colorScheme.error
|
||||
return when (security.level) {
|
||||
ConnectionSecurityLevel.Tls -> ConnSecAppearance(
|
||||
label = "Encrypted · TLS",
|
||||
label = stringResource(R.string.transport_badge_encrypted_tls),
|
||||
icon = Icons.Filled.Lock,
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
ConnectionSecurityLevel.Overlay -> ConnSecAppearance(
|
||||
label = "Encrypted · ${security.mechanism}",
|
||||
label = stringResource(R.string.transport_badge_encrypted_mech_format, security.mechanism),
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
ConnectionSecurityLevel.Mixed -> ConnSecAppearance(
|
||||
label = "Mixed routes",
|
||||
label = stringResource(R.string.transport_badge_mixed_routes),
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = amber.copy(alpha = 0.16f),
|
||||
fg = amber,
|
||||
)
|
||||
ConnectionSecurityLevel.Plain -> ConnSecAppearance(
|
||||
label = if (security.mechanism.isNotBlank() && security.mechanism != "Plain") {
|
||||
"Not encrypted · ${security.mechanism}"
|
||||
stringResource(R.string.transport_badge_not_encrypted_mech_format, security.mechanism)
|
||||
} else {
|
||||
"Not encrypted"
|
||||
stringResource(R.string.transport_badge_not_encrypted)
|
||||
},
|
||||
icon = Icons.Filled.LockOpen,
|
||||
bg = red.copy(alpha = 0.16f),
|
||||
fg = red,
|
||||
)
|
||||
ConnectionSecurityLevel.Unknown -> ConnSecAppearance(
|
||||
label = "Checking…",
|
||||
label = stringResource(R.string.transport_badge_checking),
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
fg = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -405,11 +408,11 @@ fun SurfaceSecurityGlyph(
|
||||
val green = Color(0xFF2E7D32)
|
||||
val amber = Color(0xFFF9A825)
|
||||
val (icon, tint, desc) = when (kind) {
|
||||
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, "Encrypted (TLS)")
|
||||
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, "Encrypted")
|
||||
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, stringResource(R.string.transport_badge_glyph_tls))
|
||||
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, stringResource(R.string.transport_badge_glyph_encrypted))
|
||||
// Per-route plaintext is amber (informational), not red — a secure
|
||||
// route may exist alongside it.
|
||||
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, "Not encrypted")
|
||||
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, stringResource(R.string.transport_badge_glyph_not_encrypted))
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
|
||||
@@ -31,6 +31,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* v0.4.1 — sideload-only "unattended access" toggle row + one-time
|
||||
@@ -94,19 +96,16 @@ fun UnattendedAccessRow(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Unattended Access",
|
||||
text = stringResource(R.string.unattended_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
!masterEnabled -> "Requires Agent Control — " +
|
||||
"enable the master switch above first."
|
||||
enabled -> "On — agent may wake the screen " +
|
||||
"and act while you're away."
|
||||
else -> "Off — bridge actions only land when " +
|
||||
"the screen is already on."
|
||||
!masterEnabled -> stringResource(R.string.unattended_requires_master)
|
||||
enabled -> stringResource(R.string.unattended_on)
|
||||
else -> stringResource(R.string.unattended_off)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -130,10 +129,7 @@ fun UnattendedAccessRow(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Acquires a screen-bright wake lock on each " +
|
||||
"incoming bridge command and asks the system to " +
|
||||
"dismiss the keyguard. Hard-bounded by the bridge " +
|
||||
"auto-disable timer.",
|
||||
text = stringResource(R.string.unattended_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -204,16 +200,12 @@ private fun KeyguardDetectedAlert() {
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Keyguard detected",
|
||||
text = stringResource(R.string.unattended_keyguard_detected),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "The screen will wake but stop at the lock " +
|
||||
"screen. Android won't let third-party apps " +
|
||||
"dismiss PIN / pattern / biometric locks. Set " +
|
||||
"your lock to None or Swipe in Settings > " +
|
||||
"Security to let the agent reach apps.",
|
||||
text = stringResource(R.string.unattended_keyguard_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
@@ -246,81 +238,54 @@ private fun UnattendedScaryDialog(
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = { Text("Enable Unattended Access?") },
|
||||
title = { Text(stringResource(R.string.unattended_dialog_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "Unattended access lets your Hermes agent wake " +
|
||||
"the screen and drive your phone while you're not " +
|
||||
"watching it. This is a powerful capability with " +
|
||||
"real risk — make sure you understand it before " +
|
||||
"enabling.",
|
||||
text = stringResource(R.string.unattended_dialog_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "What it does:",
|
||||
text = stringResource(R.string.unattended_dialog_what_it_does),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "• Each incoming bridge command holds a screen-" +
|
||||
"bright wake lock so taps and gestures land on a " +
|
||||
"lit display.\n" +
|
||||
"• On lock screens with no credential set (None " +
|
||||
"or Swipe), the system is asked to dismiss the " +
|
||||
"keyguard so the agent can reach app UIs.\n" +
|
||||
"• The 'Hermes has device control' notification — " +
|
||||
"posted by the master Agent Control switch, not " +
|
||||
"this toggle — stays visible so you can see the " +
|
||||
"bridge is running.",
|
||||
text = stringResource(R.string.unattended_dialog_what_it_does_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "Limitation: credential locks cannot be dismissed.",
|
||||
text = stringResource(R.string.unattended_dialog_limitation),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = if (credentialLockDetected) {
|
||||
"Your device currently has a PIN, pattern, or " +
|
||||
"biometric lock set. Android does not let " +
|
||||
"third-party apps dismiss these — the screen " +
|
||||
"will wake, but stop at the lock screen. To " +
|
||||
"let the agent reach apps, change your lock " +
|
||||
"to None or Swipe in Settings > Security."
|
||||
stringResource(R.string.unattended_dialog_limitation_lock_detected)
|
||||
} else {
|
||||
"If you later set a PIN, pattern, or biometric " +
|
||||
"lock, Android will not let Hermes dismiss it. " +
|
||||
"The screen will wake, but stop at the lock " +
|
||||
"screen, and the bridge will report a " +
|
||||
"'keyguard_blocked' error to the agent."
|
||||
stringResource(R.string.unattended_dialog_limitation_no_lock)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "How to disable:",
|
||||
text = stringResource(R.string.unattended_dialog_how_to_disable),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "• Flip this same toggle off at any time.\n" +
|
||||
"• The bridge auto-disable timer (default 30 minutes " +
|
||||
"of idle) will turn unattended access off along " +
|
||||
"with the master bridge toggle.\n" +
|
||||
"• Disconnecting from the relay also drops the " +
|
||||
"wake lock immediately.",
|
||||
text = stringResource(R.string.unattended_dialog_how_to_disable_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text("I understand — enable")
|
||||
Text(stringResource(R.string.unattended_dialog_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.unattended_dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Global single-line banner rendered at the top of [RelayApp]'s scaffold
|
||||
@@ -107,7 +109,7 @@ fun UnattendedGlobalBanner(
|
||||
) {
|
||||
PulsingStatusDot(color = amberFill)
|
||||
Text(
|
||||
text = "Unattended access ON — agent can wake and drive this device",
|
||||
text = stringResource(R.string.unattended_banner_text),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = amberOn,
|
||||
|
||||
@@ -43,12 +43,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.update.UpdateAvailabilitySource
|
||||
import com.hermesandroid.relay.update.UpdateDismissalPreferences
|
||||
import com.hermesandroid.relay.update.UpdateStatus
|
||||
@@ -318,21 +320,21 @@ fun UpdateAvailableBanner(
|
||||
|
||||
when (status) {
|
||||
is UpdateStatus.Available -> {
|
||||
title = "Update available"
|
||||
subtitle = "${displayVersion(status.versionLabel)} is ready to install."
|
||||
actionLabel = "Update"
|
||||
title = stringResource(R.string.update_banner_available)
|
||||
subtitle = stringResource(R.string.update_banner_subtitle_available, displayVersion(status.versionLabel))
|
||||
actionLabel = stringResource(R.string.update_banner_update)
|
||||
showDismiss = true
|
||||
}
|
||||
is UpdateStatus.Downloading -> {
|
||||
title = "Downloading update…"
|
||||
title = stringResource(R.string.update_banner_downloading)
|
||||
subtitle = displayVersion(status.versionLabel)
|
||||
actionLabel = null
|
||||
showDismiss = true
|
||||
}
|
||||
is UpdateStatus.Downloaded -> {
|
||||
title = "Update ready — restart"
|
||||
subtitle = "${displayVersion(status.versionLabel)} downloaded. Restart to finish."
|
||||
actionLabel = "Restart"
|
||||
title = stringResource(R.string.update_banner_ready)
|
||||
subtitle = stringResource(R.string.update_banner_subtitle_downloaded, displayVersion(status.versionLabel))
|
||||
actionLabel = stringResource(R.string.update_banner_restart)
|
||||
showDismiss = false
|
||||
}
|
||||
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> return
|
||||
@@ -416,7 +418,7 @@ fun UpdateAvailableBanner(
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = "Dismiss",
|
||||
contentDescription = stringResource(R.string.update_banner_cd_dismiss),
|
||||
tint = contentColor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.update.AvailableUpdate
|
||||
|
||||
/**
|
||||
@@ -80,12 +82,12 @@ fun UpdateBanner(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = "Update available — v${update.latestVersion}",
|
||||
text = stringResource(R.string.update_banner_available),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "You're on ${update.currentVersion}. Tap Update to download.",
|
||||
text = stringResource(R.string.update_banner_body_fmt, update.currentVersion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
@@ -104,12 +106,12 @@ fun UpdateBanner(
|
||||
),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text("Update")
|
||||
Text(stringResource(R.string.update_banner_update))
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = "Dismiss",
|
||||
contentDescription = stringResource(R.string.update_banner_cd_dismiss),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import java.io.File
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
|
||||
@@ -509,7 +511,7 @@ fun VoiceModeOverlay(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text("Retry")
|
||||
Text(stringResource(R.string.voice_overlay_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,7 +664,7 @@ private fun VoiceMicButton(
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = "Voice mic",
|
||||
contentDescription = stringResource(R.string.voice_overlay_mic_cd),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(iconSize.dp),
|
||||
)
|
||||
@@ -678,12 +680,13 @@ private fun voiceStateToSphereState(state: VoiceState): SphereState = when (stat
|
||||
VoiceState.Idle -> SphereState.Idle
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun stateHint(state: VoiceState): String = when (state) {
|
||||
VoiceState.Idle -> "Tap the mic to speak"
|
||||
VoiceState.Listening -> "Listening..."
|
||||
VoiceState.Transcribing -> "Transcribing..."
|
||||
VoiceState.Thinking -> "Thinking..."
|
||||
VoiceState.Speaking -> "Speaking..."
|
||||
VoiceState.Idle -> stringResource(R.string.voice_overlay_tap_mic)
|
||||
VoiceState.Listening -> stringResource(R.string.voice_overlay_listening)
|
||||
VoiceState.Transcribing -> stringResource(R.string.voice_overlay_transcribing)
|
||||
VoiceState.Thinking -> stringResource(R.string.voice_overlay_thinking)
|
||||
VoiceState.Speaking -> stringResource(R.string.voice_overlay_speaking)
|
||||
VoiceState.Error -> ""
|
||||
}
|
||||
|
||||
@@ -713,10 +716,11 @@ internal fun pendingVoiceTranscriptText(
|
||||
return if (alreadyRendered) null else transcribed
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InteractionMode.label(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> "Tap to talk"
|
||||
InteractionMode.HoldToTalk -> "Hold to talk"
|
||||
InteractionMode.Continuous -> "Continuous"
|
||||
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk)
|
||||
InteractionMode.HoldToTalk -> stringResource(R.string.voice_overlay_hold_to_talk)
|
||||
InteractionMode.Continuous -> stringResource(R.string.voice_overlay_continuous)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -747,17 +751,17 @@ private fun VoiceSessionPill(
|
||||
) {
|
||||
val engineText = voiceEngineLabel(engineMode)
|
||||
val providerText = voiceProviderLabel(provider, model, voice, outputEnabled)
|
||||
val profileText = profileName?.takeIf { it.isNotBlank() } ?: "default profile"
|
||||
val profileText = profileName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_default_profile)
|
||||
val scopeText = when (configScope) {
|
||||
"profile" -> "profile voice"
|
||||
"relay" -> "relay voice"
|
||||
"global" -> "global voice"
|
||||
"profile" -> stringResource(R.string.voice_overlay_profile_voice)
|
||||
"relay" -> stringResource(R.string.voice_overlay_relay_voice)
|
||||
"global" -> stringResource(R.string.voice_overlay_global_voice)
|
||||
else -> null
|
||||
}
|
||||
val headlineText = if (focusMode) {
|
||||
"$engineText / $profileText / $providerText"
|
||||
} else {
|
||||
"${stateHint(uiState.state).ifBlank { "Voice ready" }} / $engineText / $providerText"
|
||||
"${stateHint(uiState.state).ifBlank { stringResource(R.string.voice_overlay_voice_ready) }} / $engineText / $providerText"
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
@@ -801,7 +805,7 @@ private fun VoiceSessionPill(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Voice",
|
||||
text = stringResource(R.string.voice_overlay_voice),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -819,7 +823,7 @@ private fun VoiceSessionPill(
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse voice controls" else "Expand voice controls",
|
||||
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
@@ -829,7 +833,7 @@ private fun VoiceSessionPill(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Exit voice mode",
|
||||
contentDescription = stringResource(R.string.voice_overlay_exit_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
@@ -911,7 +915,7 @@ private fun VoiceSessionPill(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
if (focusMode) "Compact" else "Focus",
|
||||
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -923,13 +927,13 @@ private fun VoiceSessionPill(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Overlay", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(stringResource(R.string.voice_overlay_overlay), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onExit,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Exit", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(stringResource(R.string.voice_overlay_exit), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
// Settings link (4c): exit voice mode before navigating
|
||||
// so the overlay isn't left floating over the Voice
|
||||
@@ -942,7 +946,7 @@ private fun VoiceSessionPill(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = "Voice settings",
|
||||
contentDescription = stringResource(R.string.voice_overlay_settings_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -1168,32 +1172,35 @@ private fun VoiceHandoffStrip(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun voiceProviderLabel(
|
||||
provider: String?,
|
||||
model: String?,
|
||||
voice: String?,
|
||||
outputEnabled: Boolean?,
|
||||
): String {
|
||||
if (outputEnabled == false) return "output off"
|
||||
val providerPart = provider?.takeIf { it.isNotBlank() } ?: "provider ..."
|
||||
if (outputEnabled == false) return stringResource(R.string.voice_overlay_provider_output_off)
|
||||
val providerPart = provider?.takeIf { it.isNotBlank() } ?: stringResource(R.string.voice_overlay_provider_placeholder)
|
||||
val modelPart = model?.takeIf { it.isNotBlank() }
|
||||
val voicePart = voice?.takeIf { it.isNotBlank() }
|
||||
return listOfNotNull(providerPart, modelPart, voicePart).joinToString(" / ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
|
||||
"realtime_agent" -> "Realtime Agent"
|
||||
"hermes_voice_output" -> "Hermes voice"
|
||||
null, "" -> "Voice engine ..."
|
||||
"realtime_agent" -> stringResource(R.string.voice_overlay_engine_realtime)
|
||||
"hermes_voice_output" -> stringResource(R.string.voice_overlay_engine_hermes)
|
||||
null, "" -> stringResource(R.string.voice_overlay_engine_placeholder)
|
||||
else -> engineMode
|
||||
.replace('_', ' ')
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InteractionMode.shortLabel(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> "Tap"
|
||||
InteractionMode.HoldToTalk -> "Hold"
|
||||
InteractionMode.Continuous -> "Auto"
|
||||
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap)
|
||||
InteractionMode.HoldToTalk -> stringResource(R.string.voice_overlay_hold)
|
||||
InteractionMode.Continuous -> stringResource(R.string.voice_overlay_auto)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1232,9 +1239,9 @@ private fun CompactTranscriptRow(
|
||||
val isVoiceActionBubble = message.role == MessageRole.ASSISTANT &&
|
||||
message.id.startsWith("voice-intent-")
|
||||
val caption = when {
|
||||
isVoiceActionBubble -> "ACTION"
|
||||
message.role == MessageRole.USER -> "YOU"
|
||||
else -> "AGENT"
|
||||
isVoiceActionBubble -> stringResource(R.string.voice_overlay_caption_action)
|
||||
message.role == MessageRole.USER -> stringResource(R.string.voice_overlay_caption_you)
|
||||
else -> stringResource(R.string.voice_overlay_caption_agent)
|
||||
}
|
||||
val captionColor = when {
|
||||
isVoiceActionBubble -> MaterialTheme.colorScheme.tertiary
|
||||
@@ -1301,12 +1308,20 @@ private fun CompactTranscriptRow(
|
||||
|
||||
@Composable
|
||||
private fun VoiceToolStatusRow(toolCall: ToolCall) {
|
||||
val status = when {
|
||||
// Internal enum-style status drives both color and the localized label
|
||||
// shown to the user. Localized text comes from stringResource() in the
|
||||
// Composable so VoiceModeOverlay can switch locales mid-session.
|
||||
val statusKey = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "done"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
else -> "running"
|
||||
}
|
||||
val statusColor = when (status) {
|
||||
val statusLabel = when (statusKey) {
|
||||
"done" -> stringResource(R.string.voice_overlay_tool_status_done)
|
||||
"failed" -> stringResource(R.string.voice_overlay_tool_status_failed)
|
||||
else -> stringResource(R.string.voice_overlay_tool_status_running)
|
||||
}
|
||||
val statusColor = when (statusKey) {
|
||||
"done" -> MaterialTheme.colorScheme.primary
|
||||
"failed" -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.tertiary
|
||||
@@ -1348,7 +1363,7 @@ private fun VoiceToolStatusRow(toolCall: ToolCall) {
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = duration?.let { "$status $it" } ?: status,
|
||||
text = duration?.let { "$statusLabel $it" } ?: statusLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColor,
|
||||
)
|
||||
@@ -1407,9 +1422,9 @@ private fun HermesConfirmationCard(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = { onAnswer("allow") }) { Text("Allow") }
|
||||
TextButton(onClick = { onAnswer("deny") }) { Text("Deny") }
|
||||
TextButton(onClick = { onAnswer("cancel") }) { Text("Cancel") }
|
||||
TextButton(onClick = { onAnswer("allow") }) { Text(stringResource(R.string.voice_overlay_allow)) }
|
||||
TextButton(onClick = { onAnswer("deny") }) { Text(stringResource(R.string.voice_overlay_deny)) }
|
||||
TextButton(onClick = { onAnswer("cancel") }) { Text(stringResource(R.string.voice_overlay_cancel)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1472,9 +1487,9 @@ private fun DestructiveCountdownRow(
|
||||
) {
|
||||
Text(
|
||||
text = if (label.isNotBlank()) {
|
||||
"$label in ${durationSec}s — say cancel to stop"
|
||||
stringResource(R.string.voice_overlay_confirming_format, label, durationSec)
|
||||
} else {
|
||||
"Confirming…"
|
||||
stringResource(R.string.voice_overlay_confirming)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
@@ -1548,7 +1563,7 @@ private fun BackgroundRunChip(
|
||||
// no pulse, no live ticker.
|
||||
val dotAlpha = if (done) 1f else pulse
|
||||
val title = when (display.phase) {
|
||||
BackgroundRunPhase.RECONNECTING -> "Reconnecting — your task is still running"
|
||||
BackgroundRunPhase.RECONNECTING -> stringResource(R.string.voice_overlay_reconnecting_task)
|
||||
BackgroundRunPhase.DELIVERING -> display.message
|
||||
BackgroundRunPhase.RUNNING -> display.message
|
||||
BackgroundRunPhase.DONE -> display.message
|
||||
@@ -1559,8 +1574,11 @@ private fun BackgroundRunChip(
|
||||
?.let { add(it.trimEnd('.', '…')) }
|
||||
if (display.completedToolCount > 0) {
|
||||
add(
|
||||
"${display.completedToolCount} step" +
|
||||
if (display.completedToolCount == 1) "" else "s"
|
||||
stringResource(
|
||||
if (display.completedToolCount == 1) R.string.voice_overlay_steps_one
|
||||
else R.string.voice_overlay_steps_many,
|
||||
display.completedToolCount,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (display.queuedCount > 0) {
|
||||
@@ -1677,7 +1695,7 @@ private fun PermissionDeniedChip(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "OPEN",
|
||||
text = stringResource(R.string.voice_overlay_open),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
|
||||
@@ -17,6 +17,8 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -56,7 +58,7 @@ fun WhatsNewDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Column {
|
||||
Text("What's New")
|
||||
Text(stringResource(R.string.whats_new_title))
|
||||
notes.version?.let { version ->
|
||||
Text(
|
||||
text = version,
|
||||
@@ -76,7 +78,7 @@ fun WhatsNewDialog(
|
||||
) {
|
||||
if (notes.groups.isEmpty()) {
|
||||
Text(
|
||||
text = notes.fallback ?: "No release notes available.",
|
||||
text = notes.fallback ?: stringResource(R.string.whats_new_no_notes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -85,7 +87,7 @@ fun WhatsNewDialog(
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Got it")
|
||||
Text(stringResource(R.string.whats_new_got_it))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -314,6 +316,6 @@ private fun loadWhatsNew(context: Context): String {
|
||||
return try {
|
||||
context.assets.open("whats_new.txt").bufferedReader().readText()
|
||||
} catch (_: Exception) {
|
||||
"No release notes available."
|
||||
context.getString(R.string.whats_new_no_notes)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user