From fbbb8c9e2cf7785afea53d4a2a72bd679f115fdc Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sat, 10 Oct 2026 15:10:03 +0800 Subject: [PATCH] [fix] align Android versions with release tags, refs #107 --- .github/workflows/build-android.yml | 82 +++++++++++++++++-------- .github/workflows/build.yml | 2 + apps/android/README.md | 14 ++--- apps/android/app/build.gradle | 4 +- apps/android/gradle.properties | 2 + apps/android/scripts/release_version.py | 67 ++++++++++++++++++++ apps/android/test/verify_apk.py | 27 +++++++- 7 files changed, 161 insertions(+), 37 deletions(-) create mode 100644 apps/android/scripts/release_version.py diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 0a82c645..34886671 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -2,6 +2,12 @@ name: Build Android on: workflow_dispatch: + inputs: + patch: + description: "Optional patch number, matching the main build workflow." + required: false + type: string + default: "0" workflow_call: inputs: source_tag: @@ -9,9 +15,14 @@ on: required: false type: string default: "" + patch: + description: "Optional patch number from the main build workflow." + required: false + type: string + default: "0" outputs: version: - description: "Independent Android version and build date." + description: "Android artifact version; combined releases match the desktop tag." value: ${{ jobs.build.outputs.version }} pull_request: paths: @@ -147,51 +158,70 @@ jobs: - name: Verify shared license catalog run: python3 -B apps/ios/scripts/generate_third_party_licenses.py + - name: Resolve Android version + shell: python + env: + PATCH_NUMBER: ${{ inputs.patch || '0' }} + run: | + import datetime + import os + from pathlib import Path + import re + import runpy + + resolver = Path("apps/android/scripts/release_version.py") + if resolver.exists(): + runpy.run_path(str(resolver), run_name="__main__") + else: + # Older standalone releases predate the resolver and Gradle properties. + # Keep their original package version when retrying an existing tag. + tag = os.environ["CROSSDESK_SOURCE_TAG"] + match = re.fullmatch(r"android-v([0-9]+\.[0-9]+\.[0-9]+)-([0-9]{8})", tag) + config = Path("apps/android/app/build.gradle").read_text() + name = re.search(r"versionName\s+'([^']+)'", config) + code = re.search(r"versionCode\s+([0-9]+)", config) + if not match or not name or not code or match[1] != name[1]: + raise ValueError("Legacy Android sources require a matching standalone release tag") + datetime.datetime.strptime(match[2], "%Y%m%d") + with open(os.environ["GITHUB_ENV"], "a") as stream: + stream.write(f"ANDROID_VERSION_NAME={name[1]}\nANDROID_VERSION_CODE={code[1]}\n" + f"ANDROID_ARTIFACT_VERSION={tag.removeprefix('android-')}\n") + - name: Build APKs working-directory: apps/android run: | ./gradlew --no-daemon --stacktrace \ + -PcrossdeskVersionName="${ANDROID_VERSION_NAME}" \ + -PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \ :app:assembleDebug :app:assembleRelease - name: Verify APKs working-directory: apps/android run: | - python3 -B test/verify_apk.py app/build/outputs/apk/debug/app-debug.apk - python3 -B test/verify_apk.py app/build/outputs/apk/release/app-release-unsigned.apk + for APK in app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/release/app-release-unsigned.apk; do + python3 -B test/verify_apk.py "${APK}" \ + --aapt2 "${ANDROID_HOME}/build-tools/36.0.0/aapt2" \ + --version-name "${ANDROID_VERSION_NAME}" --version-code "${ANDROID_VERSION_CODE}" + done "${ANDROID_HOME}/build-tools/36.0.0/apksigner" verify app/build/outputs/apk/debug/app-debug.apk - - name: Package APKs with the independent Android version + - name: Package APKs with the resolved Android version id: package shell: python run: | - import datetime import json import os from pathlib import Path - import re import shutil - from zoneinfo import ZoneInfo apk_dir = Path("apps/android/app/build/outputs/apk") - metadata = json.loads((apk_dir / "release/output-metadata.json").read_text()) - version = metadata["elements"][0]["versionName"] - if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): - raise ValueError(f"Invalid Android version: {version!r}") - build_date = datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d") - # Match the dated release tags used by the desktop and iOS jobs. - tag = os.environ.get("CROSSDESK_SOURCE_TAG") - if not tag and os.environ["GITHUB_REF_TYPE"] == "tag": - tag = os.environ["GITHUB_REF_NAME"] - if tag: - tag = tag.removeprefix("android-") - match = re.fullmatch(r"v?[0-9]+(?:\.[0-9]+){1,3}-(?:[0-9]+-)?([0-9]{8})(?:-[0-9]+)?", tag) - if match: - build_date = match[1] - datetime.datetime.strptime(build_date, "%Y%m%d") - version = f"v{version}-{build_date}" - source_tag = os.environ.get("CROSSDESK_SOURCE_TAG", "") - if source_tag.startswith("android-v") and source_tag.removeprefix("android-") != version: - raise ValueError(f"Android source tag does not match the app version: {source_tag} vs {version}") + version = os.environ["ANDROID_ARTIFACT_VERSION"] + for variant in ("debug", "release"): + metadata = json.loads((apk_dir / variant / "output-metadata.json").read_text()) + for element in metadata["elements"]: + if (element["versionName"] != os.environ["ANDROID_VERSION_NAME"] or + element["versionCode"] != int(os.environ["ANDROID_VERSION_CODE"])): + raise ValueError(f"Unexpected Android {variant} version: {element}") output = Path(os.environ["RUNNER_TEMP"]) / "crossdesk-android" for variant, source in ( ("unsigned", "release/app-release-unsigned.apk"), diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 63b99d99..3ae9d4de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -346,6 +346,8 @@ jobs: build-android: name: Build Android uses: ./.github/workflows/build-android.yml + with: + patch: ${{ inputs.patch || '0' }} permissions: contents: read diff --git a/apps/android/README.md b/apps/android/README.md index 4567e0bd..58f36842 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -1,6 +1,6 @@ # CrossDesk Android -原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.0,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。 +原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.1,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。 ## 当前功能 @@ -83,11 +83,11 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP [`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建及签名成功,并将已签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。 -单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先提交 `app/build.gradle` 中需要发布的 `versionName`,再为该提交创建 `android-v-YYYYMMDD` 标签,例如: +单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先更新并提交 `gradle.properties` 中的 `crossdeskVersionName` 和递增的 `crossdeskVersionCode`,再为该提交创建 `android-v-YYYYMMDD` 标签,例如: ```sh -git tag android-v1.6.0-20261002 -git push origin android-v1.6.0-20261002 +git tag android-v1.6.1-20261010 +git push origin android-v1.6.1-20261010 ``` 该标签只触发安卓构建,使用固定发布密钥签名并验证 Release APK 后,再发布到独立的 GitHub Release 和现有下载服务器。签名失败或缺少签名 Secrets 时停止发布,不回退到未签名 APK。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。 @@ -100,15 +100,15 @@ git push origin android-v1.6.0-20261002 Gradle 缓存与 Android 原生依赖缓存分别管理;原生缓存按宿主架构、构建脚本及 MiniRTC 配方区分,不复用桌面或 iOS 的配置和目标文件。GitHub 托管 runner 每次启动仍是新环境;第一次运行或缓存失效时需要安装。不同分支和标签可读取默认分支的缓存,因此建议先在默认分支运行一次 `Build Android` 预热,再创建发布标签。 -CI 仅构建和打包 Debug、Release APK,并校验 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。单元测试、本机 C++ 协议与画面队列测试、Lint 和设备测试改为按需在本地执行;设备测试使用下面的设备验证流程在 arm64 手机或模拟器上执行。 +CI 仅构建和打包 Debug、Release APK,并校验 APK 的包名、`versionName` / `versionCode`、arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。单元测试、本机 C++ 协议与画面队列测试、Lint 和设备测试改为按需在本地执行;设备测试使用下面的设备验证流程在 arm64 手机或模拟器上执行。 -产物使用 Android 工程自己的 `versionName`,例如 `v1.6.0-20261002`,不跟随桌面版本号。构建日期采用上海时区;带日期的版本标签沿用标签中的日期,与 iOS 一致。Actions 提供: +统一 `v*` 发布从标签提取数字版本,通过 Gradle 属性写入 APK 的 `versionName`;App 内版本展示读取同一包信息。产物版本、日期及可选热修复编号与桌面端保持一致,例如 `v1.6.1-20261010` 或 `v1.6.1-2-20261010`。分支构建和独立 `android-v*` 发布使用 `gradle.properties` 中的版本,独立发布会检查标签与配置一致。构建日期采用上海时区;带日期的版本标签沿用标签中的日期,与 iOS 一致。Actions 提供: - `crossdesk-android-arm64-<版本>.apk`:已签名 Release APK,独立安卓发布和全平台标签发布收集此 APK,可直接安装。 - `crossdesk-android-arm64-unsigned-<版本>.apk`:构建工作流的中间产物,也可用于自行签名和验证修改后的构建。 - `crossdesk-android-arm64-debug-<版本>.apk`:可安装的调试应用,保留 14 天。 -Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在发布新版本时递增 `app/build.gradle` 中的 `versionCode`。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。 +Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在每次发布新版本(含统一发布及热修复)时递增 `gradle.properties` 中的 `crossdeskVersionCode`。该安装升级序号独立于显示版本,不从日期或 CI 运行编号推算。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。 ### Release 签名 diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index ea87a35a..bfa14172 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId 'cn.crossdesk.mobile' minSdk 26 targetSdk 36 - versionCode 2 - versionName '1.6.0' + versionCode providers.gradleProperty('crossdeskVersionCode').get().toInteger() + versionName providers.gradleProperty('crossdeskVersionName').get() ndk { abiFilters 'arm64-v8a' } testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties index c751e06c..06741f7c 100644 --- a/apps/android/gradle.properties +++ b/apps/android/gradle.properties @@ -1,2 +1,4 @@ org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 android.useAndroidX=true +crossdeskVersionName=1.6.1 +crossdeskVersionCode=3 diff --git a/apps/android/scripts/release_version.py b/apps/android/scripts/release_version.py new file mode 100644 index 00000000..8f4824ed --- /dev/null +++ b/apps/android/scripts/release_version.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Resolve Android package and artifact versions before building the APKs.""" + +import datetime +import os +from pathlib import Path +import re +from zoneinfo import ZoneInfo + + +def resolve_version(version_name, version_code, tag="", patch="0", today=None): + build_date = today or datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d") + patch = patch if re.fullmatch(r"[0-9]+", patch) else "0" + if tag: + independent = tag.startswith("android-v") + source_version = tag.removeprefix("android-") + base = r"(?P[0-9]+\.[0-9]+\.[0-9]+)" + for pattern in ( + rf"v{base}-(?P[0-9]+)-(?P[0-9]{{8}})", + rf"v{base}-(?P[0-9]{{8}})(?:-(?P[0-9]+))?", + rf"v{base}(?:-(?P[0-9]+))?", + ): + match = re.fullmatch(pattern, source_version) + if match: + fields = match.groupdict() + if independent and fields["base"] != version_name: + raise ValueError(f"Android source tag does not match the app version: {tag} vs {version_name}") + version_name = fields["base"] + build_date = fields.get("date") or build_date + patch = fields.get("patch") or patch + break + else: + raise ValueError(f"Invalid Android release tag: {tag!r}") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version_name): + raise ValueError(f"Invalid Android versionName: {version_name!r}") + if not re.fullmatch(r"[0-9]+", version_code) or not 0 < int(version_code) <= 2100000000: + raise ValueError(f"Invalid Android versionCode: {version_code!r}") + datetime.datetime.strptime(build_date, "%Y%m%d") + suffix = f"-{patch}" if patch != "0" else "" + artifact_version = f"v{version_name}{suffix}-{build_date}" + if tag.startswith("android-v") and tag.removeprefix("android-") != artifact_version: + raise ValueError(f"Android source tag does not match the artifact version: {tag} vs {artifact_version}") + return {"ANDROID_VERSION_NAME": version_name, "ANDROID_VERSION_CODE": str(int(version_code)), + "ANDROID_ARTIFACT_VERSION": artifact_version} + + +def main(): + properties = dict(line.split("=", 1) for line in + (Path(__file__).resolve().parents[1] / "gradle.properties").read_text().splitlines() + if "=" in line and not line.lstrip().startswith("#")) + tag = os.environ.get("CROSSDESK_SOURCE_TAG", "") + if not tag and os.environ.get("GITHUB_REF_TYPE") == "tag": + ref_name = os.environ["GITHUB_REF_NAME"] + # Moving aliases such as "latest" are builds, not versioned releases. + if ref_name.startswith(("v", "android-v")): + tag = ref_name + values = resolve_version(properties["crossdeskVersionName"], properties["crossdeskVersionCode"], + tag, os.environ.get("PATCH_NUMBER", "0")) + text = "".join(f"{key}={value}\n" for key, value in values.items()) + if os.environ.get("GITHUB_ENV"): + with open(os.environ["GITHUB_ENV"], "a") as stream: + stream.write(text) + print(text, end="") + + +if __name__ == "__main__": + main() diff --git a/apps/android/test/verify_apk.py b/apps/android/test/verify_apk.py index b5b437dd..d6bb79c2 100644 --- a/apps/android/test/verify_apk.py +++ b/apps/android/test/verify_apk.py @@ -1,13 +1,24 @@ #!/usr/bin/env python3 """Check the shipped native ABI, ELF load alignment and APK zip alignment.""" from pathlib import Path +import argparse import hashlib import json +import re import struct -import sys +import subprocess import zipfile +def verify_package_version(badging, version_name, version_code): + package = next((line for line in badging.splitlines() if line.startswith('package: ')), '') + attributes = dict(re.findall(r"(\w+)='([^']*)'", package)) + expected = {'name': 'cn.crossdesk.mobile', 'versionName': version_name, 'versionCode': str(version_code)} + for key, value in expected.items(): + if attributes.get(key) != value: + raise ValueError(f'Unexpected APK {key}: expected {value!r}, got {attributes.get(key)!r}') + + def verify(path): with zipfile.ZipFile(path) as archive, open(path, 'rb') as raw: libraries = [i for i in archive.infolist() if i.filename.endswith('.so')] @@ -59,4 +70,16 @@ def verify(path): if __name__ == '__main__': - verify(sys.argv[1]) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('apk', type=Path) + parser.add_argument('--aapt2', type=Path) + parser.add_argument('--version-name') + parser.add_argument('--version-code', type=int) + args = parser.parse_args() + if any(value is not None for value in (args.aapt2, args.version_name, args.version_code)): + if any(value is None for value in (args.aapt2, args.version_name, args.version_code)): + parser.error('--aapt2, --version-name and --version-code must be supplied together') + badging = subprocess.check_output([str(args.aapt2), 'dump', 'badging', str(args.apk)], text=True) + verify_package_version(badging, args.version_name, args.version_code) + print(f'PASS: {args.apk.name}: versionName={args.version_name}, versionCode={args.version_code}') + verify(args.apk)