mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 16:30:50 +08:00
[fix] align Android versions with release tags, refs #107
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -346,6 +346,8 @@ jobs:
|
||||
build-android:
|
||||
name: Build Android
|
||||
uses: ./.github/workflows/build-android.yml
|
||||
with:
|
||||
patch: ${{ inputs.patch || '0' }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -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<versionName>-YYYYMMDD` 标签,例如:
|
||||
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先更新并提交 `gradle.properties` 中的 `crossdeskVersionName` 和递增的 `crossdeskVersionCode`,再为该提交创建 `android-v<versionName>-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 签名
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
crossdeskVersionName=1.6.1
|
||||
crossdeskVersionCode=3
|
||||
|
||||
@@ -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<base>[0-9]+\.[0-9]+\.[0-9]+)"
|
||||
for pattern in (
|
||||
rf"v{base}-(?P<patch>[0-9]+)-(?P<date>[0-9]{{8}})",
|
||||
rf"v{base}-(?P<date>[0-9]{{8}})(?:-(?P<patch>[0-9]+))?",
|
||||
rf"v{base}(?:-(?P<patch>[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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user