name: Android APK (debug) # Triggers: # - push v*-tauri tag → signed release APK + minisign + updater manifest → GitHub Release # - workflow_dispatch → signed release APK when ANDROID_KEYSTORE_* secrets exist # (overlay install + user data preserved); otherwise unsigned debug APK (annotated, non-blocking) # - workflow_dispatch + signed_debug=true — debug APK signed with the release # keystore (when secrets exist); artifact remains openless-android-debug-*. # # #1103 build-time changes: # - Do not wipe Cargo/Gradle/target before cache post-save (removed Free disk step). # - Bump rust-cache prefix-key so old half-cold caches are not reused. # - Dispatch defaults to aarch64 only; tag builds all ABIs in a parallel matrix. # - Optional dispatch fast_profile (disable LTO / raise codegen-units); tag never uses it. # - Tauri still runs plugin-init cargo + android-studio-script per ABI (first ABI twice). on: push: tags: - 'v*-tauri' workflow_dispatch: inputs: abis: description: 'ABIs to build (comma-separated: aarch64,armv7,i686,x86_64) or all' required: false default: 'aarch64' fast_profile: description: 'Dispatch-only fast release profile (no LTO, codegen-units=16). Ignored on tags.' type: boolean default: false signed_debug: description: 'Build debug mode but sign it with the release keystore (requires ANDROID_KEYSTORE_* secrets).' type: boolean default: false # Repeated pushes of the same tag run only the newest; workflow_dispatch isolates by run_id to avoid mutual cancellation. concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} cancel-in-progress: ${{ github.event_name == 'push' }} jobs: plan: name: Plan Android ABI matrix permissions: contents: read runs-on: ubuntu-latest outputs: is_tag_release: ${{ steps.mode.outputs.is_tag_release }} mode: ${{ steps.mode.outputs.mode }} label: ${{ steps.mode.outputs.label }} signing: ${{ steps.mode.outputs.signing }} signing_label: ${{ steps.mode.outputs.signing_label }} matrix: ${{ steps.abis.outputs.matrix }} abi_list: ${{ steps.abis.outputs.abi_list }} gradle_abi_list: ${{ steps.abis.outputs.gradle_abi_list }} fast_profile: ${{ steps.abis.outputs.fast_profile }} release_channel: ${{ steps.mode.outputs.release_channel }} steps: - uses: actions/checkout@v4 with: submodules: false - name: Detect build mode id: mode shell: bash env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} INPUT_SIGNED_DEBUG: ${{ github.event.inputs.signed_debug }} run: | set -euo pipefail is_tag=false if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then is_tag=true fi echo "is_tag_release=$is_tag" >> "$GITHUB_OUTPUT" channel=stable if [[ "${{ github.ref_name }}" == *-beta-tauri ]] || [[ "${{ github.ref_name }}" == *-Beta.* ]]; then channel=beta fi echo "release_channel=$channel" >> "$GITHUB_OUTPUT" has_keystore=true for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do if [ -z "${!name:-}" ]; then has_keystore=false break fi done if $is_tag; then echo "mode=release" >> "$GITHUB_OUTPUT" echo "label=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" echo "signing=tag-release" >> "$GITHUB_OUTPUT" echo "signing_label=Tag release with release keystore" >> "$GITHUB_OUTPUT" elif [[ "${INPUT_SIGNED_DEBUG:-false}" == "true" ]] && $has_keystore; then echo "mode=debug" >> "$GITHUB_OUTPUT" echo "label=signed-debug-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT" echo "signing=signed-debug" >> "$GITHUB_OUTPUT" echo "signing_label=Manual dispatch debug APK signed with release keystore" >> "$GITHUB_OUTPUT" elif $has_keystore; then echo "mode=release" >> "$GITHUB_OUTPUT" echo "label=dispatch-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT" echo "signing=dispatch-release" >> "$GITHUB_OUTPUT" echo "signing_label=Manual dispatch with release keystore (overlay install OK)" >> "$GITHUB_OUTPUT" else echo "mode=debug" >> "$GITHUB_OUTPUT" echo "label=unsigned-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT" echo "signing=debug-fallback" >> "$GITHUB_OUTPUT" echo "signing_label=Unsigned debug fallback (no keystore secrets)" >> "$GITHUB_OUTPUT" echo "::notice title=Unsigned debug APK::ANDROID_KEYSTORE_* secrets not configured. Building debug APK without release signing. Overlay install and user data preservation require the release keystore; uninstall before installing if replacing a signed build." if [[ "${INPUT_SIGNED_DEBUG:-false}" == "true" ]]; then echo "::warning::signed_debug was requested but Android keystore secrets are incomplete; falling back to an unsigned debug APK." fi fi - name: Resolve ABI matrix id: abis shell: bash env: INPUT_ABIS: ${{ github.event.inputs.abis }} INPUT_FAST_PROFILE: ${{ github.event.inputs.fast_profile }} run: | set -euo pipefail if [ "${{ steps.mode.outputs.is_tag_release }}" = "true" ]; then raw=all fast=false else raw="${INPUT_ABIS:-aarch64}" fast="${INPUT_FAST_PROFILE:-false}" fi matrix="$(node openless-all/app/scripts/android-abi-matrix.mjs parse "$raw")" echo "matrix=$matrix" >> "$GITHUB_OUTPUT" abi_list="$(node -e 'const m=JSON.parse(process.argv[1]); process.stdout.write(m.map(x=>x.abi).join(","))' "$matrix")" gradle_list="$(node -e 'const m=JSON.parse(process.argv[1]); process.stdout.write(m.map(x=>x.gradle_abi).join(","))' "$matrix")" echo "abi_list=$abi_list" >> "$GITHUB_OUTPUT" echo "gradle_abi_list=$gradle_list" >> "$GITHUB_OUTPUT" echo "fast_profile=$fast" >> "$GITHUB_OUTPUT" echo "Resolved ABIs: $abi_list (fast_profile=$fast)" build-android-apk: name: Build Android (${{ matrix.abi }}) needs: plan permissions: contents: write runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: ${{ fromJson(needs.plan.outputs.matrix) }} env: CI: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} OPENLESS_RELEASE_CHANNEL: ${{ needs.plan.outputs.release_channel }} OPENLESS_ANDROID_TARGETS: ${{ matrix.abi }} CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 with: # Android does not use the local Qwen3/Whisper C submodules. submodules: false - name: Disable macOS-only Qwen3 MLX dependency run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs - name: Check updater signing availability (tag release) if: needs.plan.outputs.is_tag_release == 'true' shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then echo "::error::TAURI_SIGNING_PRIVATE_KEY is required for signed Android release artifacts." exit 1 fi - name: Check Android keystore secrets (tag release) if: needs.plan.outputs.is_tag_release == 'true' shell: bash env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | missing=() for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do if [ -z "${!name:-}" ]; then missing+=("$name") fi done if [ "${#missing[@]}" -gt 0 ]; then echo "::error::Android release signing secrets are required for tag builds: ${missing[*]}" echo "::error::Configure ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD in repo secrets." exit 1 fi - name: Apply dispatch fast profile if: needs.plan.outputs.fast_profile == 'true' && needs.plan.outputs.is_tag_release != 'true' shell: bash run: | set -euo pipefail # Override [profile.release] for this job only; tag builds never take this path. echo "CARGO_PROFILE_RELEASE_LTO=false" >> "$GITHUB_ENV" echo "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16" >> "$GITHUB_ENV" echo "::notice title=Fast profile::Dispatch fast_profile enabled (LTO off, codegen-units=16)" - name: Setup Java 17 (Zulu) uses: actions/setup-java@v4 with: distribution: zulu java-version: '17' - name: Setup Android SDK uses: android-actions/setup-android@v3 with: packages: platform-tools - name: Install NDK and accept SDK licenses shell: bash run: | set -euo pipefail sdkmanager "ndk;26.1.10909125" ndk_dir="$ANDROID_HOME/ndk/26.1.10909125" if [ ! -d "$ndk_dir" ]; then echo "::error::NDK not found at $ndk_dir" exit 1 fi echo "ANDROID_NDK_HOME=$ndk_dir" >> "$GITHUB_ENV" echo "NDK_HOME=$ndk_dir" >> "$GITHUB_ENV" set +o pipefail yes | sdkmanager --licenses sdkmanager_exit=${PIPESTATUS[1]} set -o pipefail if [ "$sdkmanager_exit" -ne 0 ]; then echo "::error::sdkmanager --licenses failed with exit code $sdkmanager_exit" exit "$sdkmanager_exit" fi - uses: actions/setup-node@v4 with: node-version: "22" cache: npm cache-dependency-path: openless-all/app/package-lock.json - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.rust_target }} - name: Cache Cargo id: rust-cache uses: swatinem/rust-cache@v2 with: # #1103: bump prefix so previously truncated / half-cold caches are not reused. prefix-key: v1-rust-android-1103 shared-key: android-${{ matrix.abi }} workspaces: openless-all/app/src-tauri -> target cache-on-failure: true - uses: gradle/actions/setup-gradle@v4 with: # Keep Gradle caches for post-job save; do not wipe ~/.gradle before post. cache-read-only: false - name: Install npm deps working-directory: openless-all/app run: npm ci # Frontend is built by tauri beforeBuildCommand; skip a duplicate standalone build (#1103). - name: Initialize Android project working-directory: openless-all/app run: npm run tauri -- android init --ci - name: Copy Android scaffolding (Kotlin + XML) working-directory: openless-all/app run: node scripts/copy-android-scaffolding.mjs - name: Merge APK v1 manifest permissions working-directory: openless-all/app run: node scripts/merge-android-v1-manifest.mjs - name: Merge overlay / accessibility manifest working-directory: openless-all/app run: node scripts/merge-android-overlay-manifest.mjs - name: Merge Shizuku manifest working-directory: openless-all/app run: node scripts/merge-android-shizuku-manifest.mjs - name: Patch Shizuku Gradle dependencies working-directory: openless-all/app run: node scripts/patch-android-shizuku-deps.mjs - name: Merge updater / install manifest working-directory: openless-all/app run: node scripts/merge-android-updater-manifest.mjs - name: Configure Android release signing if: needs.plan.outputs.mode == 'release' || needs.plan.outputs.signing == 'signed-debug' working-directory: openless-all/app env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} # signed_debug: also wire debug buildType to the release keystore OPENLESS_SIGN_DEBUG: ${{ needs.plan.outputs.signing == 'signed-debug' && 'true' || 'false' }} run: node scripts/configure-android-release-signing.mjs - name: Prime Gradle wrapper working-directory: openless-all/app shell: bash run: | set -euo pipefail for attempt in 1 2 3; do if src-tauri/gen/android/gradlew --project-dir src-tauri/gen/android --version --no-daemon; then exit 0 fi if [ "$attempt" -lt 3 ]; then sleep $([ "$attempt" -eq 1 ] && echo 15 || echo 30) fi done exit 1 - name: Build Android debug APK if: needs.plan.outputs.mode == 'debug' working-directory: openless-all/app env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: npm run tauri:android:build:debug - name: Build Android release APK if: needs.plan.outputs.mode == 'release' working-directory: openless-all/app env: # Resolve signing credentials at execution time; compiled Gradle scripts # are cached and must never contain secret literals. ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: npm run tauri:android:build:release - name: Record cache / duplicate-compile notes if: always() shell: bash run: | set -euo pipefail { echo "### Cache / compile notes (\`${{ matrix.abi }}\`)" echo echo "- Rust cache hit: \`${{ steps.rust-cache.outputs.cache-hit }}\`" echo "- Tauri runs plugin-init cargo for the first ABI, then \`android-studio-script\` per ABI (first ABI may compile twice)." echo "- Do not wipe \`target\` / Cargo registry / Gradle caches before action post-save (#1103)." } >> "$GITHUB_STEP_SUMMARY" - name: Collect split APKs id: apk shell: bash working-directory: openless-all/app env: OPENLESS_APK_MODE: ${{ needs.plan.outputs.mode }} OPENLESS_APK_LABEL: ${{ needs.plan.outputs.label }} OPENLESS_EXPECTED_GRADLE_ABIS: ${{ matrix.gradle_abi }} run: node scripts/collect-android-split-apks.mjs - name: Upload Android APK artifact uses: actions/upload-artifact@v4 with: name: ${{ needs.plan.outputs.mode == 'release' && format('openless-android-release-{0}', matrix.gradle_abi) || format('openless-android-debug-{0}', matrix.gradle_abi) }} path: ${{ steps.apk.outputs.apk_path }} if-no-files-found: error - name: Write build summary if: always() run: | cat >> "$GITHUB_STEP_SUMMARY" << EOF ### Android APK build (\`${{ matrix.abi }}\`) | Field | Value | |-------|-------| | Gradle mode | \`${{ needs.plan.outputs.mode }}\` | | Signing | ${{ needs.plan.outputs.signing_label }} | | Artifact label | \`${{ needs.plan.outputs.label }}\` | | Fast profile | \`${{ needs.plan.outputs.fast_profile }}\` | | Rust cache hit | \`${{ steps.rust-cache.outputs.cache-hit }}\` | EOF if [ "${{ needs.plan.outputs.signing }}" = "debug-fallback" ]; then cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' > **Unsigned debug APK.** Debug builds use a CI-only signature. Use `adb install -r` only when replacing the same debug build. To upgrade from a signed release without losing user data, configure `ANDROID_KEYSTORE_*` repo secrets and re-run this workflow. EOF elif [ "${{ needs.plan.outputs.signing }}" = "dispatch-release" ]; then cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' > **Signed release APK** (manual dispatch). Same keystore as tag releases — supports overlay install and preserves user data when upgrading from an existing signed install. EOF elif [ "${{ needs.plan.outputs.signing }}" = "signed-debug" ]; then cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' > **Signed debug APK** (manual dispatch). Debug mode is retained for diagnostics; the release keystore is used so `adb install -r` can upgrade the matching signed app without changing the debug artifact name. EOF fi publish-android-release: name: Publish Android release assets if: needs.plan.outputs.is_tag_release == 'true' needs: [plan, build-android-apk] permissions: contents: write runs-on: ubuntu-latest env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} OPENLESS_RELEASE_CHANNEL: ${{ needs.plan.outputs.release_channel }} steps: - uses: actions/checkout@v4 with: submodules: false - uses: actions/setup-node@v4 with: node-version: "22" cache: npm cache-dependency-path: openless-all/app/package-lock.json - name: Install npm deps working-directory: openless-all/app run: npm ci - name: Download all ABI artifacts uses: actions/download-artifact@v4 with: pattern: openless-android-release-* path: ${{ runner.temp }}/android-release-apks merge-multiple: true - name: Verify full ABI set and stage files id: stage shell: bash working-directory: openless-all/app env: OPENLESS_STAGE_DIR: ${{ runner.temp }}/android-release-apks run: | set -euo pipefail version="$(node -p "require('./package.json').version")" out_dir="${{ runner.temp }}/openless-android-release-split" mkdir -p "$out_dir" expected=(arm64-v8a armeabi-v7a x86 x86_64) missing=() for abi in "${expected[@]}"; do src="$OPENLESS_STAGE_DIR/OpenLess_${version}_${abi}.apk" if [ ! -f "$src" ]; then # download-artifact may nest by artifact name src="$(find "$OPENLESS_STAGE_DIR" -type f -name "OpenLess_${version}_${abi}.apk" | head -n 1 || true)" fi if [ -z "${src:-}" ] || [ ! -f "$src" ]; then missing+=("$abi") continue fi cp -f "$src" "$out_dir/OpenLess_${version}_${abi}.apk" echo "Staged $abi from $src" done if [ "${#missing[@]}" -gt 0 ]; then echo "::error::Tag release missing ABI APK(s): ${missing[*]}" find "$OPENLESS_STAGE_DIR" -type f -name '*.apk' -print || true exit 1 fi echo "out_dir=$out_dir" >> "$GITHUB_OUTPUT" { echo "release_files<> "$GITHUB_OUTPUT" - name: Sign release APKs (minisign) working-directory: openless-all/app env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail out_dir="${{ steps.stage.outputs.out_dir }}" version="$(node -p "require('./package.json').version")" node scripts/sign-android-apks.mjs \ "$out_dir/OpenLess_${version}_arm64-v8a.apk" \ "$out_dir/OpenLess_${version}_armeabi-v7a.apk" \ "$out_dir/OpenLess_${version}_x86.apk" \ "$out_dir/OpenLess_${version}_x86_64.apk" - name: Write Android updater manifests working-directory: openless-all/app env: OPENLESS_UPDATE_APK_DIR: ${{ steps.stage.outputs.out_dir }} OPENLESS_UPDATE_REPO: Open-Less/openless OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} OPENLESS_RELEASE_TAG: ${{ github.ref_name }} run: | set -euo pipefail for arch in aarch64 armv7 i686 x86_64; do OPENLESS_UPDATE_TARGET=android OPENLESS_UPDATE_ARCH="$arch" \ node scripts/write-updater-manifest.mjs done - name: Append release files (manifests + signatures) id: release_assets shell: bash run: | set -euo pipefail out_dir="${{ steps.stage.outputs.out_dir }}" { echo "${{ steps.stage.outputs.release_files }}" for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json; do [ -e "$f" ] && echo "$f" done } > "$RUNNER_TEMP/android-release-files.txt" echo "files<> "$GITHUB_OUTPUT" cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" - name: Prepare Android release body shell: bash run: | cat > "$RUNNER_TEMP/android-release-body.md" << 'EOF' ### Android 安装说明 - **推荐**:真机下载 `OpenLess__arm64-v8a.apk`(arm64 设备)。 - **模拟器**:x86_64 模拟器用 `OpenLess__x86_64.apk`。 - 首次安装需在系统设置中允许「安装未知应用」;应用内更新会拉起系统安装器。 - adb 调试:`adb install -r OpenLess__arm64-v8a.apk` ### Android 应用内更新 - Stable 用户检查 `latest-android-aarch64.json`(及 mirror 变体)。 - Beta 用户(设置 → 高级 → 加入 Beta)检查带 `-beta` 后缀的 manifest。 EOF - name: Attach Android assets to GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: OpenLess ${{ github.ref_name }} # The desktop and Android jobs share one Beta draft. Publish it only # after all jobs and package verification have finished. draft: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} append_body: true body_path: ${{ runner.temp }}/android-release-body.md files: ${{ steps.release_assets.outputs.files }}