Compare commits

..
Author SHA1 Message Date
Bailey Dixon 0fad937c23 fix(release): reuse verified Android preflight artifacts 2026-07-26 17:24:56 -04:00
42 changed files with 1613 additions and 1887 deletions
+2
View File
@@ -14,6 +14,8 @@ function classifyCiPaths(paths) {
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
'scripts/android-release-artifacts.py', 'scripts/test_android_release_artifacts.py',
'scripts/release-android.ps1',
'.github/workflows/play-preflight-android.yml',
'.github/workflows/approve-release-android.yml',
'.github/workflows/release-android.yml',
@@ -15,6 +15,9 @@ const none = {
assert.deepEqual(classifyCiPaths(['README.md']), none);
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/android-release-artifacts.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/test_android_release_artifacts.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/release-android.ps1']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
+45 -8
View File
@@ -5,6 +5,7 @@
# stable tag triggers Play submission first, then GitHub publication.
name: Approve Android Release
run-name: Approve Android ${{ inputs.version }}
on:
workflow_dispatch:
@@ -24,8 +25,9 @@ concurrency:
jobs:
approve:
name: Verify preflight and create release tag
name: Verify, tag, and observe public release
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
with:
@@ -56,13 +58,25 @@ jobs:
RELEASE_TREE: ${{ steps.metadata.outputs.tree }}
run: |
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | length')
if [ "$COUNT" -lt 1 ]; then
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$RUN_ID" ]; then
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
exit 1
fi
echo "Verified Play preflight proof: $ARTIFACT_NAME"
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
exit 1
fi
if [ "$CONCLUSION" != "success" ]; then
echo "::error::Preflight workflow run ${RUN_ID} concluded ${CONCLUSION}"
exit 1
fi
echo "Verified successful Play preflight artifact: $ARTIFACT_NAME (run $RUN_ID)"
- name: Ensure release tag does not already exist
env:
@@ -83,18 +97,41 @@ jobs:
-f ref="refs/tags/android-v${VERSION}" \
-f sha="$GITHUB_SHA"
- name: Start the tag release workflow
- name: Start and observe the tag release workflow
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.metadata.outputs.version }}
run: |
DISPATCHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
gh workflow run release-android.yml \
--ref=main \
-f version="$VERSION"
RUN_ID=""
for _ in $(seq 1 30); do
RUN_ID=$(gh run list \
--workflow=release-android.yml \
--event=workflow_dispatch \
--limit=20 \
--json databaseId,headSha,createdAt \
--jq ".[] | select(.headSha == \"$GITHUB_SHA\" and .createdAt >= \"$DISPATCHED_AT\") | .databaseId" \
| head -n 1)
if [ -n "$RUN_ID" ]; then
break
fi
sleep 2
done
if [ -z "$RUN_ID" ]; then
echo "::error::Release workflow dispatch succeeded but its run could not be located"
exit 1
fi
echo "Observing Release Android run ${RUN_ID}"
gh run watch "$RUN_ID" --exit-status --interval 15
- name: Approval summary
run: |
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
echo "## Android release complete" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
echo "The immutable release workflow completed successfully: it promoted the preflighted Play draft and published the exact signed preflight artifacts." >> "$GITHUB_STEP_SUMMARY"
+6
View File
@@ -33,6 +33,9 @@ on:
- "scripts/check-android-locales.py"
- "scripts/android-locale-harness.py"
- "scripts/check-android-collection-apis.py"
- "scripts/android-release-artifacts.py"
- "scripts/test_android_release_artifacts.py"
- "scripts/release-android.ps1"
- ".github/workflows/ci-android.yml"
- ".github/workflows/play-preflight-android.yml"
- ".github/workflows/approve-release-android.yml"
@@ -73,6 +76,9 @@ jobs:
- name: Reject unsafe Android collection APIs
run: python3 scripts/check-android-collection-apis.py
- name: Validate immutable release artifact contract
run: python3 -m unittest scripts/test_android_release_artifacts.py
- name: Run Android lint
run: ./gradlew lint --console=plain
+78 -3
View File
@@ -12,6 +12,7 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
actions: read
contents: read
pull-requests: read
@@ -30,6 +31,7 @@ jobs:
dashboard: ${{ steps.filter.outputs.dashboard }}
contract: ${{ steps.filter.outputs.contract }}
docs: ${{ steps.filter.outputs.docs }}
release_pr: ${{ steps.release.outputs.release_pr }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -39,6 +41,21 @@ jobs:
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
- name: Detect canonical release PR
id: release
env:
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
run: |
if [ "$BASE_REF" = "main" ] &&
[ "$HEAD_REF" = "dev" ] &&
[ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then
echo "release_pr=true" >> "$GITHUB_OUTPUT"
else
echo "release_pr=false" >> "$GITHUB_OUTPUT"
fi
- name: Classify changed files
id: filter
uses: actions/github-script@v8
@@ -62,9 +79,66 @@ jobs:
android:
needs: changes
if: needs.changes.outputs.android == 'true'
if: needs.changes.outputs.android == 'true' && needs.changes.outputs.release_pr != 'true'
uses: ./.github/workflows/ci-android.yml
release-proof:
name: Verify preflighted release tree
needs: changes
if: needs.changes.outputs.release_pr == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout simulated release merge
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Require unchanged successful Play preflight
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_DEV_COMMIT: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
MERGE_TREE=$(git rev-parse 'HEAD^{tree}')
DEV_COMMIT="$EXPECTED_DEV_COMMIT"
DEV_TREE=$(git rev-parse "${DEV_COMMIT}^{tree}")
if [ "$MERGE_TREE" != "$DEV_TREE" ]; then
echo "::error::The dev-to-main merge changes the preflighted tree ($DEV_TREE -> $MERGE_TREE)"
echo "Rerun Play Preflight from the simulated merge result before approval."
exit 1
fi
VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
ARTIFACT_NAME="play-preflight-${VERSION}-${DEV_TREE}"
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$RUN_ID" ]; then
echo "::error::No Play preflight artifact exists for dev tree $DEV_TREE and version $VERSION"
exit 1
fi
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
PREFLIGHT_COMMIT=$(jq -r '.head_sha' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
exit 1
fi
if [ "$PREFLIGHT_COMMIT" != "$DEV_COMMIT" ]; then
echo "::error::Preflight commit ${PREFLIGHT_COMMIT} does not match release dev commit ${DEV_COMMIT}"
exit 1
fi
if [ "$CONCLUSION" != "success" ]; then
echo "::error::Play preflight run ${RUN_ID} concluded ${CONCLUSION}"
exit 1
fi
echo "Verified release PR:"
echo " dev commit: $DEV_COMMIT"
echo " unchanged tree: $DEV_TREE"
echo " successful preflight run: $RUN_ID"
desktop:
needs: changes
if: needs.changes.outputs.desktop == 'true'
@@ -110,11 +184,12 @@ jobs:
guard:
name: Required checks
if: always()
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
needs: [changes, android, release-proof, desktop, plugin, dashboard, contract, docs]
runs-on: ubuntu-latest
env:
CHANGES_RESULT: ${{ needs.changes.result }}
ANDROID_RESULT: ${{ needs.android.result }}
RELEASE_PROOF_RESULT: ${{ needs.release-proof.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
PLUGIN_RESULT: ${{ needs.plugin.result }}
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
@@ -125,7 +200,7 @@ jobs:
shell: bash
run: |
failed=0
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
for check in CHANGES ANDROID RELEASE_PROOF DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
result_var="${check}_RESULT"
result="${!result_var}"
echo "$check: $result"
+66 -19
View File
@@ -2,10 +2,10 @@
#
# Run manually from the final dev or untagged main tree before creating
# android-v*. The job
# builds the same signed release artifacts, scans final DEX, and uploads the
# Google Play bundle as a production DRAFT. A successful upload is the automated
# Play gate while no public GitHub Release or sideload APK exists. Console-only
# pre-review and pre-launch reports are informational and do not block release.
# builds the signed release artifacts once, scans final DEX, and uploads the
# Google Play bundle as a production DRAFT. The signed APK/AAB and their manifest
# remain private Actions artifacts until approval publishes those exact bytes.
# Console-only pre-review and pre-launch reports are informational.
name: Play Preflight — Android
@@ -25,6 +25,51 @@ concurrency:
cancel-in-progress: false
jobs:
quality:
name: Validate release quality
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
- name: Validate translation catalogs and collection APIs
run: |
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
python3 -m unittest scripts/test_android_release_artifacts.py
- name: Run strict Android lint and focused tests
run: |
./gradlew lint :app:testSideloadDebugUnitTest \
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
--tests com.hermesandroid.relay.util.ServerAddressTest \
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
--tests com.hermesandroid.relay.data.AppLanguageTest \
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
--console=plain
preflight:
name: Build and upload private Play draft
runs-on: ubuntu-latest
@@ -121,25 +166,27 @@ jobs:
--resolution-strategy=ignore \
--release-name="Hermes-Relay ${{ steps.metadata.outputs.version }}"
- name: Record successful preflight for the exact commit
- name: Package immutable preflight artifacts
run: |
mkdir -p app/build/reports
cat > app/build/reports/play-preflight.json <<EOF
{
"version": "${{ steps.metadata.outputs.version }}",
"versionCode": "${{ steps.metadata.outputs.version_code }}",
"commit": "$GITHUB_SHA",
"tree": "${{ steps.metadata.outputs.tree }}",
"track": "production",
"status": "draft"
}
EOF
python3 scripts/android-release-artifacts.py package \
--version "${{ steps.metadata.outputs.version }}" \
--version-code "${{ steps.metadata.outputs.version_code }}" \
--commit "$GITHUB_SHA" \
--tree "${{ steps.metadata.outputs.tree }}" \
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
--output app/build/preflight-release
python3 scripts/android-release-artifacts.py verify \
--version "${{ steps.metadata.outputs.version }}" \
--version-code "${{ steps.metadata.outputs.version_code }}" \
--tree "${{ steps.metadata.outputs.tree }}" \
--directory app/build/preflight-release
- name: Upload preflight proof
- name: Upload private signed artifacts and proof
uses: actions/upload-artifact@v7
with:
name: play-preflight-${{ steps.metadata.outputs.version }}-${{ steps.metadata.outputs.tree }}
path: app/build/reports/play-preflight.json
path: app/build/preflight-release/
if-no-files-found: error
retention-days: 30
@@ -152,4 +199,4 @@ jobs:
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Approve Android Release** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
echo "The signed build, DEX scan, strict quality gate, and Play draft upload passed. The private artifact contains the exact APK/AAB bytes that approval will publish. Ensure this release tree is on main, then run **Approve Android Release** from main." >> "$GITHUB_STEP_SUMMARY"
+75 -45
View File
@@ -2,10 +2,12 @@
#
# Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml,
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
# GitHub Release. Server/Python package releases use server-v* tags.
# reuses the exact private APK/AAB that passed stable Play preflight, and creates
# a GitHub Release only after Play accepts the production promotion. Prerelease
# tags still build fresh artifacts because they do not use the Play preflight.
name: Release Android
run-name: Release Android ${{ inputs.version != '' && inputs.version || github.ref_name }}
on:
push:
@@ -34,6 +36,8 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
version_code: ${{ steps.version.outputs.version_code }}
release_tree: ${{ steps.version.outputs.release_tree }}
preflight_artifact_id: ${{ steps.preflight.outputs.artifact_id }}
steps:
- uses: actions/checkout@v7
with:
@@ -58,6 +62,7 @@ jobs:
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "release_tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- name: Verify version sync
run: |
@@ -92,25 +97,41 @@ jobs:
fi
- name: Require successful Play preflight for this exact release tree
id: preflight
if: ${{ !contains(steps.version.outputs.version, '-') }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.version }}
RELEASE_TREE: ${{ steps.version.outputs.release_tree }}
run: |
RELEASE_TREE=$(git rev-parse 'HEAD^{tree}')
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | length')
if [ "$COUNT" -lt 1 ]; then
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
ARTIFACT_ID=$(jq -r '.id // empty' <<<"$ARTIFACT")
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$ARTIFACT_ID" ] || [ -z "$RUN_ID" ]; then
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
echo "Run Play Preflight from the final dev tree, merge that unchanged tree to main, then approve the release."
exit 1
fi
echo "Play preflight proof found: $ARTIFACT_NAME"
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
exit 1
fi
if [ "$CONCLUSION" != "success" ]; then
echo "::error::Preflight workflow run ${RUN_ID} concluded ${CONCLUSION}"
exit 1
fi
echo "artifact_id=$ARTIFACT_ID" >> "$GITHUB_OUTPUT"
echo "Play preflight artifact verified: $ARTIFACT_NAME (run $RUN_ID, artifact $ARTIFACT_ID)"
ci:
name: CI Checks
name: CI Checks (prerelease only)
needs: validate
if: ${{ contains(needs.validate.outputs.version, '-') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -149,6 +170,7 @@ jobs:
release:
name: Build & Publish Release
needs: [validate, ci]
if: ${{ always() && needs.validate.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -167,54 +189,61 @@ jobs:
with:
cache-read-only: false
- name: Decode release keystore
- name: Download exact stable preflight artifacts
if: ${{ !contains(needs.validate.outputs.version, '-') }}
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_ID: ${{ needs.validate.outputs.preflight_artifact_id }}
run: |
mkdir -p app/build/release-artifacts
gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/preflight.zip"
python3 scripts/android-release-artifacts.py extract \
--archive "$RUNNER_TEMP/preflight.zip" \
--output app/build/release-artifacts
- name: Verify exact stable preflight artifacts
if: ${{ !contains(needs.validate.outputs.version, '-') }}
run: |
python3 scripts/android-release-artifacts.py verify \
--version "${{ needs.validate.outputs.version }}" \
--version-code "${{ needs.validate.outputs.version_code }}" \
--tree "${{ needs.validate.outputs.release_tree }}" \
--directory app/build/release-artifacts
- name: Decode release keystore for prerelease build
env:
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
if: env.HERMES_KEYSTORE_BASE64 != ''
if: ${{ contains(needs.validate.outputs.version, '-') && env.HERMES_KEYSTORE_BASE64 != '' }}
run: |
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
- name: Build release artifacts (APK + AAB)
- name: Build prerelease artifacts
if: ${{ contains(needs.validate.outputs.version, '-') }}
env:
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
# `assembleRelease` and `bundleRelease` are flavor-wide task aliases
# (added by `flavorDimensions += "track"` in app/build.gradle.kts), so
# this one line builds ALL four artifacts at once. Filenames come from
# `archivesName` (set in app/build.gradle.kts) which injects the app
# version, so `<version>` below is `libs.versions.appVersionName`:
# app/build/outputs/apk/googlePlay/release/hermes-relay-<version>-googlePlay-release.apk
# app/build/outputs/apk/sideload/release/hermes-relay-<version>-sideload-release.apk
# app/build/outputs/bundle/googlePlayRelease/hermes-relay-<version>-googlePlay-release.aab
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
run: ./gradlew bundleRelease assembleRelease
run: ./gradlew bundleRelease assembleRelease --console=plain
- name: Scan release DEX for unsupported collection APIs
- name: Scan prerelease DEX
if: ${{ contains(needs.validate.outputs.version, '-') }}
run: |
python3 scripts/check-android-collection-apis.py \
--apk app/build/outputs/apk/googlePlay/release/*.apk \
--apk app/build/outputs/apk/sideload/release/*.apk
- name: List produced artifacts (debug aid)
- name: Package prerelease artifacts
if: ${{ contains(needs.validate.outputs.version, '-') }}
run: |
echo "=== APK outputs ==="
find app/build/outputs/apk -name '*.apk' -print 2>/dev/null || true
echo "=== AAB outputs ==="
find app/build/outputs/bundle -name '*.aab' -print 2>/dev/null || true
- name: Generate checksums
# Flavor dimension adds an extra path segment to the AGP output layout.
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
# (note the concatenated camelCase — AGP path quirk, documented but
# different between APK and AAB). Checksums cover EXACTLY the files
# attached to the GitHub Release (see the 2-asset policy on the
# release step below) so SHA256SUMS.txt matches the assets 1:1.
run: |
cd app/build/outputs
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
cat SHA256SUMS.txt
python3 scripts/android-release-artifacts.py package \
--version "${{ needs.validate.outputs.version }}" \
--version-code "${{ needs.validate.outputs.version_code }}" \
--commit "$(git rev-parse HEAD)" \
--tree "${{ needs.validate.outputs.release_tree }}" \
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
--output app/build/release-artifacts
- name: Require Play credentials for stable release
env:
@@ -252,9 +281,9 @@ jobs:
# Deliberate 2-asset policy (#144): attach ONLY the installable
# sideload APK and Play AAB, plus checksums covering those files.
files: |
app/build/outputs/apk/sideload/release/*.apk
app/build/outputs/bundle/googlePlayRelease/*.aab
app/build/outputs/SHA256SUMS.txt
app/build/release-artifacts/*.apk
app/build/release-artifacts/*.aab
app/build/release-artifacts/SHA256SUMS.txt
- name: Release summary
env:
@@ -262,7 +291,9 @@ jobs:
run: |
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
if [[ "${{ needs.validate.outputs.version }}" != *-* ]]; then
echo "✅ **Published the exact signed Play-preflight artifacts**" >> "$GITHUB_STEP_SUMMARY"
elif [ -n "$HERMES_KEYSTORE_BASE64" ]; then
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
else
echo "⚠️ **Debug-signed** (no \`HERMES_KEYSTORE_BASE64\` secret) — NOT suitable for Play Store. Add the secret in repo settings to enable release signing." >> "$GITHUB_STEP_SUMMARY"
@@ -270,6 +301,5 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Artifacts" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
find app/build/release-artifacts -maxdepth 1 -type f -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
-7
View File
@@ -10,13 +10,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [Android 1.5.2] - 2026-07-28
### Fixed
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
## [Android 1.5.1] - 2026-07-26
### Added
+13 -27
View File
@@ -1,34 +1,20 @@
# Hermes-Relay — Dev Log
## 2026-07-28 — Android 1.5.2 production release
## 2026-07-26 — Android release fast path
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
the advertised native PKCE system-browser flow, while compatible self-hosted
providers retain cookie-backed full-page Dashboard authentication. Callback
origin discovery remains server-driven, private-network HTTP compatibility is
preserved, and arbitrary public HTTP redirects remain rejected.
Stable Android preflight now retains the exact signed APK and Play AAB with a
tree-bound manifest, sizes, and SHA-256 checksums. The release PR verifies that
its simulated merge tree is unchanged and that the originating preflight run
succeeded, while final publication downloads and verifies those private bytes
instead of rebuilding them. Approval observes the immutable-tag release through
Play promotion and GitHub publication.
The private Play preflight validated the exact application tree before release
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
`main` tip, the production workflow promoted versionCode 35 to the completed
Google Play production track, and the public GitHub release contains the
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
checksum was independently verified; replacing the debug-signed phone build
with the release-signed artifact requires an uninstall because Android
correctly rejects cross-signature in-place updates.
## 2026-07-27 — Android replayed-message identity reconciliation
Android history reconciliation now collapses reconnect/rejoin replays of the
same persisted message ID before publishing the transcript to Compose. The
latest repeated snapshot replaces the value at the message's first transcript
position, preserving stable ordering, distinct messages, and the LazyColumn
identity contract without index- or random-key fallbacks.
Focused coverage reproduces the duplicate UUID condition and verifies that the
authoritative final content wins while every rendered message keeps a unique
stable UI key.
A PowerShell orchestrator provides the prepared-`dev` to Production path with a
dry run and optional configuration-preserving phone deployment. The canonical
release guide was reduced to policy and executable steps; Android account
setup, emergency Play recovery, track selection, signing behavior, and
troubleshooting moved to `docs/release/android-operations.md`. No application
version changed for this release-engineering update.
## 2026-07-26 — Android 1.5.1 patch reconciliation
+269 -854
View File
File diff suppressed because it is too large Load Diff
+15 -10
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.5.2
# Hermes-Relay-Android v1.5.1
**Release Date:** July 28, 2026
**Release Date:** July 26, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.5.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.5.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,17 +12,22 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch restores reliable dashboard sign-in for self-hosted OIDC and Nous Portal connections, including private-LAN and Tailscale routes, and prevents replayed chat events from destabilizing the conversation list.
This patch restores reliable narration and background behavior in Voice, adds focused and full-conversation voice layouts, and keeps richly formatted streamed answers anchored at their completed end.
## Added
- Voice Focus keeps narration, Markdown, tools, media, and actionable cards in a compact voice-first view.
- Voice Conversation exposes the complete Chat renderer while preserving the active voice session.
- A final-answer speech preference keeps intermediate progress visual while supported voice paths wait for the settled response.
## Fixed
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback.
- Nous Portal authentication opens in the system browser so provider security challenges can complete.
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
- Full-screen in-app sign-in remains available for compatible dashboard providers.
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
- Standard Voice narrates valid completed assistant responses instead of losing them during the generation-to-speech handoff.
- Realtime tasks promoted to background release the foreground spinner and microphone while progress and results remain reachable.
- Completed streamed answers switch to full Markdown and preserve the measured trailing edge instead of jumping to the start of the response.
- Assistant text uses the theme's full-contrast foreground with a more readable chat type scale.
## Install / Verify
- App version: **1.5.2** (versionCode **35**).
- App version: **1.5.1** (versionCode **34**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
@@ -1 +1 @@
Dashboard sign-in now completes reliably for self-hosted OIDC and Nous Portal, including private-LAN and Tailscale routes. Nous opens securely in the system browser, while compatible providers retain full-screen in-app sign-in. Replayed chat updates no longer duplicate conversation rows.
Choose compact Voice Focus or the complete Conversation layout. Standard Voice reliably speaks completed replies, Realtime background work no longer blocks voice controls, and streamed answers render Markdown while staying anchored at their completed end.
@@ -1 +1 @@
Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。
可选择精简的语音专注视图或完整的对话视图。标准语音现在会可靠朗读已完成的回复,实时后台任务不再阻塞语音控制,流式回复会渲染 Markdown 并停留在完成位置。
-20
View File
@@ -1,25 +1,5 @@
{
"versions": [
{
"version": "1.5.2",
"title": "Sign in without detours",
"date": "2026-07-28",
"sections": [
{
"header": "Provider-compatible sign-in",
"bullets": [
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
]
},
{
"header": "Stable conversation updates",
"bullets": [
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
]
}
]
},
{
"version": "1.5.1",
"title": "Voice and chat stay in place",
+5 -5
View File
@@ -1,6 +1,6 @@
v1.5.2 - Sign in without detours
v1.5.1 - Voice and chat stay in place
* Complete self-hosted OIDC sign-in through the dashboard callback.
* Open Nous Portal securely in the system browser.
* Sign in over private-LAN and Tailscale dashboard routes.
* Keep replayed chat updates from duplicating conversation rows.
* Choose a compact Voice Focus view or the complete Conversation renderer.
* Hear Standard Voice replies reliably after generation completes.
* Keep Realtime background work active without blocking voice controls.
* Read formatted streamed answers without jumping back to their beginning.
@@ -247,7 +247,6 @@ data class Connection(
apiServerUrl: String,
relayUrl: String,
extraApiUrls: List<Pair<String, String>> = emptyList(),
dashboardUrl: String? = null,
): List<EndpointCandidate> {
val routes = buildList {
endpointCandidateFromApiUrl(
@@ -256,7 +255,6 @@ data class Connection(
apiServerUrl = apiServerUrl,
relayUrl = relayUrl.takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add)
extraApiUrls
@@ -268,7 +266,6 @@ data class Connection(
priority = index + 1,
apiServerUrl = url,
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add)
}
}
@@ -343,7 +340,6 @@ data class Connection(
priority: Int,
apiServerUrl: String,
relayUrl: String,
dashboardUrl: String? = null,
): EndpointCandidate? {
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
?: return null
@@ -367,62 +363,12 @@ data class Connection(
role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls),
dashboard = dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
?.let { DashboardEndpoint(url = it) }
?: deriveDefaultDashboardUrl(apiServerUrl)
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
?.let { DashboardEndpoint(url = it) },
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
)
}
/**
* Reconcile stored API-derived routes with the Dashboard origin that
* was actually verified during setup. Older app versions synthesized
* `:9119` for every API route, even when the same host was reached
* through an HTTPS reverse proxy on 443. Replace only that conventional
* synthesized value (or a missing value); preserve explicit and
* different-host LAN/Tailscale routes.
*/
fun reconcileDashboardRoutes(
dashboardUrl: String?,
candidates: List<EndpointCandidate>,
): List<EndpointCandidate> {
val explicitDashboard = dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: return candidates
return candidates.map { candidate ->
val apiUrl = candidate.api?.url ?: return@map candidate
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
val currentDashboard = candidate.dashboard?.url
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
val canReplace = currentDashboard.isNullOrBlank() ||
(
derivedDashboard != null &&
currentDashboard.trim().trimEnd('/')
.equals(derivedDashboard, ignoreCase = true)
)
if (canReplace) {
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
} else {
candidate
}
}
}
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
return !leftHost.isNullOrBlank() &&
!rightHost.isNullOrBlank() &&
leftHost.equals(rightHost, ignoreCase = true)
}
/**
* De-duplication identity for rebuilding stored routes. Prefer the
* legacy API authority when present so an older API-only candidate and
@@ -543,6 +543,29 @@ class ConnectionStore private constructor(
}
}
private fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val normalizedRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
}
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
companion object {
private const val TAG = "ConnectionStore"
@@ -562,40 +585,3 @@ class ConnectionStore private constructor(
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
}
}
/**
* Restore route defaults after loading a serialized connection. This remains
* internal so focused persistence tests can exercise the same normalization
* path used by [ConnectionStore].
*/
internal fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
dashboardUrl = effectiveDashboardUrl,
)
}
val normalizedRoutes = Connection.reconcileDashboardRoutes(
dashboardUrl = effectiveDashboardUrl,
candidates = storedOrDefaultRoutes,
)
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
dashboardUrl != effectiveDashboardUrl ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = effectiveDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
@@ -1223,14 +1223,6 @@ class ChatHandler {
// so we can attach results back to the originating assistant message's ToolCall
val toolResults = items.filter { it.role == "tool" }
.associateBy { it.toolCallId }
// A reconnect/rejoin history response can repeat a persisted message row.
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
// so allowing both copies through would crash Compose before either copy
// could be reconciled. A domain id identifies one persisted message: retain
// its first transcript position while adopting the latest repeated snapshot.
// Rows without ids remain independent, and tool/hidden rows keep their
// separate handling above/below.
val renderedItems = coalesceRenderedHistoryItems(items)
// Accumulator for media markers we find in loaded content — fired AFTER
// the wholesale `_messages.value = ...` assignment so the ViewModel's
@@ -1248,8 +1240,8 @@ class ChatHandler {
// silently misses those rows, so a gateway turn's tokens/badges survived
// only if a content match happened to cover them. See
// [reconcileLiveIdsToServer].
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
// RECONCILED message id. The server transcript (MessageItem) rebuilds
@@ -1286,7 +1278,7 @@ class ChatHandler {
// clientOnly bubbles (same exchange, pre-sync copy).
val syncedRealtimeTurnContents = mutableSetOf<String>()
val loaded = renderedItems.mapNotNull { item ->
val loaded = items.mapNotNull { item ->
val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null
val role = when {
@@ -1548,34 +1540,6 @@ class ChatHandler {
}
}
/**
* Collapse replayed visible history rows by their authoritative message id.
*
* Replacing the value at its first-seen slot preserves transcript ordering;
* the last repeated value wins so a later, more complete snapshot is not lost.
* Null ids cannot be proven identical and therefore remain separate rows.
*/
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
val firstSlotById = HashMap<String, Int>()
val coalesced = ArrayList<MessageItem>(items.size)
for (item in items) {
if (renderedRoleOf(item) == null) continue
val id = item.id
if (id == null) {
coalesced += item
continue
}
val existingSlot = firstSlotById[id]
if (existingSlot == null) {
firstSlotById[id] = coalesced.size
coalesced += item
} else {
coalesced[existingSlot] = item
}
}
return coalesced
}
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
private class ReconcileSlot(
val serverId: String,
@@ -108,18 +108,13 @@ class NativeDashboardAuthClient(
provider: String? = null,
): NativeDashboardAuthorization {
requireStrictLoopbackRedirect(redirectUri)
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
// trailing "=", which makes Hermes' standards-compliant S256
// comparison fail even though both sides hashed the same bytes.
val verifier = randomBytes(32).base64Url().trimEnd('=')
val verifier = randomBytes(32).base64Url()
val challenge = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString()
.base64Url()
.trimEnd('=')
val state = randomBytes(24).base64Url()
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
val root = "$baseUrl/auth/native/authorize".toHttpUrlOrNull()
?: throw IOException("Dashboard URL is not a valid http(s) address")
val url = root.newBuilder()
.addQueryParameter("code_challenge", challenge)
@@ -135,41 +130,6 @@ class NativeDashboardAuthClient(
return NativeDashboardAuthorization(url, verifier, state, generation)
}
/**
* A private-route dashboard may be configured with a canonical HTTPS
* callback origin for its provider. Starting the browser on the private
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
* discover the provider's declared callback and start native auth there.
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
* to the active connection route.
*/
private fun resolveAuthorizationBaseUrl(provider: String?): String {
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
if (
!provider.equals("nous", ignoreCase = true) ||
configured.scheme != "http" ||
!isPrivateNetworkLiteral(configured.host)
) {
return baseUrl
}
val loginUrl = configured.newBuilder()
.addPathSegments("auth/login")
.addQueryParameter("provider", provider)
.addQueryParameter("next", "/")
.build()
val discoveryClient = client.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
val location = discoveryClient.newCall(
Request.Builder().url(loginUrl).get().build(),
).execute().use { response ->
if (response.code !in 300..399) null else response.header("Location")
}
return canonicalDashboardBaseFromNousRedirect(location)
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
}
fun exchangeCallback(
authorization: NativeDashboardAuthorization,
callbackTarget: String,
@@ -320,52 +280,7 @@ internal class NativeDashboardCallbackException(
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
return url.scheme == "https" ||
(
url.scheme == "http" &&
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
)
}
/**
* Hermes already permits explicitly configured HTTP dashboard sessions on
* local routes. The brokered flow is no less protected than that cookie flow,
* but remains unavailable to arbitrary cleartext Internet hosts.
*/
private fun isPrivateNetworkLiteral(host: String): Boolean {
val octets = host.split('.').mapNotNull(String::toIntOrNull)
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
val first = octets[0]
val second = octets[1]
return first == 10 ||
(first == 172 && second in 16..31) ||
(first == 192 && second == 168) ||
(first == 100 && second in 64..127)
}
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
val providerUrl = location?.toHttpUrlOrNull() ?: return null
if (
providerUrl.scheme != "https" ||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
) {
return null
}
val callback = providerUrl.queryParameter("redirect_uri")
?.toHttpUrlOrNull()
?: return null
if (callback.scheme != "https") return null
val callbackSuffix = "/auth/callback"
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
val basePath = callback.encodedPath
.removeSuffix(callbackSuffix)
.ifBlank { "/" }
return callback.newBuilder()
.encodedPath(basePath)
.query(null)
.fragment(null)
.build()
.toString()
.trimEnd('/')
(url.scheme == "http" && url.host == "127.0.0.1")
}
/**
@@ -33,24 +33,6 @@ internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedire
DashboardRedirectAuthMode.WebView
}
/**
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
* gateway's brokered system-browser flow for Nous when it is advertised.
*/
internal fun androidDashboardRedirectAuthMode(
providerName: String,
authFlows: List<String>,
): DashboardRedirectAuthMode =
if (
providerName.equals("nous", ignoreCase = true) &&
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
) {
DashboardRedirectAuthMode.NativePkce
} else {
DashboardRedirectAuthMode.WebView
}
/**
* Owns one native dashboard sign-in attempt.
*
@@ -35,7 +35,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -184,8 +183,8 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
return showSnackbar(
suspend fun SnackbarHostState.showHumanError(err: HumanError) {
showSnackbar(
message = err.body,
actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
@@ -1946,16 +1945,6 @@ fun RelayApp() {
launchSingleTop = true
}
},
onRepairConnection = {
navController.navigate(
Screen.Pair.route(
connectionId = activeConnectionId,
autoStart = "relay",
),
) {
launchSingleTop = true
}
},
// Empty-chat "needs connection" card also offers the offline
// demo, so a skipped / never-connected first run can explore
// without leaving Chat. Safe here — this state only shows when
@@ -139,7 +139,6 @@ import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import android.content.ClipData
import android.content.Intent
import android.net.Uri
@@ -207,7 +206,6 @@ import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.HumanErrorAction
import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.abs
import com.hermesandroid.relay.ui.theme.relayGridTexture
@@ -468,7 +466,6 @@ fun ChatScreen(
// don't wire navigation.
onNavigateToConnections: () -> Unit = {},
onNavigateToConnect: () -> Unit = onNavigateToConnections,
onRepairConnection: () -> Unit = onNavigateToConnect,
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
// skipped / never-connected first run can explore without a server. null hides it.
onTryDemo: (() -> Unit)? = null,
@@ -496,13 +493,7 @@ fun ChatScreen(
val snackbarHost = LocalSnackbarHost.current
LaunchedEffect(chatViewModel) {
chatViewModel.errorEvents.collect { err ->
val result = snackbarHost.showHumanError(err)
if (
result == SnackbarResult.ActionPerformed &&
err.action == HumanErrorAction.Repair
) {
onRepairConnection()
}
snackbarHost.showHumanError(err)
}
}
@@ -1,19 +1,18 @@
package com.hermesandroid.relay.ui.screens
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -22,7 +21,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -45,6 +43,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
@@ -52,10 +51,10 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.dashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -64,7 +63,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Connection-level Dashboard authentication flow. It is deliberately outside
@@ -94,8 +92,8 @@ fun DashboardSignInScreen(
var actionMessage by remember { mutableStateOf<String?>(null) }
var actionIsError by remember { mutableStateOf(false) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var authFlows by remember(dashboardUrl, connectionId) {
mutableStateOf<List<String>>(emptyList())
var redirectAuthMode by remember(dashboardUrl, connectionId) {
mutableStateOf(DashboardRedirectAuthMode.WebView)
}
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
var authenticationComplete by remember { mutableStateOf(false) }
@@ -152,7 +150,7 @@ fun DashboardSignInScreen(
providers = client.getAuthProviders().getOrNull()
?.takeIf { it.isNotEmpty() }
?: status.authProviderDetails
authFlows = status.authFlows
redirectAuthMode = dashboardRedirectAuthMode(status.authFlows)
val session = if (status.authRequired) client.currentSession().getOrNull() else null
connectionViewModel.recordDashboardStatus(
status = status,
@@ -198,10 +196,7 @@ fun DashboardSignInScreen(
fun startRedirectSignIn(provider: DashboardAuthProvider) {
if (actionInFlight || dashboardUrl.isBlank()) return
if (
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
DashboardRedirectAuthMode.WebView
) {
if (redirectAuthMode == DashboardRedirectAuthMode.WebView) {
oauthProvider = provider
return
}
@@ -260,8 +255,10 @@ fun DashboardSignInScreen(
onDispose { nativeSignInJob?.cancel() }
}
oauthProvider?.let { provider ->
DashboardOAuthScreen(
oauthProvider
?.takeIf { redirectAuthMode == DashboardRedirectAuthMode.WebView }
?.let { provider ->
DashboardOAuthDialog(
dashboardUrl = dashboardUrl,
provider = provider,
cookieStoreFactory = cookieStoreFactory,
@@ -287,7 +284,6 @@ fun DashboardSignInScreen(
actionIsError = true
},
)
return
}
Scaffold(
@@ -295,7 +291,10 @@ fun DashboardSignInScreen(
TopAppBar(
title = { Text(stringResource(R.string.dashboard_sign_in)) },
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = {
nativeSignInJob?.cancel()
onBack()
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
@@ -324,7 +323,9 @@ fun DashboardSignInScreen(
actionInFlight = actionInFlight,
actionMessage = actionMessage,
actionIsError = actionIsError,
nativePkce = redirectAuthMode == DashboardRedirectAuthMode.NativePkce,
nativeSignInInFlight = nativeSignInJob != null,
nativeTransportEligible = isNativeDashboardTransportEligible(dashboardUrl),
onSignIn = ::submitPassword,
onOAuthSignIn = ::startRedirectSignIn,
onCancelNativeSignIn = {
@@ -397,7 +398,9 @@ private fun DashboardSignInForm(
actionInFlight: Boolean,
actionMessage: String?,
actionIsError: Boolean,
nativePkce: Boolean,
nativeSignInInFlight: Boolean,
nativeTransportEligible: Boolean,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onCancelNativeSignIn: () -> Unit,
@@ -426,19 +429,18 @@ private fun DashboardSignInForm(
redirectProviders.forEach { provider ->
Button(
onClick = { onOAuthSignIn(provider) },
enabled = !actionInFlight,
enabled = !actionInFlight && (!nativePkce || nativeTransportEligible),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
}
}
if (nativeSignInInFlight) {
Button(
onClick = onCancelNativeSignIn,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_cancel))
}
if (nativePkce && !nativeTransportEligible && redirectProviders.isNotEmpty()) {
Text(
text = stringResource(R.string.dashboard_native_signin_requires_https),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
if (passwordProvider != null || providers.isEmpty()) {
if (redirectProviders.isNotEmpty()) HorizontalDivider()
@@ -476,11 +478,18 @@ private fun DashboardSignInForm(
},
)
}
if (nativeSignInInFlight) {
Button(
onClick = onCancelNativeSignIn,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_cancel))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DashboardOAuthScreen(
private fun DashboardOAuthDialog(
dashboardUrl: String,
provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore,
@@ -497,8 +506,6 @@ private fun DashboardOAuthScreen(
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
var checking by remember { mutableStateOf(false) }
var pageProgress by remember { mutableStateOf(0) }
var webView by remember { mutableStateOf<WebView?>(null) }
val loginUrl = remember(dashboardUrl, provider.name) {
DashboardApiClient.authLoginUrl(
baseUrl = dashboardUrl,
@@ -507,17 +514,14 @@ private fun DashboardOAuthScreen(
)
}
fun handleNavigation(url: String?) {
fun maybeVerify(url: String?) {
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
DashboardWebViewAuthNavigation.Continue -> return
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
statusText = notAcceptedStatus
onError(notAcceptedStatus)
return
}
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
}
val root = dashboardUrl.trim().trimEnd('/')
val relative = loadedUrl.trim().removePrefix(root)
val stillAuthenticating = relative.startsWith("/login", true) ||
relative.startsWith("/auth/login", true) ||
relative.startsWith("/auth/callback", true)
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
val manager = CookieManager.getInstance()
manager.flush()
val imported = importDashboardCookieHeader(
@@ -547,162 +551,39 @@ private fun DashboardOAuthScreen(
}
}
BackHandler(onBack = onDismiss)
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
}
Text(statusText, style = MaterialTheme.typography.bodySmall)
AndroidView(
modifier = Modifier.fillMaxWidth().weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean = false
DisposableEffect(Unit) {
onDispose {
webView?.stopLoading()
webView?.destroy()
webView = null
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = stringResource(
R.string.dashboard_signin_with_provider,
provider.displayName ?: provider.name,
),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = statusText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
},
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
if (pageProgress in 0..99) {
LinearProgressIndicator(
progress = { pageProgress / 100f },
modifier = Modifier.fillMaxWidth(),
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
maybeVerify(url)
}
}
loadUrl(loginUrl)
}
},
)
}
AndroidView(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
pageProgress = newProgress
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val target = request.url.toString()
if (
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
DashboardWebViewAuthNavigation.RejectLoopbackCallback
) {
handleNavigation(target)
return true
}
return false
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
super.onReceivedError(view, request, error)
if (request.isForMainFrame) {
val message = error.description?.toString()
?.takeIf { it.isNotBlank() }
?: verifyFailedStatus
statusText = message
onError(message)
}
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
handleNavigation(url)
}
}
webView = this
loadUrl(loginUrl)
}
},
)
}
}
}
internal enum class DashboardWebViewAuthNavigation {
Continue,
ImportAndVerify,
RejectLoopbackCallback,
}
/**
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
* loopback callback belongs to the desktop native-PKCE contract and must never
* be followed, imported, or treated as an authenticated Android return.
*/
internal fun dashboardWebViewAuthNavigation(
dashboardUrl: String,
loadedUrl: String,
): DashboardWebViewAuthNavigation {
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val loaded = loadedUrl.trim().toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val sameOrigin = dashboard.scheme == loaded.scheme &&
dashboard.host.equals(loaded.host, ignoreCase = true) &&
dashboard.port == loaded.port
if (!sameOrigin) {
val foreignLoopback = loaded.scheme == "http" &&
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
loaded.encodedPath == "/callback"
return if (foreignLoopback) {
DashboardWebViewAuthNavigation.RejectLoopbackCallback
} else {
DashboardWebViewAuthNavigation.Continue
}
}
val basePath = dashboard.encodedPath.trimEnd('/')
val relativePath = loaded.encodedPath
.removePrefix(basePath)
.ifBlank { "/" }
return if (
relativePath.equals("/login", ignoreCase = true) ||
relativePath.equals("/auth/login", ignoreCase = true)
) {
DashboardWebViewAuthNavigation.Continue
} else {
// Includes the public /auth/callback response: import its cookies at
// root scope, then verify the resulting session through /api/auth/me.
DashboardWebViewAuthNavigation.ImportAndVerify
}
}
@@ -22,16 +22,11 @@ import javax.net.ssl.SSLPeerUnverifiedException
* showHumanError in RelayApp.kt.
*/
enum class HumanErrorAction {
Repair,
}
data class HumanError(
val title: String,
val body: String,
val retryable: Boolean = false,
val actionLabel: String? = null,
val action: HumanErrorAction? = null,
)
private fun titlePrefix(context: String?, ctx: Context?): String = ctx?.let { c ->
@@ -121,7 +116,6 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
body = "Your session is no longer valid — re-pair this device",
retryable = false,
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
action = HumanErrorAction.Repair,
)
"403" in msg || "forbidden" in msg -> HumanError(
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
@@ -277,7 +271,6 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
body = "The server certificate changed since you paired — re-pair to trust it",
retryable = false,
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
action = HumanErrorAction.Repair,
)
is SecurityException -> HumanError(
title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed",
@@ -212,12 +212,9 @@ internal fun resolveEffectiveDashboardUrl(
endpoint?.dashboard?.url
?.takeIf { it.isNotBlank() }
?.let { return it }
endpoint?.api?.url?.let { apiUrl ->
connection.dashboardUrl
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
?.let { return it }
Connection.deriveDefaultDashboardUrl(apiUrl)?.let { return it }
}
endpoint?.api?.url
?.let(Connection::deriveDefaultDashboardUrl)
?.let { return it }
return connection.resolvedDashboardUrl
}
@@ -791,7 +788,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
extraApiUrls = extraApiUrls,
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
),
existing = activeConnection.value?.routeCandidates.orEmpty(),
)
@@ -4690,21 +4686,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
} else {
current.dashboardUrl
}
val newRouteCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = newDashboardUrl,
candidates = payload.endpoints.orEmpty(),
)
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
current.relayUrl != newRelayUrl ||
current.dashboardUrl != newDashboardUrl ||
current.routeCandidates != newRouteCandidates
current.routeCandidates != payload.endpoints.orEmpty()
if (needsUpdate) {
connectionStore.updateConnection(
current.copy(
apiServerUrl = payload.serverUrl,
relayUrl = newRelayUrl,
dashboardUrl = newDashboardUrl,
routeCandidates = newRouteCandidates,
routeCandidates = payload.endpoints.orEmpty(),
preferredRouteRole = current.preferredRouteRole
?.takeIf { preferred ->
payload.endpoints.orEmpty().any {
@@ -4935,22 +4927,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
current.copy(
label = nextLabel,
dashboardUrl = normalized,
routeCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = normalized,
candidates = current.routeCandidates.ifEmpty {
listOfNotNull(
Connection.endpointCandidateFromDashboardUrl(
role = Connection.inferRouteRole(normalized),
priority = 0,
dashboardUrl = normalized,
apiServerUrl = current.apiServerUrl
.takeIf { it.isNotBlank() },
relayUrl = current.relayUrl
.takeIf { it.isNotBlank() },
),
)
},
),
),
)
probeStandardVoice()
@@ -6140,6 +6116,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
) {
val activeId = connectionStore.activeConnectionId.value ?: return
val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: return
val nextRouteCandidates = routeCandidates ?: current.routeCandidates
val nextPreferredRouteRole = when {
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
routeCandidates != null &&
current.preferredRouteRole != null &&
nextRouteCandidates.none {
it.role.equals(current.preferredRouteRole, ignoreCase = true)
} -> null
else -> current.preferredRouteRole
}
val nextDashboardUrl = when {
dashboardUrlOverride != null -> {
dashboardUrlOverride
@@ -6153,19 +6139,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
else -> current.dashboardUrl
}
val nextRouteCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = nextDashboardUrl,
candidates = routeCandidates ?: current.routeCandidates,
)
val nextPreferredRouteRole = when {
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
routeCandidates != null &&
current.preferredRouteRole != null &&
nextRouteCandidates.none {
it.role.equals(current.preferredRouteRole, ignoreCase = true)
} -> null
else -> current.preferredRouteRole
}
if (
current.apiServerUrl == apiServerUrl &&
current.relayUrl == relayUrl &&
@@ -111,75 +111,6 @@ class ConnectionDashboardFieldsTest {
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
}
@Test
fun buildRouteCandidates_preservesExplicitSameHostHttpsDashboard() {
val routes = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
dashboardUrl = "https://hermes.example.com:443",
)
assertEquals(1, routes.size)
assertEquals("https://hermes.example.com:443", routes.single().dashboard?.url)
assertEquals("https://hermes.example.com:8643", routes.single().api?.url)
}
@Test
fun reconcileDashboardRoutes_repairsStoredSameHostDerivedPort() {
val stored = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
)
val repaired = Connection.reconcileDashboardRoutes(
dashboardUrl = "https://hermes.example.com:443",
candidates = stored,
)
assertEquals("https://hermes.example.com:443", repaired.single().dashboard?.url)
}
@Test
fun reconcileDashboardRoutes_keepsDifferentHostRoamingDashboard() {
val stored = Connection.buildRouteCandidates(
apiServerUrl = "http://100.71.8.56:8642",
relayUrl = "ws://100.71.8.56:8767",
)
val repaired = Connection.reconcileDashboardRoutes(
dashboardUrl = "https://hermes.example.com:443",
candidates = stored,
)
assertEquals("http://100.71.8.56:9119", repaired.single().dashboard?.url)
}
@Test
fun persistedSecureDashboard_repairsDerivedGatewayRouteOnReload() {
val stored = Connection(
id = "conn-https",
label = "Secure Hermes",
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
tokenStoreKey = "hermes_auth_https",
dashboardUrl = "https://hermes.example.com:443",
routeCandidates = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
),
)
val reloaded = json.decodeFromString<Connection>(
json.encodeToString(Connection.serializer(), stored),
).withDashboardDefaults()
assertEquals("https://hermes.example.com:443", reloaded.dashboardUrl)
assertEquals(
"https://hermes.example.com:443",
reloaded.routeCandidates.single().dashboard?.url,
)
}
@Test
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
val route = Connection.endpointCandidateFromDashboardUrl(
@@ -1517,49 +1517,6 @@ class ChatHandlerTest {
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test
fun loadMessageHistory_coalescesReplayedDomainIdWithoutLosingOrderOrContent() {
val replayedId = "2c93af28-0b0b-436b-a112-7f164cac931d"
handler.loadMessageHistory(
listOf(
MessageItem(
id = "user-1",
role = "user",
content = JsonPrimitive("question"),
timestamp = 1.0,
),
MessageItem(
id = replayedId,
role = "assistant",
content = JsonPrimitive("partial answer"),
timestamp = 2.0,
),
MessageItem(
id = "system-1",
role = "system",
content = JsonPrimitive("distinct visible content"),
timestamp = 3.0,
),
// Rejoin replay of the same persisted message. The latest
// snapshot is authoritative, but its first transcript position
// and Compose identity must remain stable.
MessageItem(
id = replayedId,
role = "assistant",
content = JsonPrimitive("final answer"),
timestamp = 4.0,
),
)
)
val messages = handler.messages.value
assertEquals(listOf("user-1", replayedId, "system-1"), messages.map { it.id })
assertEquals("final answer", messages[1].content)
assertEquals("distinct visible content", messages[2].content)
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
// Once the first reload adopts the server id, subsequent reloads match by
@@ -68,36 +68,10 @@ class NativeDashboardAuthTest {
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
assertEquals("nous", query["provider"])
assertTrue(query.getValue("state").length >= 32)
assertEquals(43, query.getValue("code_challenge").length)
assertFalse(query.getValue("code_challenge").contains('='))
assertTrue(query.getValue("code_challenge").length >= 43)
assertNotEquals(query["state"], query["code_challenge"])
}
@Test
fun canonicalNousCallbackBase_usesSecurePublicOriginAndPreservesPrefix() {
val location = "https://portal.nousresearch.com/oauth/authorize" +
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fgateway%2Fauth%2Fcallback"
assertEquals(
"https://hermes.example.test/gateway",
canonicalDashboardBaseFromNousRedirect(location),
)
assertEquals(
null,
canonicalDashboardBaseFromNousRedirect(
"https://portal.nousresearch.com/oauth/authorize" +
"?redirect_uri=http%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
),
)
assertEquals(
null,
canonicalDashboardBaseFromNousRedirect(
"https://attacker.example/oauth/authorize" +
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
),
)
}
@Test(expected = IllegalArgumentException::class)
fun beginAuthorization_rejectsHostnameLoopback() {
NativeDashboardAuthClient(server.url("/").toString(), store)
@@ -129,7 +103,6 @@ class NativeDashboardAuthTest {
.digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString()
.base64Url()
.trimEnd('=')
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
.split("&")
.first { it.startsWith("code_challenge=") }
@@ -115,28 +115,7 @@ class NativeDashboardSignInCoordinatorTest {
)
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119"))
assertTrue(isNativeDashboardTransportEligible("http://172.16.24.250:9119"))
assertTrue(isNativeDashboardTransportEligible("http://100.71.8.56:9119"))
assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119"))
assertFalse(isNativeDashboardTransportEligible("http://203.0.113.10:9119"))
}
@Test
fun androidRedirectMode_usesBrowserForNous_andCookieFlowForSelfHostedOidc() {
val flows = listOf("cookie", "native_pkce")
assertEquals(
DashboardRedirectAuthMode.NativePkce,
androidDashboardRedirectAuthMode("nous", flows),
)
assertEquals(
DashboardRedirectAuthMode.WebView,
androidDashboardRedirectAuthMode("oidc", flows),
)
assertEquals(
DashboardRedirectAuthMode.WebView,
androidDashboardRedirectAuthMode("nous", listOf("cookie")),
)
}
private suspend fun completeSignIn(
@@ -1,114 +0,0 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardCookieJar
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class DashboardWebViewAuthPolicyTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun selfHostedOidc_usesDashboardLoginWithoutNativeOrLoopbackParameters() {
val url = DashboardApiClient.authLoginUrl(
baseUrl = "https://hermes.example.test",
provider = "self-hosted",
next = "/",
)
assertEquals(
"https://hermes.example.test/auth/login?provider=self-hosted&next=%2F",
url,
)
assertFalse(url.contains("/auth/native/authorize"))
assertFalse(url.contains("redirect_uri"))
assertFalse(url.contains("127.0.0.1"))
}
@Test
fun publicDashboardCallback_importsCookieAndVerifiesAuthenticatedSession() = runTest {
assertEquals(
DashboardWebViewAuthNavigation.ImportAndVerify,
dashboardWebViewAuthNavigation(
"https://hermes.example.test",
"https://hermes.example.test/auth/callback?code=public-code&state=public-state",
),
)
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""{"authenticated":true,"username":"operator","provider":"self-hosted"}""",
),
)
val store = InMemoryDashboardCookieStore()
val callbackUrl = server.url("/auth/callback?code=public-code").toString()
assertEquals(
1,
importDashboardCookieHeader(
store = store,
url = callbackUrl,
cookieHeader = "hermes_session=authenticated",
),
)
val client = DashboardApiClient(
baseUrl = server.url("/").toString(),
okHttpClient = OkHttpClient.Builder()
.cookieJar(DashboardCookieJar(store))
.build(),
)
val session = client.currentSession().getOrThrow()
assertTrue(session.authenticated)
val request = server.takeRequest()
assertEquals("/api/auth/me", request.path)
assertEquals("hermes_session=authenticated", request.getHeader("Cookie"))
client.shutdown()
}
@Test
fun foreignLoopbackCallbacksAreRejectedWhileProviderPagesContinue() {
val dashboard = "https://hermes.example.test"
listOf(
"http://127.0.0.1:40179/callback?code=code",
"http://localhost:40179/callback?code=code",
"http://[::1]:40179/callback?code=code",
).forEach { callback ->
assertEquals(
callback,
DashboardWebViewAuthNavigation.RejectLoopbackCallback,
dashboardWebViewAuthNavigation(dashboard, callback),
)
}
assertEquals(
DashboardWebViewAuthNavigation.Continue,
dashboardWebViewAuthNavigation(
dashboard,
"https://auth.example.test/application/o/authorize/",
),
)
}
}
@@ -4,7 +4,6 @@ import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -61,15 +60,6 @@ class RelayErrorClassifierTest {
assertEquals("Session expired", err.title)
assertTrue(err.body.contains("re-pair", ignoreCase = true))
assertEquals(HumanErrorAction.Repair, err.action)
}
@Test
fun certificateMismatchExposesRepairAction() {
val err = classifyError(SSLException("certificate changed"))
assertEquals("Certificate mismatch", err.title)
assertEquals(HumanErrorAction.Repair, err.action)
}
@Test
@@ -54,25 +54,7 @@ class EffectiveDashboardRouteTest {
}
@Test
fun `selected API-only route keeps explicit same-host secure dashboard`() {
val connection = connection(
dashboardUrl = "https://hermes.example.com:443",
apiServerUrl = "https://hermes.example.com:8643",
)
val fallback = EndpointCandidate(
role = "public",
priority = 1,
api = ApiEndpoint("hermes.example.com", 8643, tls = true),
)
assertEquals(
"https://hermes.example.com:443",
resolveEffectiveDashboardUrl(connection, fallback),
)
}
@Test
fun `selected API-only route derives dashboard for a different route host`() {
fun `selected API-only route derives dashboard even when primary dashboard is explicit`() {
val connection = connection(
dashboardUrl = "http://192.168.1.20:9119",
apiServerUrl = "http://192.168.1.20:8642",
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.3.1" apply false
id("com.android.library") version "9.3.1" apply false
id("com.android.application") version "9.3.0" apply false
id("com.android.library") version "9.3.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
}
+1 -53
View File
@@ -2169,7 +2169,7 @@ An API endpoint or Relay can be added later without recreating the connection.
## ADR 39 — Android dashboard redirect auth uses native PKCE
**Status:** Superseded by ADR 40 (2026-07-27).
**Status:** Accepted (2026-07-25).
**Context.** Android originally completed redirect-provider dashboard sign-in
inside a WebView and imported cookies. Current upstream Gateway can advertise a
@@ -2202,55 +2202,3 @@ socket.
is offered.
- Older upstream versions remain usable through the explicitly identified
WebView compatibility path.
---
## ADR 40 — Android dashboard redirect auth is provider-compatible
**Status:** Amended (2026-07-28).
**Context.** Upstream advertises `native_pkce` in `/api/status.auth_flows` for
its desktop client. The corresponding `/auth/native/*` broker is explicitly a
desktop system-browser flow: it redirects to a loopback listener owned by the
desktop process and returns bearer tokens rather than dashboard cookies.
Android incorrectly treated that server-wide capability as a platform-neutral
mode selector, so redirect providers such as self-hosted OIDC were sent through
the desktop loopback contract.
**Decision.** Android redirect-provider sign-in uses the upstream dashboard
cookie flow by default:
- open `/auth/login?provider=...&next=...` in a full-screen embedded sign-in
destination with a normal app bar rather than a modal WebView;
- allow the provider to return through the dashboard's public
`/auth/callback`;
- import only cookies observed on the configured dashboard origin;
- verify the imported session through `/api/auth/me`;
- reject a foreign `http://127.0.0.1`, `localhost`, or `[::1]` `/callback`
navigation instead of following or importing it.
Android does not select `/auth/native/authorize` merely because it appears in
`auth_flows`. Self-hosted OIDC remains on the cookie contract above. Nous Portal
is the narrow exception: its Cloudflare Turnstile challenge rejects embedded
Android WebViews, so Android uses the gateway-brokered native PKCE route for
that provider when advertised and opens it in a system Custom Tab. The
ephemeral loopback listener, S256 verifier, state validation, encrypted bearer
store, and exact-origin attachment remain app-owned. Public cleartext
dashboards are rejected; explicitly configured RFC 1918 and Tailscale-IP
dashboard routes retain the same HTTP allowance as their existing cookie
sessions. If the provider redirect from a private route declares a canonical
HTTPS dashboard callback, Android begins browser authorization on that
canonical origin so the temporary PKCE cookie and callback remain same-origin;
the one-time code exchange and resulting exact-origin bearer stay bound to the
active private route.
**Consequences.**
- Self-hosted OIDC uses the same public callback registered for the dashboard.
- Android Manage, Chat, Voice, and onboarding continue to share one verified
dashboard cookie session.
- A server-wide desktop capability can no longer switch Android into a
loopback callback flow.
- Android retains a full-screen embedded WebView for compatible dashboard
cookie providers, while providers that prohibit embedding use the explicit
brokered native route.
+5 -5
View File
@@ -85,12 +85,12 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.5.2 - Sign in without detours
v1.5.1 - Voice and chat stay in place
* Reliable self-hosted OIDC and Nous Portal sign-in.
* Secure system-browser flow for Nous provider challenges.
* Private-LAN and Tailscale dashboard route support.
* Replayed chat updates no longer duplicate rows.
* Voice Focus and full Conversation layouts.
* Reliable Standard Voice narration after generation.
* Realtime background work without blocked voice controls.
* Formatted streamed answers stay at their completed end.
```
## Category
+200
View File
@@ -0,0 +1,200 @@
# Android release operations
Setup, fallback, and troubleshooting reference for the canonical Android
release process in [RELEASE.md](../../RELEASE.md).
The normal stable path is:
```powershell
pwsh scripts/release-android.ps1 -Version X.Y.Z
```
The procedures below are initial setup or emergency recovery, not the normal
release checklist.
## Release signing
Generate and protect a dedicated Android release keystore. Never commit the
keystore, its Base64 representation, passwords, service-account JSON, or local
secret files.
Store these GitHub Actions secrets:
| Secret | Value |
|---|---|
| `HERMES_KEYSTORE_BASE64` | Base64-encoded release keystore |
| `HERMES_KEYSTORE_PASSWORD` | Keystore password |
| `HERMES_KEY_ALIAS` | Release-key alias |
| `HERMES_KEY_PASSWORD` | Release-key password |
PowerShell encoding example:
```powershell
[Convert]::ToBase64String(
[IO.File]::ReadAllBytes('release.keystore')
) | Set-Content -Encoding ascii release.keystore.b64
```
Delete the temporary Base64 file after storing the secret.
Google Play App Signing manages the store-distributed signing certificate.
The repository keystore is the upload/release certificate and must remain
stable across releases.
## Play Developer API
`PLAY_SERVICE_ACCOUNT_JSON` is required for stable automated releases.
One-time setup:
1. Enable the Google Play Android Developer API in a Google Cloud project.
2. Create a dedicated service account and JSON key.
3. Grant that account the minimum Play Console application permissions needed
to view the app, create and edit Production releases, and use Play App
Signing.
4. Store the complete JSON document as the GitHub Actions secret
`PLAY_SERVICE_ACCOUNT_JSON`.
5. Confirm **Play Preflight — Android** can create a Production draft.
The service account is not optional for the stable workflow. Manual Console
upload is an emergency fallback only.
## Managed Publishing
For unattended releases, keep Play Console Managed Publishing disabled.
- Disabled: `completed` submits the production change; availability follows
Google review and propagation.
- Enabled: an approved change remains under **Changes ready to publish** until
a Console operator publishes it.
The Play Developer API reports acceptance of the edit; review reports and
storefront propagation remain asynchronous.
## Track intent
- **Production**: stable `android-vX.Y.Z` releases.
- **Open testing / beta**: intentional public beta.
- **Closed testing / alpha**: intentional private beta.
- **Internal**: throwaway test distribution.
Tracks are options, not a mandatory ladder. Stable releases go directly to
Production unless the release plan explicitly says otherwise.
## Emergency manual Play recovery
Use this only when the automated workflow is unavailable and the release owner
has explicitly approved a manual recovery.
To upload the prepared Google Play bundle as a Production draft:
```powershell
.\gradlew.bat publishGooglePlayReleaseBundle `
--track=production `
--release-status=draft `
--resolution-strategy=ignore `
--release-name='Hermes-Relay X.Y.Z'
```
To promote an existing Production draft:
```powershell
.\gradlew.bat promoteGooglePlayReleaseArtifact `
--update=production `
--version-code=N `
--release-status=completed `
--release-name='Hermes-Relay X.Y.Z'
```
Both commands require `play-service-account.json` in the configured local
location. Remove the file after use.
If API automation is unavailable, the final fallback is Play Console:
1. Open **Release → Production**.
2. Create or edit the release.
3. Upload the `-googlePlay-release.aab`.
4. Use the locale-specific files under
`app/src/googlePlay/play/release-notes/` for What's New.
5. Review and start the rollout.
Record the exact versionCode and resulting Play status in the release issue.
## Private preflight artifact
Stable preflight stores a private Actions artifact named:
```text
play-preflight-X.Y.Z-<git-tree>
```
Retention is 30 days. The release workflow rejects an expired artifact, an
artifact from a failed run, a tree/version mismatch, unexpected files, or any
size/SHA-256 mismatch.
If the artifact expires before approval, rerun preflight from the unchanged
source tree. Never recreate the artifact locally and upload it under the proof
name.
## Phone signing behavior
Android will not update an installed package when the signing certificate
changes.
- Public sideload releases use the release key.
- Rapid development builds use the development/debug key.
- Both currently use `com.axiomlabs.hermesrelay.sideload`.
Therefore, a public release APK cannot update a development-signed installation
without uninstalling it and erasing app data. The orchestrator's `-DeployPhone`
option preserves configuration by installing a development-signed build from
the exact release source tree. Use a release-signed installation when the goal
is to test the downloadable public APK itself.
Never automatically uninstall the app as a signing workaround.
## Troubleshooting
### No successful preflight artifact
Confirm:
- preflight ran from the final `dev` or untagged `main` tree;
- requested version matches `appVersionName`;
- Play draft upload succeeded;
- both preflight jobs concluded successfully;
- the artifact has not expired;
- the release PR merge tree is unchanged.
### Version code already used
Increase `appVersionCode` with `scripts/bump-android-version.sh`. Play version
codes are permanent and cannot be reused even when a draft is discarded.
### Play rejects signing
Verify all keystore secrets exist and inspect the artifact certificate. Stable
preflight intentionally fails when `HERMES_KEYSTORE_BASE64` is absent; it never
falls back to a debug-signed Play upload.
### Approval succeeded but publication failed
Approval now waits for the release workflow and inherits its failure. Inspect
the linked **Release Android** run. If workflow code—not application content—is
the problem, repair the workflow on `main`, dispatch it for the existing
version, and keep every job checked out at the immutable tag. Never move the
tag.
### Release PR still runs the old full Android matrix
`pull_request` workflows use the definition on the PR base branch. The first
release carrying the fast path may therefore run the older `main` workflow.
After that release lands, canonical `dev` → `main` release PRs use the
preflight-proof job.
### Phone install reports `INSTALL_FAILED_UPDATE_INCOMPATIBLE`
The installed app and APK have different signing certificates. Use the
orchestrator's compatible `-DeployPhone` path, use a release-signed test
installation, or explicitly export configuration before an approved clean
install. Do not silently uninstall.
+10 -15
View File
@@ -170,21 +170,16 @@ Phone control — mirrors upstream relay protocol.
### 3.3 Auth Flow
Dashboard/Gateway redirect authentication is provider-compatible. Nous Portal,
which relies on a challenge that rejects embedded Android WebViews, uses the
upstream brokered `native_pkce` flow in a system Custom Tab when the dashboard
advertises it. The app owns an ephemeral loopback callback and stores the
resulting bearer session only for that connection and exact dashboard origin.
Self-hosted OIDC remains on the dashboard cookie flow: Android opens
`/auth/login` in a full-screen embedded browser destination, lets the provider
return through the public `/auth/callback`, imports only same-origin cookies,
and verifies them through `/api/auth/me`. HTTPS is required on public routes;
explicit private-LAN and Tailscale-IP dashboards may use their existing HTTP
transport. When such a private route advertises a canonical HTTPS Nous callback,
Android starts the browser on that canonical origin so Hermes' temporary PKCE
cookie and the provider callback remain same-origin, then exchanges the
one-time code through the active private route. The verified session is shared
by Manage, Gateway tickets, and standard voice.
Dashboard/Gateway redirect providers use the upstream native PKCE contract when
`GET /api/status` advertises `native_pkce`. Android opens the selected provider
in a Custom Tab and owns a single ephemeral callback on
`http://127.0.0.1:<os-assigned-port>/callback`. PKCE verifier and CSRF state
exist only for that sign-in coroutine. Access and refresh tokens are encrypted
per connection and are attached only to the exact trusted dashboard base for
Manage, Gateway tickets, and standard voice. Native exchange is allowed only
for HTTPS dashboard addresses (plus literal loopback for development). A
gateway without the capability uses the legacy cookie/WebView flow; a failed
native attempt never silently downgrades.
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
+4 -4
View File
@@ -1,7 +1,7 @@
[versions]
appVersionName = "1.5.2"
appVersionCode = "35"
agp = "9.3.1"
appVersionName = "1.5.1"
appVersionCode = "34"
agp = "9.3.0"
kotlin = "2.4.10"
compose-bom = "2026.06.01"
navigation-compose = "2.9.8"
@@ -15,7 +15,7 @@ security-crypto = "1.1.0"
tink-android = "1.23.0"
lifecycle = "2.11.0"
activity-compose = "1.13.0"
browser = "1.10.0"
browser = "1.9.0"
appcompat = "1.7.1"
core-ktx = "1.19.0"
datastore = "1.2.1"
+2 -2
View File
@@ -5,8 +5,8 @@ pluginManagement {
gradlePluginPortal()
}
plugins {
id("com.android.application") version "9.3.1"
id("com.android.library") version "9.3.1"
id("com.android.application") version "9.3.0"
id("com.android.library") version "9.3.0"
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
}
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Package and verify the immutable Android Play-preflight artifact set."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import sys
import zipfile
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
MANIFEST_NAME = "play-preflight.json"
CHECKSUMS_NAME = "SHA256SUMS.txt"
EXPECTED_ROLES = {
"sideload-apk": "hermes-relay-{version}-sideload-release.apk",
"google-play-aab": "hermes-relay-{version}-googlePlay-release.aab",
}
GIT_OBJECT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
class ArtifactError(ValueError):
"""Raised when a release artifact set violates its contract."""
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def safe_artifact_name(name: str) -> str:
candidate = Path(name)
if candidate.name != name or name in {"", ".", ".."}:
raise ArtifactError(f"Unsafe artifact filename: {name!r}")
return name
def expected_checksum_text(artifacts: list[dict[str, Any]]) -> str:
lines = [
f"{artifact['sha256']} {artifact['name']}"
for artifact in sorted(artifacts, key=lambda item: item["name"])
]
return "\n".join(lines) + "\n"
def package_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.commit):
raise ArtifactError(f"Invalid commit id: {args.commit!r}")
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"Invalid tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"Invalid versionCode: {args.version_code!r}")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
if any(output.iterdir()):
raise ArtifactError(f"Output directory must be empty: {output}")
sources = {
"sideload-apk": args.sideload_apk.resolve(),
"google-play-aab": args.google_play_aab.resolve(),
}
artifacts: list[dict[str, Any]] = []
for role, source in sources.items():
if not source.is_file():
raise ArtifactError(f"Missing {role}: {source}")
expected_name = EXPECTED_ROLES[role].format(version=args.version)
if source.name != expected_name:
raise ArtifactError(
f"{role} must be named {expected_name}, got {source.name}"
)
destination = output / safe_artifact_name(source.name)
shutil.copy2(source, destination)
artifacts.append(
{
"role": role,
"name": destination.name,
"sha256": sha256(destination),
"size": destination.stat().st_size,
}
)
manifest = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"commit": args.commit,
"tree": args.tree,
"track": "production",
"status": "draft",
"artifacts": sorted(artifacts, key=lambda item: item["role"]),
}
(output / MANIFEST_NAME).write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
(output / CHECKSUMS_NAME).write_text(
expected_checksum_text(artifacts),
encoding="utf-8",
newline="\n",
)
print(json.dumps(manifest, sort_keys=True))
def load_manifest(directory: Path) -> dict[str, Any]:
manifest_path = directory / MANIFEST_NAME
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ArtifactError(f"Missing {MANIFEST_NAME} in {directory}") from exc
except json.JSONDecodeError as exc:
raise ArtifactError(f"Invalid {MANIFEST_NAME}: {exc}") from exc
if not isinstance(manifest, dict):
raise ArtifactError(f"{MANIFEST_NAME} must contain a JSON object")
return manifest
def extract_archive(args: argparse.Namespace) -> None:
archive_path = args.archive.resolve()
if not archive_path.is_file():
raise ArtifactError(f"Artifact archive does not exist: {archive_path}")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
if any(output.iterdir()):
raise ArtifactError(f"Output directory must be empty: {output}")
seen: set[str] = set()
try:
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
if info.is_dir():
raise ArtifactError(
f"Artifact archive contains a directory: {info.filename!r}"
)
name = safe_artifact_name(info.filename)
if name in seen:
raise ArtifactError(f"Duplicate archive entry: {name}")
unix_type = (info.external_attr >> 16) & 0o170000
if unix_type == 0o120000:
raise ArtifactError(f"Artifact archive contains a symlink: {name}")
seen.add(name)
with archive.open(info) as source, (output / name).open("wb") as target:
shutil.copyfileobj(source, target)
except zipfile.BadZipFile as exc:
raise ArtifactError(f"Invalid artifact ZIP: {exc}") from exc
def verify_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"Invalid expected tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"Invalid expected versionCode: {args.version_code!r}")
directory = args.directory.resolve()
if not directory.is_dir():
raise ArtifactError(f"Artifact directory does not exist: {directory}")
manifest = load_manifest(directory)
commit = manifest.get("commit")
if not isinstance(commit, str) or not GIT_OBJECT_PATTERN.fullmatch(commit):
raise ArtifactError(f"Manifest commit is invalid: {commit!r}")
expected_metadata = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"tree": args.tree,
"track": "production",
"status": "draft",
}
for key, expected in expected_metadata.items():
actual = manifest.get(key)
if str(actual) != str(expected):
raise ArtifactError(
f"Manifest {key} mismatch: expected {expected!r}, got {actual!r}"
)
entries = manifest.get("artifacts")
if not isinstance(entries, list):
raise ArtifactError("Manifest artifacts must be a list")
by_role: dict[str, dict[str, Any]] = {}
for entry in entries:
if not isinstance(entry, dict):
raise ArtifactError("Every manifest artifact must be an object")
role = entry.get("role")
if role not in EXPECTED_ROLES:
raise ArtifactError(f"Unexpected artifact role: {role!r}")
if role in by_role:
raise ArtifactError(f"Duplicate artifact role: {role}")
by_role[role] = entry
if set(by_role) != set(EXPECTED_ROLES):
missing = sorted(set(EXPECTED_ROLES) - set(by_role))
raise ArtifactError(f"Missing artifact roles: {', '.join(missing)}")
verified: list[dict[str, Any]] = []
for role, expected_pattern in EXPECTED_ROLES.items():
entry = by_role[role]
name = safe_artifact_name(str(entry.get("name", "")))
expected_name = expected_pattern.format(version=args.version)
if name != expected_name:
raise ArtifactError(f"{role} must be named {expected_name}, got {name}")
path = directory / name
if not path.is_file():
raise ArtifactError(f"Missing artifact file: {name}")
actual_hash = sha256(path)
if actual_hash != entry.get("sha256"):
raise ArtifactError(f"SHA-256 mismatch for {name}")
if path.stat().st_size != entry.get("size"):
raise ArtifactError(f"Size mismatch for {name}")
verified.append(entry)
checksum_path = directory / CHECKSUMS_NAME
try:
checksum_text = checksum_path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise ArtifactError(f"Missing {CHECKSUMS_NAME}") from exc
if checksum_text != expected_checksum_text(verified):
raise ArtifactError(f"{CHECKSUMS_NAME} does not match the manifest")
allowed_names = {
MANIFEST_NAME,
CHECKSUMS_NAME,
*(entry["name"] for entry in verified),
}
directory_entries = list(directory.iterdir())
non_files = sorted(path.name for path in directory_entries if not path.is_file())
if non_files:
raise ArtifactError(
"Artifact directory contains non-file entries: " + ", ".join(non_files)
)
actual_names = {path.name for path in directory_entries}
if actual_names != allowed_names:
unexpected = sorted(actual_names - allowed_names)
missing = sorted(allowed_names - actual_names)
details = []
if unexpected:
details.append(f"unexpected: {', '.join(unexpected)}")
if missing:
details.append(f"missing: {', '.join(missing)}")
raise ArtifactError("Artifact set mismatch (" + "; ".join(details) + ")")
print(json.dumps(manifest, sort_keys=True))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
package = subparsers.add_parser("package", help="Create a preflight artifact set")
package.add_argument("--version", required=True)
package.add_argument("--version-code", required=True)
package.add_argument("--commit", required=True)
package.add_argument("--tree", required=True)
package.add_argument("--sideload-apk", required=True, type=Path)
package.add_argument("--google-play-aab", required=True, type=Path)
package.add_argument("--output", required=True, type=Path)
package.set_defaults(func=package_artifacts)
extract = subparsers.add_parser(
"extract", help="Safely extract a downloaded Actions artifact"
)
extract.add_argument("--archive", required=True, type=Path)
extract.add_argument("--output", required=True, type=Path)
extract.set_defaults(func=extract_archive)
verify = subparsers.add_parser("verify", help="Verify a preflight artifact set")
verify.add_argument("--version", required=True)
verify.add_argument("--version-code", required=True)
verify.add_argument("--tree", required=True)
verify.add_argument("--directory", required=True, type=Path)
verify.set_defaults(func=verify_artifacts)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except ArtifactError as exc:
parser.error(str(exc))
return 0
if __name__ == "__main__":
sys.exit(main())
+285
View File
@@ -0,0 +1,285 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')]
[string]$Version,
[switch]$DeployPhone,
[string]$DeviceSerial,
[switch]$DryRun,
[string]$Repository = 'Codename-11/hermes-relay'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
Set-Location $RepoRoot
function Invoke-Captured {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
$output = & $Command @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
throw "$Command $($Arguments -join ' ') failed:`n$($output -join [Environment]::NewLine)"
}
return ($output -join [Environment]::NewLine).Trim()
}
function Invoke-Streaming {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
}
}
function Start-GitHubWorkflow {
param(
[Parameter(Mandatory = $true)][string]$Workflow,
[Parameter(Mandatory = $true)][string]$Ref,
[Parameter(Mandatory = $true)][hashtable]$Inputs
)
$arguments = @('workflow', 'run', $Workflow, '--repo', $Repository, '--ref', $Ref)
foreach ($entry in $Inputs.GetEnumerator() | Sort-Object Key) {
$arguments += @('-f', "$($entry.Key)=$($entry.Value)")
}
$output = Invoke-Captured -Command 'gh' -Arguments $arguments
if ($output -notmatch '/actions/runs/(?<id>\d+)') {
throw "GitHub did not return a workflow run URL:`n$output"
}
return $Matches.id
}
function Wait-GitHubRun {
param([Parameter(Mandatory = $true)][string]$RunId)
Invoke-Streaming -Command 'gh' -Arguments @(
'run', 'watch', $RunId,
'--repo', $Repository,
'--exit-status',
'--interval', '15'
)
}
function Require-CleanPreparedDev {
$branch = Invoke-Captured -Command 'git' -Arguments @('branch', '--show-current')
if ($branch -ne 'dev') {
throw "Android publication must start from dev; current branch is $branch"
}
$status = Invoke-Captured -Command 'git' -Arguments @('status', '--porcelain')
if ($status) {
throw "Working tree is not clean:`n$status"
}
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'dev', 'main', '--tags')
$head = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
$originDev = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/dev')
if ($head -ne $originDev) {
throw "Local dev ($head) does not match origin/dev ($originDev)"
}
$existingTag = & git ls-remote --tags origin "refs/tags/android-v$Version"
if ($LASTEXITCODE -ne 0) {
throw 'Unable to query existing Android tags'
}
if ($existingTag) {
throw "android-v$Version already exists"
}
}
function Require-ReleaseMetadata {
$versionsFile = Get-Content 'gradle/libs.versions.toml' -Raw
if ($versionsFile -notmatch 'appVersionName\s*=\s*"(?<version>[^"]+)"') {
throw 'Unable to read appVersionName from gradle/libs.versions.toml'
}
if ($Matches.version -ne $Version) {
throw "Requested version $Version does not match appVersionName $($Matches.version)"
}
if ($versionsFile -notmatch 'appVersionCode\s*=\s*"(?<code>\d+)"') {
throw 'Unable to read appVersionCode from gradle/libs.versions.toml'
}
$versionCode = $Matches.code
if (-not (Select-String -Path 'CHANGELOG.md' -Pattern "^## \[(Android )?$([regex]::Escape($Version))\]" -Quiet)) {
throw "CHANGELOG.md has no Android release heading for $Version"
}
return $versionCode
}
function Deploy-CompatiblePhoneBuild {
param([Parameter(Mandatory = $true)][string]$VersionCode)
$sdk = if ($env:ANDROID_HOME) {
$env:ANDROID_HOME
} else {
Join-Path $env:LOCALAPPDATA 'Android\Sdk'
}
$adb = Join-Path $sdk 'platform-tools\adb.exe'
if (-not (Test-Path -LiteralPath $adb)) {
throw "adb was not found at $adb"
}
$env:ANDROID_HOME = $sdk
$env:ANDROID_SDK_ROOT = $sdk
Invoke-Streaming -Command '.\gradlew.bat' -Arguments @(
':app:assembleSideloadDebug',
'--no-daemon',
'--console=plain'
)
$apk = Get-ChildItem 'app\build\outputs\apk\sideload\debug' -Filter "*-$Version-sideload-debug.apk" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $apk) {
throw "No compatible sideload debug APK was produced for $Version"
}
$adbArguments = @()
if ($DeviceSerial) {
$adbArguments += @('-s', $DeviceSerial)
}
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @('install', '-r', $apk.FullName))
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
'shell', 'am', 'force-stop', 'com.axiomlabs.hermesrelay.sideload'
))
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
'shell', 'am', 'start', '-n',
'com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity'
))
$package = Invoke-Captured -Command $adb -Arguments ($adbArguments + @(
'shell', 'dumpsys', 'package', 'com.axiomlabs.hermesrelay.sideload'
))
if ($package -notmatch "versionCode=$([regex]::Escape($VersionCode))\b" -or
$package -notmatch "versionName=$([regex]::Escape($Version))-sideload\b") {
throw "Phone package verification failed for version $Version / code $VersionCode"
}
Write-Host "Phone verified at $Version-sideload (versionCode $VersionCode)."
}
$versionCode = Require-ReleaseMetadata
if ($DryRun) {
Write-Host "Dry run: Android $Version (versionCode $versionCode)"
Write-Host '1. Require clean origin/dev and an unused Android tag.'
Write-Host '2. Run and await Play Preflight from dev.'
Write-Host '3. Open or reuse the dev-to-main release PR, await checks, and merge.'
Write-Host '4. Verify the main tree equals the preflighted dev tree.'
Write-Host '5. Run and await Approve Android Release from main.'
Write-Host '6. Verify the immutable tag and GitHub release.'
if ($DeployPhone) {
Write-Host '7. Build/install the compatible sideload-debug APK without erasing phone data.'
}
exit 0
}
Invoke-Streaming -Command 'gh' -Arguments @('auth', 'status')
Require-CleanPreparedDev
$devCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
$devTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD^{tree}')
Write-Host "Starting Android $Version from dev $devCommit (tree $devTree)."
$preflightRun = Start-GitHubWorkflow -Workflow 'play-preflight-android.yml' -Ref 'dev' -Inputs @{
version = $Version
}
Write-Host "Play preflight run: https://github.com/$Repository/actions/runs/$preflightRun"
Wait-GitHubRun -RunId $preflightRun
$pullRequests = Invoke-Captured -Command 'gh' -Arguments @(
'pr', 'list',
'--repo', $Repository,
'--base', 'main',
'--head', 'dev',
'--state', 'open',
'--limit', '1',
'--json', 'number,url,headRefOid'
) | ConvertFrom-Json
if (-not $pullRequests) {
$body = @"
Promote the exact Play-preflighted Android $Version release tree from ``dev`` to ``main``.
- dev SHA: ``$devCommit``
- release tree: ``$devTree``
- Play preflight: https://github.com/$Repository/actions/runs/$preflightRun
Approval will verify this unchanged tree before creating ``android-v$Version``.
"@
$prUrl = Invoke-Captured -Command 'gh' -Arguments @(
'pr', 'create',
'--repo', $Repository,
'--base', 'main',
'--head', 'dev',
'--title', "release(android): promote android-v$Version",
'--body', $body
)
$prNumber = [regex]::Match($prUrl, '/pull/(?<number>\d+)').Groups['number'].Value
} else {
if ([string]$pullRequests[0].headRefOid -ne $devCommit) {
throw "Existing release PR head $($pullRequests[0].headRefOid) does not match preflighted dev $devCommit"
}
$prNumber = [string]$pullRequests[0].number
$prUrl = [string]$pullRequests[0].url
}
if (-not $prNumber) {
throw "Unable to determine release PR number from $prUrl"
}
Write-Host "Release PR: $prUrl"
Invoke-Streaming -Command 'gh' -Arguments @(
'pr', 'checks', $prNumber,
'--repo', $Repository,
'--watch',
'--fail-fast'
)
$checkedHead = Invoke-Captured -Command 'gh' -Arguments @(
'pr', 'view', $prNumber,
'--repo', $Repository,
'--json', 'headRefOid',
'--jq', '.headRefOid'
)
if ($checkedHead -ne $devCommit) {
throw "Release PR advanced to $checkedHead after preflight; rerun from the new dev tip"
}
Invoke-Streaming -Command 'gh' -Arguments @(
'pr', 'merge', $prNumber,
'--repo', $Repository,
'--merge'
)
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'main', '--tags')
$mainCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main')
$mainTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main^{tree}')
if ($mainTree -ne $devTree) {
throw "Merged main tree $mainTree does not match preflighted dev tree $devTree"
}
$approvalRun = Start-GitHubWorkflow -Workflow 'approve-release-android.yml' -Ref 'main' -Inputs @{
version = $Version
}
Write-Host "Approval run: https://github.com/$Repository/actions/runs/$approvalRun"
Wait-GitHubRun -RunId $approvalRun
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', '--tags')
$tagCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', "android-v$Version")
if ($tagCommit -ne $mainCommit) {
throw "android-v$Version points to $tagCommit instead of main $mainCommit"
}
$release = Invoke-Captured -Command 'gh' -Arguments @(
'release', 'view', "android-v$Version",
'--repo', $Repository,
'--json', 'url,tagName,publishedAt'
) | ConvertFrom-Json
Write-Host "Published release: $($release.url)"
if ($DeployPhone) {
Deploy-CompatiblePhoneBuild -VersionCode $versionCode
}
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import importlib.util
import io
import json
import tempfile
import unittest
import zipfile
from argparse import Namespace
from contextlib import redirect_stdout
from pathlib import Path
SCRIPT_PATH = Path(__file__).with_name("android-release-artifacts.py")
SPEC = importlib.util.spec_from_file_location("android_release_artifacts", SCRIPT_PATH)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class AndroidReleaseArtifactsTest(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.apk = self.root / "hermes-relay-1.5.2-sideload-release.apk"
self.aab = self.root / "hermes-relay-1.5.2-googlePlay-release.aab"
self.apk.write_bytes(b"apk payload")
self.aab.write_bytes(b"aab payload")
self.output = self.root / "release"
self.package_args = Namespace(
version="1.5.2",
version_code="35",
commit="a" * 40,
tree="b" * 40,
sideload_apk=self.apk,
google_play_aab=self.aab,
output=self.output,
)
self.verify_args = Namespace(
version="1.5.2",
version_code="35",
tree="b" * 40,
directory=self.output,
)
def tearDown(self) -> None:
self.temp.cleanup()
def test_package_and_verify_round_trip(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
MODULE.verify_artifacts(self.verify_args)
manifest = json.loads(
(self.output / MODULE.MANIFEST_NAME).read_text(encoding="utf-8")
)
self.assertEqual("1.5.2", manifest["version"])
self.assertEqual("35", manifest["versionCode"])
self.assertEqual(
{"google-play-aab", "sideload-apk"},
{artifact["role"] for artifact in manifest["artifacts"]},
)
def test_verify_rejects_modified_artifact(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
packaged_apk = self.output / self.apk.name
packaged_apk.write_bytes(b"tampered")
with self.assertRaisesRegex(MODULE.ArtifactError, "SHA-256 mismatch"):
MODULE.verify_artifacts(self.verify_args)
def test_verify_rejects_wrong_release_tree(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
self.verify_args.tree = "c" * 40
with self.assertRaisesRegex(MODULE.ArtifactError, "tree mismatch"):
MODULE.verify_artifacts(self.verify_args)
def test_package_rejects_wrong_extension(self) -> None:
self.package_args.sideload_apk = self.root / "not-an-apk.txt"
self.package_args.sideload_apk.write_text("no", encoding="utf-8")
with self.assertRaisesRegex(MODULE.ArtifactError, "must be named"):
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
def test_verify_rejects_unexpected_file(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
(self.output / "extra.bin").write_bytes(b"unexpected")
with self.assertRaisesRegex(MODULE.ArtifactError, "unexpected"):
MODULE.verify_artifacts(self.verify_args)
def test_extract_rejects_path_traversal(self) -> None:
archive = self.root / "artifact.zip"
with zipfile.ZipFile(archive, "w") as handle:
handle.writestr("../outside.txt", "unsafe")
with self.assertRaisesRegex(MODULE.ArtifactError, "Unsafe artifact filename"):
MODULE.extract_archive(
Namespace(archive=archive, output=self.root / "extracted")
)
self.assertFalse((self.root / "outside.txt").exists())
if __name__ == "__main__":
unittest.main()