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) # # Scope: full overlay/accessibility APK for ADB testing and tag releases. on: push: tags: - 'v*-tauri' workflow_dispatch: jobs: build-android-apk: permissions: contents: write runs-on: ubuntu-latest 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: ${{ (endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }} steps: - uses: actions/checkout@v4 with: submodules: recursive - 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 }} 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" 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 $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." fi - name: Check updater signing availability (tag release) if: steps.mode.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: steps.mode.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: 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: 20 cache: npm cache-dependency-path: openless-all/app/package-lock.json - uses: dtolnay/rust-toolchain@stable with: targets: >- aarch64-linux-android, armv7-linux-androideabi, i686-linux-android, x86_64-linux-android - name: Cache Cargo uses: swatinem/rust-cache@v2 with: workspaces: openless-all/app/src-tauri -> target - uses: gradle/actions/setup-gradle@v4 - name: Install npm deps working-directory: openless-all/app run: npm ci - name: Build frontend working-directory: openless-all/app run: npm run build - 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 updater / install manifest working-directory: openless-all/app run: node scripts/merge-android-updater-manifest.mjs - name: Configure Android release signing if: steps.mode.outputs.mode == 'release' 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 }} 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: steps.mode.outputs.mode == 'debug' working-directory: openless-all/app run: npm run tauri:android:build:debug - name: Build Android release APK if: steps.mode.outputs.mode == 'release' working-directory: openless-all/app run: npm run tauri:android:build:release - name: Free disk before artifact upload shell: bash working-directory: openless-all/app run: | set -euo pipefail rm -rf src-tauri/target rm -rf ~/.cargo/registry ~/.cargo/git ~/.gradle/caches df -h - name: Collect split APKs id: apk shell: bash working-directory: openless-all/app env: OPENLESS_APK_MODE: ${{ steps.mode.outputs.mode }} OPENLESS_APK_LABEL: ${{ steps.mode.outputs.label }} run: | set -euo pipefail python - <<'PY' import json import os import shutil import sys import zipfile from pathlib import Path expected = { "arm64-v8a": "arm64_v8a", "armeabi-v7a": "armeabi_v7a", "x86": "x86", "x86_64": "x86_64", } arch_map = { "arm64-v8a": "aarch64", "armeabi-v7a": "armv7", "x86": "i686", "x86_64": "x86_64", } root = Path("src-tauri/gen/android") mode = os.environ["OPENLESS_APK_MODE"] label = os.environ["OPENLESS_APK_LABEL"] version = json.loads(Path("package.json").read_text(encoding="utf-8"))["version"] out_dir = Path(os.environ["RUNNER_TEMP"]) / f"openless-android-{mode}-split" out_dir.mkdir(parents=True, exist_ok=True) found = {} candidates = [ apk for apk in sorted(root.rglob("*.apk")) if "outputs" in apk.parts ] if not candidates: print("::error::No APK found under src-tauri/gen/android/**/outputs/") for apk in sorted(root.rglob("*.apk")): print(apk) sys.exit(1) for apk in candidates: with zipfile.ZipFile(apk) as archive: abis = sorted({ name.split("/")[1] for name in archive.namelist() if name.startswith("lib/") and len(name.split("/")) >= 3 }) if len(abis) != 1: print(f"::error::{apk} contains ABI directories {abis}; expected exactly one ABI per APK") sys.exit(1) abi = abis[0] if abi not in expected: print(f"::error::{apk} contains unexpected ABI {abi}") sys.exit(1) if abi in found: print(f"::error::Duplicate APKs for ABI {abi}: {found[abi]} and {apk}") sys.exit(1) if mode == "release": dest = out_dir / f"OpenLess_{version}_{abi}.apk" else: dest = out_dir / f"OpenLess-android-debug-{abi}-{label}.apk" shutil.copy2(apk, dest) found[abi] = dest print(f"Collected {abi}: {apk} -> {dest}") missing = sorted(set(expected) - set(found)) if missing: print(f"::error::Missing split APKs for ABI(s): {', '.join(missing)}") sys.exit(1) with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: for abi, key in expected.items(): output.write(f"{key}_path={found[abi]}\n") output.write(f"{key}_arch={arch_map[abi]}\n") output.write(f"out_dir={out_dir}\n") output.write("release_files< "$RUNNER_TEMP/android-release-files.txt" echo "files<> "$GITHUB_OUTPUT" cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" - name: Upload Android APK artifact (arm64-v8a) uses: actions/upload-artifact@v4 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-arm64-v8a' || 'openless-android-debug-arm64-v8a' }} path: ${{ steps.apk.outputs.arm64_v8a_path }} if-no-files-found: error - name: Upload Android APK artifact (armeabi-v7a) uses: actions/upload-artifact@v4 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-armeabi-v7a' || 'openless-android-debug-armeabi-v7a' }} path: ${{ steps.apk.outputs.armeabi_v7a_path }} if-no-files-found: error - name: Upload Android APK artifact (x86) uses: actions/upload-artifact@v4 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86' || 'openless-android-debug-x86' }} path: ${{ steps.apk.outputs.x86_path }} if-no-files-found: error - name: Upload Android APK artifact (x86_64) uses: actions/upload-artifact@v4 with: name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86_64' || 'openless-android-debug-x86_64' }} path: ${{ steps.apk.outputs.x86_64_path }} if-no-files-found: error - name: Prepare Android release body if: steps.mode.outputs.is_tag_release == 'true' 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 if: steps.mode.outputs.is_tag_release == 'true' uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: OpenLess ${{ github.ref_name }} draft: false prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} append_body: true body_path: ${{ runner.temp }}/android-release-body.md files: ${{ steps.release_assets.outputs.files }} - name: Write build summary if: always() && steps.mode.outputs.signing != '' run: | cat >> "$GITHUB_STEP_SUMMARY" << EOF ### Android APK build | Field | Value | |-------|-------| | Gradle mode | \`${{ steps.mode.outputs.mode }}\` | | Signing | ${{ steps.mode.outputs.signing_label }} | | Artifact label | \`${{ steps.mode.outputs.label }}\` | EOF if [ "${{ steps.mode.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 [ "${{ steps.mode.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 fi