Files
openless/.github/workflows/android-apk.yml
T
Jimmy LeeandChris233 42c1b31754 本地模型增加whisper相关模型的支持; 增加qwen3-asr的rust版本, 相比C版本速度更快 (#940)
* feat: add selectable local ASR backends

* fix(build): 修复本地 ASR 多平台构建与旧渠道兼容

* fix(asr): 修复 MLX 权重跨线程 stream 错误

* fix(ci): exclude MLX Qwen3 from non-macOS jobs

* fix(ci): use app-relative ASR submodule path

* fix(android): skip qwen-asr C backend

* fix(android): guard qwen-asr by target triple

* fix(ci): 修正语法错误

* fix(ci): remove invalid checkout step options

* fix(asr): harden local backend integration

* fix(asr): 处理 PR #940 审查发现(9 项)

- strip: 全局保留 symbols,macOS release profile 单独退回 debuginfo(Homebrew rustc 的 LINKEDIT 问题仅限 macOS)
- 转录超时改为驱逐引擎而非无效 cancel():spawn_blocking 解码任务无法被中止,驱逐后下次会话加载新引擎而非排队
- Turbo/Q5 共享目录模型在 is_downloaded/downloaded_bytes/delete_model 上互相认账
- MLX 临时 WAV 用 RAII guard 兜底清理;HF tree API 改 Link rel=next 游标分页(offset 被服务器静默忽略),带防御上限与 5 个单测
- Intel Mac 渠道下拉不再闪现 MLX 预设;MetalToolchainGuide 默认折叠并标注仅源码构建期要求
- qwen3-asr-rs 子模块 URL 与 gitlink 指向 Open-Less org 镜像,嵌套 mlx-c 同步改指 org(供应链 N-6)
- README/注释补充:所有平台源码构建均需递归初始化子模块(path 依赖跨 target 解析)

* fix: harden local whisper model lifecycle and downloads

* fix(asr): address PR 940 review findings

* fix(ci): add local ASR model stub to backend tests

* fix(linux): bundle and sync fcitx5 selection plugin

* fix(macos): avoid duplicate whisper test module

* fix(history): make detail sibling keys unique

* fix(ci): stub macos whisper model constant

* test(ci): avoid macOS streaming timeout race

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
2026-08-20 20:22:02 +08:00

459 lines
18 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:
# 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' }}
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:
# 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
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: "22"
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 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: 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<<EOF\n")
for abi in expected:
output.write(f"{found[abi]}\n")
output.write("EOF\n")
PY
- 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
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 }}"
- 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_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)
if: steps.mode.outputs.is_tag_release == 'true'
id: release_assets
shell: bash
run: |
set -euo pipefail
out_dir="${{ steps.apk.outputs.out_dir }}"
{
echo "${{ steps.apk.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<<EOF" >> "$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_<version>_arm64-v8a.apk`(arm64 设备)。
- **模拟器**:x86_64 模拟器用 `OpenLess_<version>_x86_64.apk`。
- 首次安装需在系统设置中允许「安装未知应用」;应用内更新会拉起系统安装器。
- adb 调试:`adb install -r OpenLess_<version>_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