Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fad937c23 |
@@ -14,6 +14,8 @@ function classifyCiPaths(paths) {
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
|
||||
'scripts/android-release-artifacts.py', 'scripts/test_android_release_artifacts.py',
|
||||
'scripts/release-android.ps1',
|
||||
'.github/workflows/play-preflight-android.yml',
|
||||
'.github/workflows/approve-release-android.yml',
|
||||
'.github/workflows/release-android.yml',
|
||||
|
||||
@@ -15,6 +15,9 @@ const none = {
|
||||
assert.deepEqual(classifyCiPaths(['README.md']), none);
|
||||
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
|
||||
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/android-release-artifacts.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/test_android_release_artifacts.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/release-android.ps1']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
|
||||
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# stable tag triggers Play submission first, then GitHub publication.
|
||||
|
||||
name: Approve Android Release
|
||||
run-name: Approve Android ${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -24,8 +25,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: Verify preflight and create release tag
|
||||
name: Verify, tag, and observe public release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -56,13 +58,25 @@ jobs:
|
||||
RELEASE_TREE: ${{ steps.metadata.outputs.tree }}
|
||||
run: |
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
|
||||
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | length')
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
|
||||
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified Play preflight proof: $ARTIFACT_NAME"
|
||||
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
|
||||
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
|
||||
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
|
||||
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
|
||||
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONCLUSION" != "success" ]; then
|
||||
echo "::error::Preflight workflow run ${RUN_ID} concluded ${CONCLUSION}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified successful Play preflight artifact: $ARTIFACT_NAME (run $RUN_ID)"
|
||||
|
||||
- name: Ensure release tag does not already exist
|
||||
env:
|
||||
@@ -83,18 +97,41 @@ jobs:
|
||||
-f ref="refs/tags/android-v${VERSION}" \
|
||||
-f sha="$GITHUB_SHA"
|
||||
|
||||
- name: Start the tag release workflow
|
||||
- name: Start and observe the tag release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
DISPATCHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
gh workflow run release-android.yml \
|
||||
--ref=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"
|
||||
|
||||
@@ -33,6 +33,9 @@ on:
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- "scripts/android-release-artifacts.py"
|
||||
- "scripts/test_android_release_artifacts.py"
|
||||
- "scripts/release-android.ps1"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
- ".github/workflows/play-preflight-android.yml"
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
@@ -73,6 +76,9 @@ jobs:
|
||||
- name: Reject unsafe Android collection APIs
|
||||
run: python3 scripts/check-android-collection-apis.py
|
||||
|
||||
- name: Validate immutable release artifact contract
|
||||
run: python3 -m unittest scripts/test_android_release_artifacts.py
|
||||
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lint --console=plain
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -30,6 +31,7 @@ jobs:
|
||||
dashboard: ${{ steps.filter.outputs.dashboard }}
|
||||
contract: ${{ steps.filter.outputs.contract }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
release_pr: ${{ steps.release.outputs.release_pr }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
@@ -39,6 +41,21 @@ jobs:
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
|
||||
- name: Detect canonical release PR
|
||||
id: release
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
run: |
|
||||
if [ "$BASE_REF" = "main" ] &&
|
||||
[ "$HEAD_REF" = "dev" ] &&
|
||||
[ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then
|
||||
echo "release_pr=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release_pr=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Classify changed files
|
||||
id: filter
|
||||
uses: actions/github-script@v8
|
||||
@@ -62,9 +79,66 @@ jobs:
|
||||
|
||||
android:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
if: needs.changes.outputs.android == 'true' && needs.changes.outputs.release_pr != 'true'
|
||||
uses: ./.github/workflows/ci-android.yml
|
||||
|
||||
release-proof:
|
||||
name: Verify preflighted release tree
|
||||
needs: changes
|
||||
if: needs.changes.outputs.release_pr == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout simulated release merge
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require unchanged successful Play preflight
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EXPECTED_DEV_COMMIT: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MERGE_TREE=$(git rev-parse 'HEAD^{tree}')
|
||||
DEV_COMMIT="$EXPECTED_DEV_COMMIT"
|
||||
DEV_TREE=$(git rev-parse "${DEV_COMMIT}^{tree}")
|
||||
if [ "$MERGE_TREE" != "$DEV_TREE" ]; then
|
||||
echo "::error::The dev-to-main merge changes the preflighted tree ($DEV_TREE -> $MERGE_TREE)"
|
||||
echo "Rerun Play Preflight from the simulated merge result before approval."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${DEV_TREE}"
|
||||
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
|
||||
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::No Play preflight artifact exists for dev tree $DEV_TREE and version $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
|
||||
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
|
||||
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
|
||||
PREFLIGHT_COMMIT=$(jq -r '.head_sha' <<<"$RUN")
|
||||
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
|
||||
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PREFLIGHT_COMMIT" != "$DEV_COMMIT" ]; then
|
||||
echo "::error::Preflight commit ${PREFLIGHT_COMMIT} does not match release dev commit ${DEV_COMMIT}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CONCLUSION" != "success" ]; then
|
||||
echo "::error::Play preflight run ${RUN_ID} concluded ${CONCLUSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified release PR:"
|
||||
echo " dev commit: $DEV_COMMIT"
|
||||
echo " unchanged tree: $DEV_TREE"
|
||||
echo " successful preflight run: $RUN_ID"
|
||||
|
||||
desktop:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.desktop == 'true'
|
||||
@@ -110,11 +184,12 @@ jobs:
|
||||
guard:
|
||||
name: Required checks
|
||||
if: always()
|
||||
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
|
||||
needs: [changes, android, release-proof, desktop, plugin, dashboard, contract, docs]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
ANDROID_RESULT: ${{ needs.android.result }}
|
||||
RELEASE_PROOF_RESULT: ${{ needs.release-proof.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
PLUGIN_RESULT: ${{ needs.plugin.result }}
|
||||
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
|
||||
@@ -125,7 +200,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
failed=0
|
||||
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
for check in CHANGES ANDROID RELEASE_PROOF DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
result_var="${check}_RESULT"
|
||||
result="${!result_var}"
|
||||
echo "$check: $result"
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
#
|
||||
# Run manually from the final dev or untagged main tree before creating
|
||||
# android-v*. The job
|
||||
# builds the same signed release artifacts, scans final DEX, and uploads the
|
||||
# Google Play bundle as a production DRAFT. A successful upload is the automated
|
||||
# Play gate while no public GitHub Release or sideload APK exists. Console-only
|
||||
# pre-review and pre-launch reports are informational and do not block release.
|
||||
# builds the signed release artifacts once, scans final DEX, and uploads the
|
||||
# Google Play bundle as a production DRAFT. The signed APK/AAB and their manifest
|
||||
# remain private Actions artifacts until approval publishes those exact bytes.
|
||||
# Console-only pre-review and pre-launch reports are informational.
|
||||
|
||||
name: Play Preflight — Android
|
||||
|
||||
@@ -25,6 +25,51 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Validate release quality
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Validate translation catalogs and collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m unittest scripts/test_android_release_artifacts.py
|
||||
|
||||
- name: Run strict Android lint and focused tests
|
||||
run: |
|
||||
./gradlew lint :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--tests com.hermesandroid.relay.util.ServerAddressTest \
|
||||
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.data.AppLanguageTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
|
||||
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
|
||||
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
|
||||
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
|
||||
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
|
||||
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
|
||||
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
|
||||
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
|
||||
--console=plain
|
||||
|
||||
preflight:
|
||||
name: Build and upload private Play draft
|
||||
runs-on: ubuntu-latest
|
||||
@@ -121,25 +166,27 @@ jobs:
|
||||
--resolution-strategy=ignore \
|
||||
--release-name="Hermes-Relay ${{ steps.metadata.outputs.version }}"
|
||||
|
||||
- name: Record successful preflight for the exact commit
|
||||
- name: Package immutable preflight artifacts
|
||||
run: |
|
||||
mkdir -p app/build/reports
|
||||
cat > app/build/reports/play-preflight.json <<EOF
|
||||
{
|
||||
"version": "${{ steps.metadata.outputs.version }}",
|
||||
"versionCode": "${{ steps.metadata.outputs.version_code }}",
|
||||
"commit": "$GITHUB_SHA",
|
||||
"tree": "${{ steps.metadata.outputs.tree }}",
|
||||
"track": "production",
|
||||
"status": "draft"
|
||||
}
|
||||
EOF
|
||||
python3 scripts/android-release-artifacts.py package \
|
||||
--version "${{ steps.metadata.outputs.version }}" \
|
||||
--version-code "${{ steps.metadata.outputs.version_code }}" \
|
||||
--commit "$GITHUB_SHA" \
|
||||
--tree "${{ steps.metadata.outputs.tree }}" \
|
||||
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
|
||||
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
|
||||
--output app/build/preflight-release
|
||||
python3 scripts/android-release-artifacts.py verify \
|
||||
--version "${{ steps.metadata.outputs.version }}" \
|
||||
--version-code "${{ steps.metadata.outputs.version_code }}" \
|
||||
--tree "${{ steps.metadata.outputs.tree }}" \
|
||||
--directory app/build/preflight-release
|
||||
|
||||
- name: Upload preflight proof
|
||||
- name: Upload private signed artifacts and proof
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: play-preflight-${{ steps.metadata.outputs.version }}-${{ steps.metadata.outputs.tree }}
|
||||
path: app/build/reports/play-preflight.json
|
||||
path: app/build/preflight-release/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
@@ -152,4 +199,4 @@ jobs:
|
||||
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Approve Android Release** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The signed build, DEX scan, strict quality gate, and Play draft upload passed. The private artifact contains the exact APK/AAB bytes that approval will publish. Ensure this release tree is on main, then run **Approve Android Release** from main." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -2,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+15
-10
@@ -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 并停留在完成位置。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-88
@@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-18
@@ -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
|
||||
|
||||
+1
-28
@@ -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=") }
|
||||
|
||||
-21
@@ -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(
|
||||
|
||||
-114
@@ -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
|
||||
|
||||
+1
-19
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package and verify the immutable Android Play-preflight artifact set."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
MANIFEST_NAME = "play-preflight.json"
|
||||
CHECKSUMS_NAME = "SHA256SUMS.txt"
|
||||
EXPECTED_ROLES = {
|
||||
"sideload-apk": "hermes-relay-{version}-sideload-release.apk",
|
||||
"google-play-aab": "hermes-relay-{version}-googlePlay-release.aab",
|
||||
}
|
||||
GIT_OBJECT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ArtifactError(ValueError):
|
||||
"""Raised when a release artifact set violates its contract."""
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def safe_artifact_name(name: str) -> str:
|
||||
candidate = Path(name)
|
||||
if candidate.name != name or name in {"", ".", ".."}:
|
||||
raise ArtifactError(f"Unsafe artifact filename: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def expected_checksum_text(artifacts: list[dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
f"{artifact['sha256']} {artifact['name']}"
|
||||
for artifact in sorted(artifacts, key=lambda item: item["name"])
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def package_artifacts(args: argparse.Namespace) -> None:
|
||||
if not GIT_OBJECT_PATTERN.fullmatch(args.commit):
|
||||
raise ArtifactError(f"Invalid commit id: {args.commit!r}")
|
||||
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
|
||||
raise ArtifactError(f"Invalid tree id: {args.tree!r}")
|
||||
if not str(args.version_code).isdigit():
|
||||
raise ArtifactError(f"Invalid versionCode: {args.version_code!r}")
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
if any(output.iterdir()):
|
||||
raise ArtifactError(f"Output directory must be empty: {output}")
|
||||
|
||||
sources = {
|
||||
"sideload-apk": args.sideload_apk.resolve(),
|
||||
"google-play-aab": args.google_play_aab.resolve(),
|
||||
}
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
for role, source in sources.items():
|
||||
if not source.is_file():
|
||||
raise ArtifactError(f"Missing {role}: {source}")
|
||||
expected_name = EXPECTED_ROLES[role].format(version=args.version)
|
||||
if source.name != expected_name:
|
||||
raise ArtifactError(
|
||||
f"{role} must be named {expected_name}, got {source.name}"
|
||||
)
|
||||
|
||||
destination = output / safe_artifact_name(source.name)
|
||||
shutil.copy2(source, destination)
|
||||
artifacts.append(
|
||||
{
|
||||
"role": role,
|
||||
"name": destination.name,
|
||||
"sha256": sha256(destination),
|
||||
"size": destination.stat().st_size,
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"version": args.version,
|
||||
"versionCode": str(args.version_code),
|
||||
"commit": args.commit,
|
||||
"tree": args.tree,
|
||||
"track": "production",
|
||||
"status": "draft",
|
||||
"artifacts": sorted(artifacts, key=lambda item: item["role"]),
|
||||
}
|
||||
(output / MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(output / CHECKSUMS_NAME).write_text(
|
||||
expected_checksum_text(artifacts),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
print(json.dumps(manifest, sort_keys=True))
|
||||
|
||||
|
||||
def load_manifest(directory: Path) -> dict[str, Any]:
|
||||
manifest_path = directory / MANIFEST_NAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise ArtifactError(f"Missing {MANIFEST_NAME} in {directory}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ArtifactError(f"Invalid {MANIFEST_NAME}: {exc}") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise ArtifactError(f"{MANIFEST_NAME} must contain a JSON object")
|
||||
return manifest
|
||||
|
||||
|
||||
def extract_archive(args: argparse.Namespace) -> None:
|
||||
archive_path = args.archive.resolve()
|
||||
if not archive_path.is_file():
|
||||
raise ArtifactError(f"Artifact archive does not exist: {archive_path}")
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
if any(output.iterdir()):
|
||||
raise ArtifactError(f"Output directory must be empty: {output}")
|
||||
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for info in archive.infolist():
|
||||
if info.is_dir():
|
||||
raise ArtifactError(
|
||||
f"Artifact archive contains a directory: {info.filename!r}"
|
||||
)
|
||||
name = safe_artifact_name(info.filename)
|
||||
if name in seen:
|
||||
raise ArtifactError(f"Duplicate archive entry: {name}")
|
||||
unix_type = (info.external_attr >> 16) & 0o170000
|
||||
if unix_type == 0o120000:
|
||||
raise ArtifactError(f"Artifact archive contains a symlink: {name}")
|
||||
seen.add(name)
|
||||
with archive.open(info) as source, (output / name).open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ArtifactError(f"Invalid artifact ZIP: {exc}") from exc
|
||||
|
||||
|
||||
def verify_artifacts(args: argparse.Namespace) -> None:
|
||||
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
|
||||
raise ArtifactError(f"Invalid expected tree id: {args.tree!r}")
|
||||
if not str(args.version_code).isdigit():
|
||||
raise ArtifactError(f"Invalid expected versionCode: {args.version_code!r}")
|
||||
directory = args.directory.resolve()
|
||||
if not directory.is_dir():
|
||||
raise ArtifactError(f"Artifact directory does not exist: {directory}")
|
||||
manifest = load_manifest(directory)
|
||||
commit = manifest.get("commit")
|
||||
if not isinstance(commit, str) or not GIT_OBJECT_PATTERN.fullmatch(commit):
|
||||
raise ArtifactError(f"Manifest commit is invalid: {commit!r}")
|
||||
|
||||
expected_metadata = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"version": args.version,
|
||||
"versionCode": str(args.version_code),
|
||||
"tree": args.tree,
|
||||
"track": "production",
|
||||
"status": "draft",
|
||||
}
|
||||
for key, expected in expected_metadata.items():
|
||||
actual = manifest.get(key)
|
||||
if str(actual) != str(expected):
|
||||
raise ArtifactError(
|
||||
f"Manifest {key} mismatch: expected {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
entries = manifest.get("artifacts")
|
||||
if not isinstance(entries, list):
|
||||
raise ArtifactError("Manifest artifacts must be a list")
|
||||
by_role: dict[str, dict[str, Any]] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise ArtifactError("Every manifest artifact must be an object")
|
||||
role = entry.get("role")
|
||||
if role not in EXPECTED_ROLES:
|
||||
raise ArtifactError(f"Unexpected artifact role: {role!r}")
|
||||
if role in by_role:
|
||||
raise ArtifactError(f"Duplicate artifact role: {role}")
|
||||
by_role[role] = entry
|
||||
if set(by_role) != set(EXPECTED_ROLES):
|
||||
missing = sorted(set(EXPECTED_ROLES) - set(by_role))
|
||||
raise ArtifactError(f"Missing artifact roles: {', '.join(missing)}")
|
||||
|
||||
verified: list[dict[str, Any]] = []
|
||||
for role, expected_pattern in EXPECTED_ROLES.items():
|
||||
entry = by_role[role]
|
||||
name = safe_artifact_name(str(entry.get("name", "")))
|
||||
expected_name = expected_pattern.format(version=args.version)
|
||||
if name != expected_name:
|
||||
raise ArtifactError(f"{role} must be named {expected_name}, got {name}")
|
||||
path = directory / name
|
||||
if not path.is_file():
|
||||
raise ArtifactError(f"Missing artifact file: {name}")
|
||||
actual_hash = sha256(path)
|
||||
if actual_hash != entry.get("sha256"):
|
||||
raise ArtifactError(f"SHA-256 mismatch for {name}")
|
||||
if path.stat().st_size != entry.get("size"):
|
||||
raise ArtifactError(f"Size mismatch for {name}")
|
||||
verified.append(entry)
|
||||
|
||||
checksum_path = directory / CHECKSUMS_NAME
|
||||
try:
|
||||
checksum_text = checksum_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise ArtifactError(f"Missing {CHECKSUMS_NAME}") from exc
|
||||
if checksum_text != expected_checksum_text(verified):
|
||||
raise ArtifactError(f"{CHECKSUMS_NAME} does not match the manifest")
|
||||
|
||||
allowed_names = {
|
||||
MANIFEST_NAME,
|
||||
CHECKSUMS_NAME,
|
||||
*(entry["name"] for entry in verified),
|
||||
}
|
||||
directory_entries = list(directory.iterdir())
|
||||
non_files = sorted(path.name for path in directory_entries if not path.is_file())
|
||||
if non_files:
|
||||
raise ArtifactError(
|
||||
"Artifact directory contains non-file entries: " + ", ".join(non_files)
|
||||
)
|
||||
actual_names = {path.name for path in directory_entries}
|
||||
if actual_names != allowed_names:
|
||||
unexpected = sorted(actual_names - allowed_names)
|
||||
missing = sorted(allowed_names - actual_names)
|
||||
details = []
|
||||
if unexpected:
|
||||
details.append(f"unexpected: {', '.join(unexpected)}")
|
||||
if missing:
|
||||
details.append(f"missing: {', '.join(missing)}")
|
||||
raise ArtifactError("Artifact set mismatch (" + "; ".join(details) + ")")
|
||||
|
||||
print(json.dumps(manifest, sort_keys=True))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
package = subparsers.add_parser("package", help="Create a preflight artifact set")
|
||||
package.add_argument("--version", required=True)
|
||||
package.add_argument("--version-code", required=True)
|
||||
package.add_argument("--commit", required=True)
|
||||
package.add_argument("--tree", required=True)
|
||||
package.add_argument("--sideload-apk", required=True, type=Path)
|
||||
package.add_argument("--google-play-aab", required=True, type=Path)
|
||||
package.add_argument("--output", required=True, type=Path)
|
||||
package.set_defaults(func=package_artifacts)
|
||||
|
||||
extract = subparsers.add_parser(
|
||||
"extract", help="Safely extract a downloaded Actions artifact"
|
||||
)
|
||||
extract.add_argument("--archive", required=True, type=Path)
|
||||
extract.add_argument("--output", required=True, type=Path)
|
||||
extract.set_defaults(func=extract_archive)
|
||||
|
||||
verify = subparsers.add_parser("verify", help="Verify a preflight artifact set")
|
||||
verify.add_argument("--version", required=True)
|
||||
verify.add_argument("--version-code", required=True)
|
||||
verify.add_argument("--tree", required=True)
|
||||
verify.add_argument("--directory", required=True, type=Path)
|
||||
verify.set_defaults(func=verify_artifacts)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
args.func(args)
|
||||
except ArtifactError as exc:
|
||||
parser.error(str(exc))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,285 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')]
|
||||
[string]$Version,
|
||||
|
||||
[switch]$DeployPhone,
|
||||
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[switch]$DryRun,
|
||||
|
||||
[string]$Repository = 'Codename-11/hermes-relay'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
Set-Location $RepoRoot
|
||||
|
||||
function Invoke-Captured {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Command,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
|
||||
$output = & $Command @Arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Command $($Arguments -join ' ') failed:`n$($output -join [Environment]::NewLine)"
|
||||
}
|
||||
return ($output -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
|
||||
function Invoke-Streaming {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Command,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
|
||||
& $Command @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Start-GitHubWorkflow {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Workflow,
|
||||
[Parameter(Mandatory = $true)][string]$Ref,
|
||||
[Parameter(Mandatory = $true)][hashtable]$Inputs
|
||||
)
|
||||
|
||||
$arguments = @('workflow', 'run', $Workflow, '--repo', $Repository, '--ref', $Ref)
|
||||
foreach ($entry in $Inputs.GetEnumerator() | Sort-Object Key) {
|
||||
$arguments += @('-f', "$($entry.Key)=$($entry.Value)")
|
||||
}
|
||||
$output = Invoke-Captured -Command 'gh' -Arguments $arguments
|
||||
if ($output -notmatch '/actions/runs/(?<id>\d+)') {
|
||||
throw "GitHub did not return a workflow run URL:`n$output"
|
||||
}
|
||||
return $Matches.id
|
||||
}
|
||||
|
||||
function Wait-GitHubRun {
|
||||
param([Parameter(Mandatory = $true)][string]$RunId)
|
||||
|
||||
Invoke-Streaming -Command 'gh' -Arguments @(
|
||||
'run', 'watch', $RunId,
|
||||
'--repo', $Repository,
|
||||
'--exit-status',
|
||||
'--interval', '15'
|
||||
)
|
||||
}
|
||||
|
||||
function Require-CleanPreparedDev {
|
||||
$branch = Invoke-Captured -Command 'git' -Arguments @('branch', '--show-current')
|
||||
if ($branch -ne 'dev') {
|
||||
throw "Android publication must start from dev; current branch is $branch"
|
||||
}
|
||||
$status = Invoke-Captured -Command 'git' -Arguments @('status', '--porcelain')
|
||||
if ($status) {
|
||||
throw "Working tree is not clean:`n$status"
|
||||
}
|
||||
|
||||
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'dev', 'main', '--tags')
|
||||
$head = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
|
||||
$originDev = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/dev')
|
||||
if ($head -ne $originDev) {
|
||||
throw "Local dev ($head) does not match origin/dev ($originDev)"
|
||||
}
|
||||
|
||||
$existingTag = & git ls-remote --tags origin "refs/tags/android-v$Version"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Unable to query existing Android tags'
|
||||
}
|
||||
if ($existingTag) {
|
||||
throw "android-v$Version already exists"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-ReleaseMetadata {
|
||||
$versionsFile = Get-Content 'gradle/libs.versions.toml' -Raw
|
||||
if ($versionsFile -notmatch 'appVersionName\s*=\s*"(?<version>[^"]+)"') {
|
||||
throw 'Unable to read appVersionName from gradle/libs.versions.toml'
|
||||
}
|
||||
if ($Matches.version -ne $Version) {
|
||||
throw "Requested version $Version does not match appVersionName $($Matches.version)"
|
||||
}
|
||||
if ($versionsFile -notmatch 'appVersionCode\s*=\s*"(?<code>\d+)"') {
|
||||
throw 'Unable to read appVersionCode from gradle/libs.versions.toml'
|
||||
}
|
||||
$versionCode = $Matches.code
|
||||
if (-not (Select-String -Path 'CHANGELOG.md' -Pattern "^## \[(Android )?$([regex]::Escape($Version))\]" -Quiet)) {
|
||||
throw "CHANGELOG.md has no Android release heading for $Version"
|
||||
}
|
||||
return $versionCode
|
||||
}
|
||||
|
||||
function Deploy-CompatiblePhoneBuild {
|
||||
param([Parameter(Mandatory = $true)][string]$VersionCode)
|
||||
|
||||
$sdk = if ($env:ANDROID_HOME) {
|
||||
$env:ANDROID_HOME
|
||||
} else {
|
||||
Join-Path $env:LOCALAPPDATA 'Android\Sdk'
|
||||
}
|
||||
$adb = Join-Path $sdk 'platform-tools\adb.exe'
|
||||
if (-not (Test-Path -LiteralPath $adb)) {
|
||||
throw "adb was not found at $adb"
|
||||
}
|
||||
$env:ANDROID_HOME = $sdk
|
||||
$env:ANDROID_SDK_ROOT = $sdk
|
||||
|
||||
Invoke-Streaming -Command '.\gradlew.bat' -Arguments @(
|
||||
':app:assembleSideloadDebug',
|
||||
'--no-daemon',
|
||||
'--console=plain'
|
||||
)
|
||||
$apk = Get-ChildItem 'app\build\outputs\apk\sideload\debug' -Filter "*-$Version-sideload-debug.apk" |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $apk) {
|
||||
throw "No compatible sideload debug APK was produced for $Version"
|
||||
}
|
||||
|
||||
$adbArguments = @()
|
||||
if ($DeviceSerial) {
|
||||
$adbArguments += @('-s', $DeviceSerial)
|
||||
}
|
||||
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @('install', '-r', $apk.FullName))
|
||||
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
|
||||
'shell', 'am', 'force-stop', 'com.axiomlabs.hermesrelay.sideload'
|
||||
))
|
||||
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
|
||||
'shell', 'am', 'start', '-n',
|
||||
'com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity'
|
||||
))
|
||||
$package = Invoke-Captured -Command $adb -Arguments ($adbArguments + @(
|
||||
'shell', 'dumpsys', 'package', 'com.axiomlabs.hermesrelay.sideload'
|
||||
))
|
||||
if ($package -notmatch "versionCode=$([regex]::Escape($VersionCode))\b" -or
|
||||
$package -notmatch "versionName=$([regex]::Escape($Version))-sideload\b") {
|
||||
throw "Phone package verification failed for version $Version / code $VersionCode"
|
||||
}
|
||||
Write-Host "Phone verified at $Version-sideload (versionCode $VersionCode)."
|
||||
}
|
||||
|
||||
$versionCode = Require-ReleaseMetadata
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "Dry run: Android $Version (versionCode $versionCode)"
|
||||
Write-Host '1. Require clean origin/dev and an unused Android tag.'
|
||||
Write-Host '2. Run and await Play Preflight from dev.'
|
||||
Write-Host '3. Open or reuse the dev-to-main release PR, await checks, and merge.'
|
||||
Write-Host '4. Verify the main tree equals the preflighted dev tree.'
|
||||
Write-Host '5. Run and await Approve Android Release from main.'
|
||||
Write-Host '6. Verify the immutable tag and GitHub release.'
|
||||
if ($DeployPhone) {
|
||||
Write-Host '7. Build/install the compatible sideload-debug APK without erasing phone data.'
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
Invoke-Streaming -Command 'gh' -Arguments @('auth', 'status')
|
||||
Require-CleanPreparedDev
|
||||
|
||||
$devCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
|
||||
$devTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD^{tree}')
|
||||
Write-Host "Starting Android $Version from dev $devCommit (tree $devTree)."
|
||||
|
||||
$preflightRun = Start-GitHubWorkflow -Workflow 'play-preflight-android.yml' -Ref 'dev' -Inputs @{
|
||||
version = $Version
|
||||
}
|
||||
Write-Host "Play preflight run: https://github.com/$Repository/actions/runs/$preflightRun"
|
||||
Wait-GitHubRun -RunId $preflightRun
|
||||
|
||||
$pullRequests = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'pr', 'list',
|
||||
'--repo', $Repository,
|
||||
'--base', 'main',
|
||||
'--head', 'dev',
|
||||
'--state', 'open',
|
||||
'--limit', '1',
|
||||
'--json', 'number,url,headRefOid'
|
||||
) | ConvertFrom-Json
|
||||
if (-not $pullRequests) {
|
||||
$body = @"
|
||||
Promote the exact Play-preflighted Android $Version release tree from ``dev`` to ``main``.
|
||||
|
||||
- dev SHA: ``$devCommit``
|
||||
- release tree: ``$devTree``
|
||||
- Play preflight: https://github.com/$Repository/actions/runs/$preflightRun
|
||||
|
||||
Approval will verify this unchanged tree before creating ``android-v$Version``.
|
||||
"@
|
||||
$prUrl = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'pr', 'create',
|
||||
'--repo', $Repository,
|
||||
'--base', 'main',
|
||||
'--head', 'dev',
|
||||
'--title', "release(android): promote android-v$Version",
|
||||
'--body', $body
|
||||
)
|
||||
$prNumber = [regex]::Match($prUrl, '/pull/(?<number>\d+)').Groups['number'].Value
|
||||
} else {
|
||||
if ([string]$pullRequests[0].headRefOid -ne $devCommit) {
|
||||
throw "Existing release PR head $($pullRequests[0].headRefOid) does not match preflighted dev $devCommit"
|
||||
}
|
||||
$prNumber = [string]$pullRequests[0].number
|
||||
$prUrl = [string]$pullRequests[0].url
|
||||
}
|
||||
if (-not $prNumber) {
|
||||
throw "Unable to determine release PR number from $prUrl"
|
||||
}
|
||||
Write-Host "Release PR: $prUrl"
|
||||
Invoke-Streaming -Command 'gh' -Arguments @(
|
||||
'pr', 'checks', $prNumber,
|
||||
'--repo', $Repository,
|
||||
'--watch',
|
||||
'--fail-fast'
|
||||
)
|
||||
$checkedHead = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'pr', 'view', $prNumber,
|
||||
'--repo', $Repository,
|
||||
'--json', 'headRefOid',
|
||||
'--jq', '.headRefOid'
|
||||
)
|
||||
if ($checkedHead -ne $devCommit) {
|
||||
throw "Release PR advanced to $checkedHead after preflight; rerun from the new dev tip"
|
||||
}
|
||||
Invoke-Streaming -Command 'gh' -Arguments @(
|
||||
'pr', 'merge', $prNumber,
|
||||
'--repo', $Repository,
|
||||
'--merge'
|
||||
)
|
||||
|
||||
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'main', '--tags')
|
||||
$mainCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main')
|
||||
$mainTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main^{tree}')
|
||||
if ($mainTree -ne $devTree) {
|
||||
throw "Merged main tree $mainTree does not match preflighted dev tree $devTree"
|
||||
}
|
||||
|
||||
$approvalRun = Start-GitHubWorkflow -Workflow 'approve-release-android.yml' -Ref 'main' -Inputs @{
|
||||
version = $Version
|
||||
}
|
||||
Write-Host "Approval run: https://github.com/$Repository/actions/runs/$approvalRun"
|
||||
Wait-GitHubRun -RunId $approvalRun
|
||||
|
||||
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', '--tags')
|
||||
$tagCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', "android-v$Version")
|
||||
if ($tagCommit -ne $mainCommit) {
|
||||
throw "android-v$Version points to $tagCommit instead of main $mainCommit"
|
||||
}
|
||||
$release = Invoke-Captured -Command 'gh' -Arguments @(
|
||||
'release', 'view', "android-v$Version",
|
||||
'--repo', $Repository,
|
||||
'--json', 'url,tagName,publishedAt'
|
||||
) | ConvertFrom-Json
|
||||
Write-Host "Published release: $($release.url)"
|
||||
|
||||
if ($DeployPhone) {
|
||||
Deploy-CompatiblePhoneBuild -VersionCode $versionCode
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from argparse import Namespace
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("android-release-artifacts.py")
|
||||
SPEC = importlib.util.spec_from_file_location("android_release_artifacts", SCRIPT_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class AndroidReleaseArtifactsTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.apk = self.root / "hermes-relay-1.5.2-sideload-release.apk"
|
||||
self.aab = self.root / "hermes-relay-1.5.2-googlePlay-release.aab"
|
||||
self.apk.write_bytes(b"apk payload")
|
||||
self.aab.write_bytes(b"aab payload")
|
||||
self.output = self.root / "release"
|
||||
self.package_args = Namespace(
|
||||
version="1.5.2",
|
||||
version_code="35",
|
||||
commit="a" * 40,
|
||||
tree="b" * 40,
|
||||
sideload_apk=self.apk,
|
||||
google_play_aab=self.aab,
|
||||
output=self.output,
|
||||
)
|
||||
self.verify_args = Namespace(
|
||||
version="1.5.2",
|
||||
version_code="35",
|
||||
tree="b" * 40,
|
||||
directory=self.output,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_package_and_verify_round_trip(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
manifest = json.loads(
|
||||
(self.output / MODULE.MANIFEST_NAME).read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual("1.5.2", manifest["version"])
|
||||
self.assertEqual("35", manifest["versionCode"])
|
||||
self.assertEqual(
|
||||
{"google-play-aab", "sideload-apk"},
|
||||
{artifact["role"] for artifact in manifest["artifacts"]},
|
||||
)
|
||||
|
||||
def test_verify_rejects_modified_artifact(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
packaged_apk = self.output / self.apk.name
|
||||
packaged_apk.write_bytes(b"tampered")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "SHA-256 mismatch"):
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
def test_verify_rejects_wrong_release_tree(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
self.verify_args.tree = "c" * 40
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "tree mismatch"):
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
def test_package_rejects_wrong_extension(self) -> None:
|
||||
self.package_args.sideload_apk = self.root / "not-an-apk.txt"
|
||||
self.package_args.sideload_apk.write_text("no", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "must be named"):
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
|
||||
def test_verify_rejects_unexpected_file(self) -> None:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
MODULE.package_artifacts(self.package_args)
|
||||
(self.output / "extra.bin").write_bytes(b"unexpected")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "unexpected"):
|
||||
MODULE.verify_artifacts(self.verify_args)
|
||||
|
||||
def test_extract_rejects_path_traversal(self) -> None:
|
||||
archive = self.root / "artifact.zip"
|
||||
with zipfile.ZipFile(archive, "w") as handle:
|
||||
handle.writestr("../outside.txt", "unsafe")
|
||||
|
||||
with self.assertRaisesRegex(MODULE.ArtifactError, "Unsafe artifact filename"):
|
||||
MODULE.extract_archive(
|
||||
Namespace(archive=archive, output=self.root / "extracted")
|
||||
)
|
||||
self.assertFalse((self.root / "outside.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user