diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 007564c86..624e7e97b 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -5,13 +5,27 @@ name: Android APK (debug) # - 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. +# #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 # 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。 concurrency: @@ -19,25 +33,27 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'push' }} jobs: - build-android-apk: + plan: + name: Plan Android ABI matrix permissions: - contents: write + contents: read 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' }} + 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: - # Android 不使用本地 Qwen3/Whisper C 子模块。 submodules: false - - name: Disable macOS-only Qwen3 MLX dependency - run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs - - - name: Detect build mode id: mode shell: bash @@ -54,6 +70,12 @@ jobs: 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 @@ -80,8 +102,58 @@ jobs: 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: 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 不使用本地 Qwen3/Whisper C 子模块。 + 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: steps.mode.outputs.is_tag_release == 'true' + if: needs.plan.outputs.is_tag_release == 'true' shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -92,7 +164,7 @@ jobs: fi - name: Check Android keystore secrets (tag release) - if: steps.mode.outputs.is_tag_release == 'true' + if: needs.plan.outputs.is_tag_release == 'true' shell: bash env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} @@ -112,6 +184,16 @@ jobs: 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: @@ -152,26 +234,28 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: - targets: >- - aarch64-linux-android, - armv7-linux-androideabi, - i686-linux-android, - x86_64-linux-android + 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 - - name: Build frontend - working-directory: openless-all/app - run: npm run build + # Frontend is built by tauri beforeBuildCommand; skip a duplicate standalone build (#1103). - name: Initialize Android project working-directory: openless-all/app @@ -202,7 +286,7 @@ jobs: run: node scripts/merge-android-updater-manifest.mjs - name: Configure Android release signing - if: steps.mode.outputs.mode == 'release' + if: needs.plan.outputs.mode == 'release' working-directory: openless-all/app env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} @@ -227,127 +311,161 @@ jobs: exit 1 - name: Build Android debug APK - if: steps.mode.outputs.mode == 'debug' + if: needs.plan.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' + if: needs.plan.outputs.mode == 'release' working-directory: openless-all/app run: npm run tauri:android:build:release - - name: Free disk before artifact upload + - name: Record cache / duplicate-compile notes + if: always() 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 - + { + 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: ${{ steps.mode.outputs.mode }} - OPENLESS_APK_LABEL: ${{ steps.mode.outputs.label }} + 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 + 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 - 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<> "$GITHUB_OUTPUT" + { + echo "release_files<> "$GITHUB_OUTPUT" - name: Sign release APKs (minisign) - if: steps.mode.outputs.is_tag_release == 'true' 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 \ - "${{ steps.apk.outputs.arm64_v8a_path }}" \ - "${{ steps.apk.outputs.armeabi_v7a_path }}" \ - "${{ steps.apk.outputs.x86_path }}" \ - "${{ steps.apk.outputs.x86_64_path }}" + "$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 - if: steps.mode.outputs.is_tag_release == 'true' working-directory: openless-all/app env: - OPENLESS_UPDATE_APK_DIR: ${{ steps.apk.outputs.out_dir }} + 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 }} @@ -360,14 +478,13 @@ jobs: done - name: Append release files (manifests + signatures) - if: steps.mode.outputs.is_tag_release == 'true' id: release_assets shell: bash run: | set -euo pipefail - out_dir="${{ steps.apk.outputs.out_dir }}" + out_dir="${{ steps.stage.outputs.out_dir }}" { - echo "${{ steps.apk.outputs.release_files }}" + echo "${{ steps.stage.outputs.release_files }}" for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json; do [ -e "$f" ] && echo "$f" done @@ -376,36 +493,7 @@ jobs: 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' @@ -423,7 +511,6 @@ jobs: 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 }} @@ -435,26 +522,3 @@ jobs: 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 diff --git a/docs/android-build-time-research-1103.md b/docs/android-build-time-research-1103.md new file mode 100644 index 000000000..86f8b8ddd --- /dev/null +++ b/docs/android-build-time-research-1103.md @@ -0,0 +1,38 @@ +# Android APK 编译耗时调研(#1103) + +调研日期:2026-09-25。基线:上游 beta `d9113e0b03c0b5998079d5f43dcb33fb8bba50e7`。仅调研,未修改构建实现。 + +## 实测证据 + +- [tag run 35989822568](https://github.com/Open-Less/openless/actions/runs/35989822568):job 32 分 32 秒;Build Android release APK 30 分 31 秒。 +- Cargo 完成记录为 6m01s、2m14s、5m11s、5m27s、5m37s。前两轮都为 aarch64,第二轮重新编译应用及部分 Tauri crates。重复编译事实已确认,失效原因尚未确认。 +- [同 SHA 的 beta dispatch 35985550453](https://github.com/Open-Less/openless/actions/runs/35985550453)也有五轮编译;arm64 第二轮 2m15s。 +- tag Gradle 日志明确 cache-read-only=true;仓库默认分支为 beta,beta dispatch 为 false。不能用 post 为零秒单独证明清盘阻止保存。 +- beta dispatch 在清盘后仍保存约 171 MB Rust cache,随后 tag 命中同一缓存。这不证明 target 编译产物被保留:workflow 明确提前删除 target、Cargo registry/git 和 Gradle caches。 +- 独立前端构建约 15 秒;Tauri beforeBuildCommand 随后再次执行 npm run build。 + +## 建议顺序 + +1. 修复清盘与缓存生命周期,并更换缓存版本键,避免继续命中已有残缺缓存;验证连续两次运行的实际缓存内容及编译耗时。post action 在普通 steps 之后执行,仅把清盘移动到普通 steps 末尾无效。 +2. 日常 dispatch 可选 ABI,默认 arm64;正式 tag 保留全 ABI。进一步使用 ABI matrix 降低全量墙钟,分别衡量总 runner 分钟和排队时间;发布资产集中汇总,校验 ABI 完整性。 +3. 记录 Cargo timings/fingerprint 和两轮 arm64 调用参数、环境、生成文件差异,定位重复编译。不要直接绕过 Tauri/Gradle native 构建。 +4. 当前 release 使用 opt-level=3、thin LTO、codegen-units=1。试验仅 dispatch 的快速 profile:关闭 LTO、提高 codegen-units,同时保持签名;单独比较 APK 大小、运行表现和耗时。正式 tag 优化配置暂不改变。 +5. ci-disable-macos-qwen3.mjs 每次删除平台依赖后 cargo generate-lockfile。应研究保留已锁定版本的确定性处理,避免无关依赖升级及缓存键变化;stable Rust 也应考虑固定版本并有计划升级。 +6. Cargo timings 若表明依赖或主 crate 占比高,再审计 Android 不使用的依赖/features、缩小重编译边界。现有 openless-core 可作为评估起点,不能未经测量就大改架构。 +7. 次要优化:删除重复前端构建,验证 Gradle task-output cache,更强的 x64 runner、预置工具链环境,以及 artifact 压缩参数。现有四份上传合计约 14 秒,优先级低。 + +## 限制与官方资料 + +- [Cargo profiles](https://doc.rust-lang.org/cargo/reference/profiles.html):LTO/codegen-units 是构建速度与产物表现之间的取舍,不能承诺未经实测的收益。 +- [rust-cache](https://github.com/Swatinem/rust-cache):默认排除 workspace crates、清理 incremental;缓存命中不等于应用无需重编译。 +- [sccache Rust](https://github.com/mozilla/sccache/blob/main/docs/Rust.md):不能缓存涉及系统链接的 cdylib 等输出,因此不是 Tauri 主库的万能缓存;可单独评估依赖缓存收益。 +- [Gradle Actions v4](https://github.com/gradle/actions/blob/v4/docs/setup-gradle.md)与[GitHub cache scope](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching):默认分支写缓存与 ref 可见性应一并设计。 +- [Gradle build cache](https://docs.gradle.org/current/userguide/build_cache.html):任务输出缓存和依赖缓存不同;仅缓存输入输出定义完整的任务。 +- [Android NDK host](https://developer.android.com/ndk/guides/other_build_systems):Linux 官方 NDK 使用 x86_64 host 工具链,不能因为目标 APK 是 arm64 就直接换 Linux arm64 runner。 +- [Tauri CLI](https://v2.tauri.app/reference/cli/):Android build 会执行 beforeBuildCommand。 + +尚未运行优化版本 CI,因此所有优化收益均为待验证假设。基准应覆盖冷缓存、相同依赖下的源码改动、依赖变更,以及正式全 ABI 构建;同时检查签名、APK ABI 和 updater manifest 完整性。 + +## 实现备注(#1103 跟进) + +重复 aarch64 根因已定位:Tauri CLI `android build` 在 `apk::build` 之前会对**第一个** target 调用 `first_target.build(...)`(注释为 initialize plugins),随后 Gradle 再对每个 ABI 执行 `tauri android android-studio-script`。因此首个 ABI 必然两次 cargo;第二轮约 2m 是因为 `write_options` / `inject_resources` 发生在首次编译之后,指纹变脏。不要绕过 Gradle/native 路径;用单 ABI dispatch + 全 ABI matrix 并行降低墙钟。 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 0207a0ed5..26fb8e20e 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -9447,7 +9447,19 @@ mod tests { } } backend.cancel_dictation(Some(first)).await.unwrap(); - let second = backend.start_dictation().await.unwrap(); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match backend.start_dictation().await { + Ok(id) => break id, + Err(error) if error.code == BackendErrorCode::Busy => { + tokio::task::yield_now().await; + } + Err(error) => panic!("unexpected start failure: {error}"), + } + } + }) + .await + .expect("successor dictation should become available after cancel"); release_guard.release(); settings.join().unwrap().unwrap(); stopping.await.unwrap().unwrap(); diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index b716156bf..2b8d7048b 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -2474,6 +2474,39 @@ mod linux_app { }); } + /// Phone-input status panel used by the CA fingerprint contract test. + /// Keep the fingerprint copy aligned with the desktop Remote Input settings. + fn remote_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("手机输入"); + if let Some((remote, _pin)) = &self.remote_access { + if remote.enabled && remote.running && !remote.urls_stale { + // 首次信任前必须核对根证书指纹:网页与描述文件名称不能证明身份。 + ui.label("本机根证书 SHA-256"); + match remote.ca_fingerprint_sha256.as_ref().filter(|value| { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + Some(fingerprint) => { + let display = fingerprint + .as_bytes() + .chunks(2) + .map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase()) + .collect::>() + .join(" "); + ui.add( + egui::Label::new(egui::RichText::new(&display).monospace()).wrap(), + ); + if ui.button("复制完整指纹").clicked() { + ui.ctx().copy_text(display); + } + } + None => { + ui.label("完整指纹不可用。请勿安装或信任下载的证书。"); + } + } + } + } + } + fn provider_management_ui(&mut self, ui: &mut egui::Ui) { let lang = self.lang; ui.horizontal(|ui| { @@ -5457,6 +5490,41 @@ mod linux_app { mod tests { use super::*; + fn disconnected_app() -> OpenLessEguiApp { + OpenLessEguiApp::new( + Arc::new(tokio::runtime::Runtime::new().unwrap()), + Err("fixture: plugin unavailable".into()), + None, + LinuxUpdateSupport::ManualOnly { + releases_url: openless_linux_egui::RELEASES_URL, + }, + ) + } + + fn rendered_text(mut draw: impl FnMut(&mut egui::Ui)) -> String { + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(720.0, 1800.0), + )), + ..Default::default() + }); + egui::CentralPanel::default().show(&ctx, |ui| { + draw(ui); + }); + let output = ctx.end_pass(); + output + .shapes + .into_iter() + .filter_map(|clipped| match clipped.shape { + egui::epaint::Shape::Text(text) => Some(text.galley.text().to_owned()), + _ => None, + }) + .collect::>() + .join("\n") + } + #[test] fn running_remote_status_shows_ca_fingerprint_or_unavailable_warning() { let mut app = disconnected_app(); diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 34c2f4e17..b62b3cc74 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -14,9 +14,10 @@ "tauri": "tauri", "tauri:android:init": "tauri android init", "tauri:android:dev": "tauri android dev", - "tauri:android:build": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi", - "tauri:android:build:debug": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi", - "tauri:android:build:release": "tauri android build --apk --target aarch64 armv7 i686 x86_64 --split-per-abi", + "tauri:android:build": "node scripts/run-android-tauri-build.mjs --debug", + "tauri:android:build:debug": "node scripts/run-android-tauri-build.mjs --debug", + "tauri:android:build:release": "node scripts/run-android-tauri-build.mjs --release", + "check:android-apk-workflow": "node scripts/android-apk-workflow-contract.test.mjs", "merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs", "merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs", "merge:android-shizuku-manifest": "node scripts/merge-android-shizuku-manifest.mjs", diff --git a/openless-all/app/scripts/android-abi-matrix.mjs b/openless-all/app/scripts/android-abi-matrix.mjs new file mode 100644 index 000000000..e4c0b30cf --- /dev/null +++ b/openless-all/app/scripts/android-abi-matrix.mjs @@ -0,0 +1,123 @@ +/** + * Android ABI matrix helpers for CI (#1103). + * + * CLI target names match `tauri android build --target`. + * Gradle ABI folder names match APK lib/ layout. + */ + +export const ANDROID_ABI_MATRIX = [ + { + abi: 'aarch64', + rustTarget: 'aarch64-linux-android', + gradleAbi: 'arm64-v8a', + outputKey: 'arm64_v8a', + }, + { + abi: 'armv7', + rustTarget: 'armv7-linux-androideabi', + gradleAbi: 'armeabi-v7a', + outputKey: 'armeabi_v7a', + }, + { + abi: 'i686', + rustTarget: 'i686-linux-android', + gradleAbi: 'x86', + outputKey: 'x86', + }, + { + abi: 'x86_64', + rustTarget: 'x86_64-linux-android', + gradleAbi: 'x86_64', + outputKey: 'x86_64', + }, +]; + +const BY_ABI = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.abi, entry])); +const BY_GRADLE = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.gradleAbi, entry])); + +export function entryForAbi(abi) { + const entry = BY_ABI.get(abi); + if (!entry) { + throw new Error( + `Unknown Android ABI "${abi}". Expected one of: ${ANDROID_ABI_MATRIX.map((e) => e.abi).join(', ')}`, + ); + } + return entry; +} + +export function entryForGradleAbi(gradleAbi) { + const entry = BY_GRADLE.get(gradleAbi); + if (!entry) { + throw new Error(`Unknown Gradle ABI "${gradleAbi}"`); + } + return entry; +} + +/** + * Parse a comma/space-separated ABI list or the keyword "all". + * Empty / whitespace → defaultAbis. + */ +export function parseAndroidAbis(raw, { defaultAbis = ['aarch64'] } = {}) { + const text = (raw ?? '').trim(); + if (!text) { + return defaultAbis.map(entryForAbi); + } + if (text.toLowerCase() === 'all') { + return [...ANDROID_ABI_MATRIX]; + } + const tokens = text + .split(/[,\s]+/) + .map((t) => t.trim()) + .filter(Boolean); + if (tokens.length === 0) { + return defaultAbis.map(entryForAbi); + } + const seen = new Set(); + const out = []; + for (const token of tokens) { + const entry = entryForAbi(token); + if (seen.has(entry.abi)) continue; + seen.add(entry.abi); + out.push(entry); + } + return out; +} + +/** GitHub Actions matrix.include payload for selected ABIs. */ +export function toGithubMatrixInclude(entries) { + return entries.map((entry) => ({ + abi: entry.abi, + rust_target: entry.rustTarget, + gradle_abi: entry.gradleAbi, + output_key: entry.outputKey, + })); +} + +function main() { + const mode = process.argv[2] || 'parse'; + if (mode === 'parse') { + const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? ''; + const defaultRaw = process.env.OPENLESS_ANDROID_ABIS_DEFAULT ?? 'aarch64'; + const entries = parseAndroidAbis(raw, { + defaultAbis: parseAndroidAbis(defaultRaw, { defaultAbis: ['aarch64'] }).map((e) => e.abi), + }); + process.stdout.write(JSON.stringify(toGithubMatrixInclude(entries))); + return; + } + if (mode === 'list-all') { + process.stdout.write(JSON.stringify(toGithubMatrixInclude(ANDROID_ABI_MATRIX))); + return; + } + if (mode === 'rust-targets') { + const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? 'all'; + const entries = parseAndroidAbis(raw, { defaultAbis: ANDROID_ABI_MATRIX.map((e) => e.abi) }); + process.stdout.write(entries.map((e) => e.rustTarget).join(',')); + return; + } + console.error(`Usage: node android-abi-matrix.mjs [abis]`); + process.exit(1); +} + +if (process.argv[1]?.replace(/\\/g, '/').endsWith('android-abi-matrix.mjs')) { + main(); +} diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs new file mode 100644 index 000000000..7c47ec549 --- /dev/null +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + readFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { + ANDROID_ABI_MATRIX, + parseAndroidAbis, + toGithubMatrixInclude, + entryForAbi, +} from './android-abi-matrix.mjs'; +import { collectSplitApks } from './collect-android-split-apks.mjs'; + +const scriptDir = fileURLToPath(new URL('.', import.meta.url)); +const workflowPath = fileURLToPath( + new URL('../../../.github/workflows/android-apk.yml', import.meta.url), +); + +// --- ABI matrix --- +assert.equal(parseAndroidAbis('').map((e) => e.abi).join(','), 'aarch64'); +assert.equal(parseAndroidAbis('all').length, 4); +assert.equal(parseAndroidAbis('aarch64,armv7').map((e) => e.abi).join(','), 'aarch64,armv7'); +assert.equal( + parseAndroidAbis('aarch64 aarch64,armv7').map((e) => e.abi).join(','), + 'aarch64,armv7', +); +assert.throws(() => parseAndroidAbis('riscv'), /Unknown Android ABI/); +assert.equal(entryForAbi('x86_64').gradleAbi, 'x86_64'); +assert.equal(toGithubMatrixInclude(ANDROID_ABI_MATRIX)[0].rust_target, 'aarch64-linux-android'); + +const cli = spawnSync( + process.execPath, + [join(scriptDir, 'android-abi-matrix.mjs'), 'parse', 'aarch64'], + { encoding: 'utf8' }, +); +assert.equal(cli.status, 0); +assert.equal(JSON.parse(cli.stdout)[0].gradle_abi, 'arm64-v8a'); + +// --- collectSplitApks with a minimal zip APK --- +function writeMinimalApk(path, abi) { + const fileName = Buffer.from(`lib/${abi}/libdummy.so`, 'utf8'); + const data = Buffer.from('so'); + const local = Buffer.alloc(30 + fileName.length + data.length); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0, 12); + local.writeUInt32LE(0, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(fileName.length, 26); + local.writeUInt16LE(0, 28); + fileName.copy(local, 30); + data.copy(local, 30 + fileName.length); + + const central = Buffer.alloc(46 + fileName.length); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0, 14); + central.writeUInt32LE(0, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(fileName.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(0, 42); + fileName.copy(central, 46); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(central.length, 12); + eocd.writeUInt32LE(local.length, 16); + eocd.writeUInt16LE(0, 20); + + writeFileSync(path, Buffer.concat([local, central, eocd])); +} + +const tmp = mkdtempSync(join(tmpdir(), 'openless-apk-collect-')); +try { + const androidRoot = join(tmp, 'android'); + const outDir = join(tmp, 'out'); + const apkDir = join(androidRoot, 'app', 'build', 'outputs', 'apk', 'release'); + mkdirSync(apkDir, { recursive: true }); + writeMinimalApk(join(apkDir, 'app-arm64-v8a-release.apk'), 'arm64-v8a'); + const { outputs } = collectSplitApks({ + mode: 'release', + label: 'test', + expectedGradleAbis: ['arm64-v8a'], + androidRoot, + outDir, + version: '9.9.9', + }); + assert.match(outputs.arm64_v8a_path.replace(/\\/g, '/'), /OpenLess_9\.9\.9_arm64-v8a\.apk$/); + assert.equal(outputs.arm64_v8a_arch, 'aarch64'); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} + +// --- workflow contract (#1103) --- +const workflow = readFileSync(workflowPath, 'utf8'); +assert.match(workflow, /prefix-key:\s*v1-rust-android-1103/); +assert.doesNotMatch(workflow, /Free disk before artifact upload/); +assert.doesNotMatch(workflow, /rm -rf src-tauri\/target/); +assert.doesNotMatch(workflow, /rm -rf ~\/\.cargo\/registry/); +assert.doesNotMatch(workflow, /rm -rf ~\/\.gradle\/caches/); +assert.match(workflow, /abis:/); +assert.match(workflow, /default:\s*['"]aarch64['"]/); +assert.match(workflow, /fast_profile:/); +assert.match(workflow, /strategy:[\s\S]*matrix:/); +assert.match(workflow, /OPENLESS_ANDROID_TARGETS/); +assert.match(workflow, /CARGO_PROFILE_RELEASE_LTO/); +assert.match(workflow, /first ABI may compile twice|android-studio-script/); +assert.match(workflow, /publish-android-release/); +assert.match(workflow, /download-artifact/); +assert.match(workflow, /Rust cache/); + +console.log('android-apk-workflow-contract checks passed'); diff --git a/openless-all/app/scripts/collect-android-split-apks.mjs b/openless-all/app/scripts/collect-android-split-apks.mjs new file mode 100644 index 000000000..b17271f6e --- /dev/null +++ b/openless-all/app/scripts/collect-android-split-apks.mjs @@ -0,0 +1,206 @@ +/** + * Collect split-per-ABI APKs from gen/android outputs. + * + * Env: + * OPENLESS_APK_MODE debug|release + * OPENLESS_APK_LABEL artifact label suffix + * OPENLESS_EXPECTED_GRADLE_ABIS comma-separated Gradle ABI folders (required) + * RUNNER_TEMP output parent (required in CI) + * GITHUB_OUTPUT optional; writes paths when set + */ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join, relative } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { entryForGradleAbi } from './android-abi-matrix.mjs'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const KNOWN_ABIS = new Set(['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']); + +function walkApks(root) { + const out = []; + if (!existsSync(root)) return out; + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + for (const name of readdirSync(dir)) { + const path = join(dir, name); + const st = statSync(path); + if (st.isDirectory()) { + stack.push(path); + } else if (name.endsWith('.apk')) { + out.push(path); + } + } + } + return out.sort(); +} + +function listApkAbis(apkPath) { + const abis = new Set(); + const unzip = spawnSync('unzip', ['-Z1', apkPath], { + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }); + if (unzip.status === 0 && unzip.stdout) { + for (const line of unzip.stdout.split(/\r?\n/)) { + if (!line.startsWith('lib/')) continue; + const abi = line.split('/')[1]; + if (KNOWN_ABIS.has(abi)) abis.add(abi); + } + return [...abis].sort(); + } + + // Fallback when unzip is unavailable (local Windows): scan zip central-directory names. + const buf = readFileSync(apkPath); + let offset = 0; + while (offset + 46 < buf.length) { + if (buf.readUInt32LE(offset) !== 0x02014b50) { + offset += 1; + continue; + } + const nameLen = buf.readUInt16LE(offset + 28); + const extraLen = buf.readUInt16LE(offset + 30); + const commentLen = buf.readUInt16LE(offset + 32); + const nameStart = offset + 46; + const nameEnd = nameStart + nameLen; + if (nameEnd > buf.length) break; + const name = buf.toString('utf8', nameStart, nameEnd); + if (name.startsWith('lib/')) { + const abi = name.split('/')[1]; + if (KNOWN_ABIS.has(abi)) abis.add(abi); + } + offset = nameEnd + extraLen + commentLen; + } + return [...abis].sort(); +} + +export function collectSplitApks({ + mode, + label, + expectedGradleAbis, + androidRoot = join(appRoot, 'src-tauri/gen/android'), + outDir, + version, +} = {}) { + if (!mode || !label) { + throw new Error('mode and label are required'); + } + if (!expectedGradleAbis?.length) { + throw new Error('expectedGradleAbis must be a non-empty array'); + } + if (!outDir) { + throw new Error('outDir is required'); + } + if (!version) { + throw new Error('version is required'); + } + + mkdirSync(outDir, { recursive: true }); + const candidates = walkApks(androidRoot).filter((apk) => apk.replace(/\\/g, '/').includes('/outputs/')); + if (candidates.length === 0) { + const all = walkApks(androidRoot); + const hint = all.length ? all.map((p) => relative(androidRoot, p)).join('\n') : '(none)'; + throw new Error(`No APK found under ${androidRoot}/**/outputs/\nOther APKs:\n${hint}`); + } + + const expected = new Set(expectedGradleAbis); + const found = new Map(); + + for (const apk of candidates) { + const abis = listApkAbis(apk); + if (abis.length !== 1) { + throw new Error(`${apk} contains ABI directories [${abis.join(', ')}]; expected exactly one ABI per APK`); + } + const abi = abis[0]; + if (!expected.has(abi)) { + // Ignore extras from previous local builds; only enforce expected set. + console.warn(`Skipping unexpected ABI ${abi} from ${apk}`); + continue; + } + if (found.has(abi)) { + throw new Error(`Duplicate APKs for ABI ${abi}: ${found.get(abi)} and ${apk}`); + } + const destName = + mode === 'release' + ? `OpenLess_${version}_${abi}.apk` + : `OpenLess-android-debug-${abi}-${label}.apk`; + const dest = join(outDir, destName); + copyFileSync(apk, dest); + found.set(abi, dest); + console.log(`Collected ${abi}: ${apk} -> ${dest}`); + } + + const missing = [...expected].filter((abi) => !found.has(abi)).sort(); + if (missing.length) { + throw new Error(`Missing split APKs for ABI(s): ${missing.join(', ')}`); + } + + const releaseFiles = [...expected].map((abi) => found.get(abi)); + const outputs = {}; + for (const abi of expected) { + const entry = entryForGradleAbi(abi); + outputs[`${entry.outputKey}_path`] = found.get(abi); + outputs[`${entry.outputKey}_arch`] = entry.abi; + } + outputs.out_dir = outDir; + outputs.release_files = releaseFiles.join('\n'); + // Single-ABI CI jobs consume these stable keys. + if (expected.size === 1) { + const onlyAbi = [...expected][0]; + const entry = entryForGradleAbi(onlyAbi); + outputs.apk_path = found.get(onlyAbi); + outputs.gradle_abi = onlyAbi; + outputs.cli_abi = entry.abi; + } + return { found, outputs, releaseFiles }; +} + +function main() { + const mode = process.env.OPENLESS_APK_MODE; + const label = process.env.OPENLESS_APK_LABEL; + const expectedRaw = process.env.OPENLESS_EXPECTED_GRADLE_ABIS || ''; + const expectedGradleAbis = expectedRaw + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter(Boolean); + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp) { + throw new Error('RUNNER_TEMP is required'); + } + const version = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')).version; + const outDir = join(runnerTemp, `openless-android-${mode}-split`); + const { outputs } = collectSplitApks({ + mode, + label, + expectedGradleAbis, + outDir, + version, + }); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + const lines = []; + for (const [key, value] of Object.entries(outputs)) { + if (key === 'release_files') { + lines.push(`${key}< e.abi); + } + return parseAndroidAbis(raw, { + defaultAbis: ANDROID_ABI_MATRIX.map((e) => e.abi), + }).map((e) => e.abi); +} + +function main() { + const mode = process.argv[2]; + if (mode !== '--debug' && mode !== '--release') { + console.error('Usage: node scripts/run-android-tauri-build.mjs --debug|--release'); + process.exit(1); + } + const targets = resolveTargets(); + const args = ['tauri', 'android', 'build', '--apk', '--split-per-abi', '--target', ...targets]; + if (mode === '--debug') { + args.push('--debug'); + } + + console.log(`[android-build] targets=${targets.join(',')} mode=${mode.slice(2)}`); + // #1103: Tauri CLI runs an initial cargo build for the first target to + // "initialize plugins", then Gradle invokes `android-studio-script` per ABI + // (including the first). Expect two cargo passes for the first ABI; do not + // bypass the Gradle/native path. + console.log( + '[android-build] note: first ABI may compile twice (Tauri plugin init + android-studio-script)', + ); + + const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + const result = spawnSync(bin, args, { + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', + }); + if (result.error) { + throw result.error; + } + process.exit(result.status ?? 1); +} + +main();