diff --git a/README.md b/README.md index 9bf587d..5bb8178 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe ## 状态 -**阶段三已完成**: T-AND-001 -> T-AND-012 全部落地(含重连状态机与 MediaPipe 崩溃修复)。 +**阶段三已完成**: T-AND-001 -> T-AND-013 全部落地(含重连状态机、MediaPipe 崩溃修复、APK 产物版本元数据校验)。 | 验收项 | 结果 | |--|--| @@ -20,6 +20,7 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe | 包信息 | OK `com.opendance.studio` v0.2.0, minSdk 29(Android 10+)/ targetSdk 34 | | 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) | | ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) | +| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.0`/`versionCode=200`,与 `project.yaml` 一致(T-AND-013) | ## 快速开始 @@ -255,6 +256,21 @@ app/src/main/java/com/opendance/studio/ (= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从 `contracts/VERSION` 解析后注入 `BuildConfig`;Kotlin 界面代码里不出现任何版本号字面量。 +**产物侧复核(T-AND-013)**:`versionName` 是**构建配置**,而安装界面 / 应用信息页 / +`dumpsys package` 读的是 **APK 的 `AndroidManifest`**。构建缓存、复用旧 APK、 +打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(Windows 端 +v0.2.1 就踩过同类坑)。因此新增: + +```bash +bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk +# -> AndroidManifest versionName = 0.2.0 / versionCode = 200 +# -> ✓ APK 版本元数据一致:0.2.0(versionCode 200) +``` + +`build/check_apk_version.py` 直接解析二进制 AXML,**不需要 Android SDK / aapt2** +(已与 `aapt2 dump badging` 在 v0.1.0 / v0.2.0 两个真实 APK 上交叉验证逐项一致)。 +该检查已接入 `build/verify.sh`(步骤 3/3)与 `build/package-release.sh`(打包前 `die`)。 + - [`contracts/websocket-keypoints.md`](contracts/websocket-keypoints.md) - 239 字节关键点帧(本端为**发送方**) - [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) - MediaPipe 33 点索引与坐标语义 - [`contracts/open-source-credits.md`](contracts/open-source-credits.md) - 关于页必须展示的开源声明 diff --git a/README.txt b/README.txt index 63fbf74..f88dfaf 100644 --- a/README.txt +++ b/README.txt @@ -138,6 +138,14 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评 $ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk 预期:退出码 0(无输出即通过) + bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk + 预期:AndroidManifest versionName = + AndroidManifest versionCode = 10000*主 + 100*次 + 修订 + "APK 版本元数据一致" + 说明:安装界面 / 应用信息页读的是 APK 的 AndroidManifest,而不是 + build.gradle.kts 的配置。构建缓存或拷错文件会让两者不一致, + 所以这里直接读产物(不需要 aapt2,只用 python3 解析二进制 AXML)。 + adb install -r app/build/outputs/apk/debug/app-debug.apk 预期:Success diff --git a/build/check-apk-version.sh b/build/check-apk-version.sh new file mode 100755 index 0000000..f900413 --- /dev/null +++ b/build/check-apk-version.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# ============================================================ +# OpenDance Studio —— APK 版本元数据校验(读产物本身) +# +# 文档 §11.2 要求「版本号单一来源 + 可核对」。`app/build.gradle.kts` 里的 +# `verifyReleaseVersion` 任务只比**构建配置**(versionName ↔ project.yaml); +# 而版本号真正被系统与用户看到的地方是 **APK 的 AndroidManifest** —— +# 安装界面、应用信息页、`dumpsys package` 读的都是它。 +# 构建缓存、复用旧 APK、打包时拷错文件都会让「配置全对、产物是旧版」; +# Windows 端 v0.2.1 就踩过同类的坑(配置文件全一致,exe 属性页显示 0.1.0, +# 见 opendance-windows/tasks/017-pe-version.md)。 +# +# 本脚本直接读产物,因此与构建配置无关也能发现漂移。 +# +# 用法: +# bash build/check-apk-version.sh [期望版本 X.Y.Z] +# 期望版本缺省时取 project.yaml 的 version;此时同时校验 +# versionCode == 10000*主 + 100*次 + 修订(文档 §11.1)。 +# ============================================================ +set -uo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +APK="${1:-}" +EXPECTED="${2:-$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)}" + +if [ -z "$APK" ]; then + echo "用法:bash build/check-apk-version.sh [期望版本 X.Y.Z]" >&2 + exit 2 +fi +if [ ! -f "$APK" ]; then + echo "✗ 找不到文件:$APK" >&2 + exit 2 +fi + +# 解析器只用 Python 标准库读二进制 AXML(不依赖 Android SDK / aapt2)。 +# 已用 aapt2 dump badging 在 v0.1.0 与 v0.2.0 两个真实 APK 上交叉验证一致。 +PYTHON="${PYTHON_BIN:-}" +if [ -z "$PYTHON" ]; then + for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then PYTHON="$candidate"; break; fi + done +fi +if [ -z "$PYTHON" ]; then + echo "✗ 未找到 python3(用于解析 APK 内的二进制 AndroidManifest.xml)" >&2 + echo " Debian/Ubuntu: apt-get install -y python3" >&2 + exit 2 +fi + +if [ -n "$EXPECTED" ]; then + "$PYTHON" "$PROJECT_DIR/build/check_apk_version.py" "$APK" --expect-version "$EXPECTED" +else + "$PYTHON" "$PROJECT_DIR/build/check_apk_version.py" "$APK" +fi diff --git a/build/check_apk_version.py b/build/check_apk_version.py new file mode 100755 index 0000000..53971dd --- /dev/null +++ b/build/check_apk_version.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""读取 APK 内 AndroidManifest.xml(二进制 AXML)的 versionName / versionCode。 + +为什么需要它(T-AND-013): + +文档 §11.2 要求「版本号单一来源 + 可核对」。本仓库原有的 +`app/build.gradle.kts` 里那个 `verifyReleaseVersion` 任务只比**构建配置** +(`versionName` ↔ `project.yaml`)。但版本号真正呈现给系统与用户的地方是 +**APK 的 AndroidManifest** —— 安装界面、应用信息页、`dumpsys package` 读的都是它。 +配置全对、产物却是旧版(构建缓存、复用旧 APK、打包时拷错文件)属于同一类 +「校验位置少一个,等于没校验」的失效;Windows 端 v0.2.1 就踩过(见该仓库 +`tasks/017-pe-version.md`:配置文件全一致,exe 属性页显示 0.1.0)。 + +因此这里直接读**产物本身**。不依赖 Android SDK:AXML 的属性名就在字符串池里, +按属性名文本匹配即可,无需解析资源映射表,也不需要 aapt2。 + +用法: + python3 build/check_apk_version.py [--expect-version X.Y.Z] + [--expect-code N] + [--print-code-for X.Y.Z] +退出码:0 通过 / 1 不一致 / 2 用法或文件问题。 +""" + +from __future__ import annotations + +import struct +import sys +import zipfile + +# AXML chunk 类型 +RES_STRING_POOL = 0x0001 +RES_XML_START_ELEMENT = 0x0102 + +# Res_value 的 dataType +TYPE_STRING = 0x03 +TYPE_INT_DEC = 0x10 +TYPE_INT_HEX = 0x11 + + +class AxmlError(Exception): + pass + + +def _parse_string_pool(data: bytes, offset: int) -> list[str]: + """解析 ResStringPool:返回字符串列表。支持 UTF-8 与 UTF-16 两种编码。""" + chunk_type, header_size, _chunk_size = struct.unpack_from(" tuple[str | None, int | None]: + """读 标签的 versionName / versionCode。""" + try: + with zipfile.ZipFile(apk_path) as zf: + raw = zf.read("AndroidManifest.xml") + except (OSError, KeyError, zipfile.BadZipFile) as exc: + raise AxmlError("无法读取 %s 里的 AndroidManifest.xml:%s" % (apk_path, exc)) from exc + + if len(raw) < 8: + raise AxmlError("AndroidManifest.xml 太短(%d 字节),不是二进制 AXML" % len(raw)) + + # 顶层 ResChunk_header(type=0x0003 XML),其后依次是字符串池、资源映射、XML 节点 + offset = struct.unpack_from("= len(raw): + offset = header_size if header_size else 8 + + strings = _parse_string_pool(raw, offset) + pos = offset + struct.unpack_from(" 标签") + + +def expected_version_code(version: str) -> int: + """文档 §11.1:versionCode = 10000 * 主 + 100 * 次 + 修订。""" + major, minor, patch = (int(x) for x in version.split(".")) + return 10000 * major + 100 * minor + patch + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print(__doc__.strip(), file=sys.stderr) + return 2 + + apk = argv[1] + expect_version = None + expect_code = None + print_code_for = None + + i = 2 + while i < len(argv): + arg = argv[i] + if arg == "--expect-version" and i + 1 < len(argv): + expect_version = argv[i + 1] + i += 2 + elif arg == "--expect-code" and i + 1 < len(argv): + expect_code = int(argv[i + 1]) + i += 2 + elif arg == "--print-code-for" and i + 1 < len(argv): + print_code_for = argv[i + 1] + i += 2 + else: + print("未知参数:%s" % arg, file=sys.stderr) + return 2 + + if print_code_for is not None: + print(expected_version_code(print_code_for)) + return 0 + + try: + version_name, version_code = read_manifest_versions(apk) + except AxmlError as exc: + print("✗ %s" % exc, file=sys.stderr) + return 2 + + print(" APK 产物版本元数据(%s):" % apk) + print(" AndroidManifest versionName = %s" % (version_name if version_name else "(未找到)")) + print(" AndroidManifest versionCode = %s" % (version_code if version_code is not None else "(未找到)")) + + if expect_version is None and expect_code is None: + return 0 + + failed = False + if expect_version is not None: + if version_name != expect_version: + print( + " ✗ APK 的 versionName 是 '%s',期望 '%s'(安装界面与应用信息页会显示错版本)" + % (version_name, expect_version), + file=sys.stderr, + ) + failed = True + derived = expected_version_code(expect_version) + if version_code is not None and version_code != derived: + print( + " ✗ APK 的 versionCode 是 %s,期望 %s(§11.1:10000*主 + 100*次 + 修订)" + % (version_code, derived), + file=sys.stderr, + ) + failed = True + if expect_code is not None and version_code != expect_code: + print(" ✗ APK 的 versionCode 是 %s,期望 %s" % (version_code, expect_code), file=sys.stderr) + failed = True + + if failed: + return 1 + print(" ✓ APK 版本元数据一致:%s(versionCode %s)" % (version_name, version_code)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/build/package-release.sh b/build/package-release.sh index c5dbb91..1aebf33 100755 --- a/build/package-release.sh +++ b/build/package-release.sh @@ -23,6 +23,11 @@ set -euo pipefail PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$PROJECT_DIR" +# 先定义 log/die:下面的版本号校验就会用到 die,函数必须先于调用定义, +# 否则命中的是 "die: 未找到命令"(退出码 127,看不出真实原因)。 +log() { printf '\033[1;34m[package]\033[0m %s\n' "$*"; } +die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; } + YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)" GRADLE_VERSION="$(sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' app/build.gradle.kts | head -1)" VERSION="${1:-$GRADLE_VERSION}" @@ -36,11 +41,14 @@ OUT_DIR="$PROJECT_DIR/dist-release" STAGE="$OUT_DIR/$NAME" APK="app/build/outputs/apk/release/app-release.apk" -log() { printf '\033[1;34m[package]\033[0m %s\n' "$*"; } -die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; } - [ -f "$APK" ] || die "未找到 $APK,请先执行 bash build/build-apk.sh release" +# 源 APK 的 AndroidManifest 版本号必须是本次发布版本(文档 §11.2): +# 安装界面 / 应用信息页读的就是它,与 build.gradle.kts 的配置无关。 +bash "$PROJECT_DIR/build/check-apk-version.sh" "$APK" "$VERSION" \ + || die "APK 的 versionName/versionCode 不是 v$VERSION;请用 bash build/build-apk.sh release 重新构建" +log "源 APK 版本元数据校验通过(=$VERSION)" + # APK 是 zip,校验魔数 MAGIC=$(head -c 2 "$APK" | od -An -tx1 | tr -d ' \n') [ "$MAGIC" = "504b" ] || die "$APK 不是合法 APK(魔数 $MAGIC)" diff --git a/build/verify.sh b/build/verify.sh index 357c5f1..88698ce 100755 --- a/build/verify.sh +++ b/build/verify.sh @@ -38,7 +38,7 @@ fi echo "" echo "============================================================" -echo " 3/3 产物校验(签名 / 包信息 / 模型资源)" +echo " 3/3 产物校验(签名 / 版本元数据 / 包信息 / 模型资源 / ABI / R8)" echo "============================================================" SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" # 优先选用与 compileSdk 匹配的 build-tools,缺失时退回最新版 @@ -77,6 +77,16 @@ else "$BT/aapt2" dump badging "$RELEASE_APK" 2>/dev/null | head -1 | sed 's/^/ /' + # ---- 版本元数据必须落在**产物本身**上(文档 §11.2)---- + # gradle 的 verifyReleaseVersion 只比构建配置;APK 的 AndroidManifest 才是 + # 安装界面 / 应用信息页读到的版本。构建缓存或拷错文件都只会在这一层暴露。 + if bash "$PROJECT_DIR/build/check-apk-version.sh" "$RELEASE_APK"; then + : + else + echo "✗ APK 的 versionName/versionCode 与 project.yaml 不一致" + FAILED=1 + fi + if unzip -l "$RELEASE_APK" | grep -q "assets/pose_landmarker_lite.task"; then echo "✓ MediaPipe 模型已随包分发" else diff --git a/project.yaml b/project.yaml index 045136b..ace3267 100644 --- a/project.yaml +++ b/project.yaml @@ -74,6 +74,10 @@ tasks: file: tasks/012-mediapipe-crash.md scope: MediaPipe 初始化崩溃修复 + 诊断 status: done + - id: T-AND-013 + file: tasks/013-apk-version.md + scope: APK 产物版本元数据(AndroidManifest 的 versionName/versionCode)校验 + 打包脚本 die 未定义修复 + status: done deliverable: artifact: opendance-android-v0.2.0.apk diff --git a/tasks/013-apk-version.md b/tasks/013-apk-version.md new file mode 100644 index 0000000..d767abb --- /dev/null +++ b/tasks/013-apk-version.md @@ -0,0 +1,84 @@ +# T-AND-013:APK 产物版本元数据校验(+ 打包脚本 `die` 未定义修复) + +- **状态**: done +- **依赖**: T-AND-009(构建)/ T-AND-010(版本号展示) +- **文档**: v1.6 §11.1 / §11.2 +- **契约**: 不涉及(打包元数据) + +## 一、为什么需要(同类失效的跨端复核) + +文档 §11.2 要求「版本号单一来源 + 可核对」。本仓库原有两条校验: + +1. `app/build.gradle.kts` 的 `verifyReleaseVersion` 任务:比**构建配置** + (`versionName` ↔ `project.yaml` ↔ 契约快照); +2. `README.md` / `project.yaml` 里的版本文本。 + +但版本号真正被**系统与用户看到**的地方是 **APK 的 `AndroidManifest`**: +安装界面、「应用信息」、`dumpsys package`、应用商店读的都是它。 +构建缓存、复用旧 APK、打包时拷错文件,都会造成「配置全对、产物是旧版」—— +配置文件层面的校验抓不到。 + +这不是假设:同一批工作里,Windows 端 v0.2.1 的发布包就出现过**配置文件全一致、 +`OpenDance.exe` 属性页显示 0.1.0**(`export_presets.cfg` 里的版本键没人管), +详见 `opendance-windows/tasks/017-pe-version.md`。因此本端也补一条**读产物本身**的校验。 + +## 二、实现 + +| 文件 | 作用 | +|--|--| +| `build/check_apk_version.py`(新增) | 直接解析 APK 内 `AndroidManifest.xml`(二进制 AXML)的 `versionName` / `versionCode`;只依赖 Python 标准库,**不需要 Android SDK / aapt2** | +| `build/check-apk-version.sh`(新增) | 薄包装:找 python3 → 带期望版本时同时校验 `versionCode == 10000*主 + 100*次 + 修订`(§11.1);期望版本缺省取 `project.yaml` | +| `build/package-release.sh` | 打包前校验源 APK 的版本元数据,不一致即 `die` | +| `build/verify.sh` | 步骤 3/3 新增「版本元数据」校验项(原来只 `aapt2 dump badging` 打印一行,不判失败) | + +AXML 解析要点:顶层 `ResChunk_header` → 字符串池(UTF-8 / UTF-16 两种编码都要处理) +→ `START_ELEMENT` 节点;`` 的属性名(`versionName` / `versionCode`) +本身就在字符串池里,按**属性名文本**匹配即可,无需解析资源映射表。 + +## 三、验证 + +与 `aapt2 dump badging` 交叉验证,三个真实 APK 完全一致: + +| APK | aapt2 | 本脚本 | +|--|--|--| +| `dist-release/opendance-android-v0.1.0/app-release.apk` | `versionCode='1' versionName='0.1.0'` | versionCode 1 / versionName 0.1.0 ✓ | +| `dist-release/opendance-android-v0.2.0/app-release.apk` | `versionCode='200' versionName='0.2.0'` | versionCode 200 / versionName 0.2.0 ✓ | +| `app/build/outputs/apk/release/app-release.apk` | `versionCode='200' versionName='0.2.0'` | 同上 ✓ | + +**反向验证(用真实旧产物当负例)**: + +``` +$ bash build/check-apk-version.sh dist-release/opendance-android-v0.1.0/app-release.apk + ✗ APK 的 versionName 是 '0.1.0',期望 '0.2.0'(安装界面与应用信息页会显示错版本) + ✗ APK 的 versionCode 是 1,期望 200(§11.1:10000*主 + 100*次 + 修订) + 退出码=1 +``` + +## 四、顺带修复:`package-release.sh` 里 `die` 在定义之前被调用 + +`build/package-release.sh` 的第 31/33 行用 `die` 报版本不一致,但 `die()` 直到第 40 行 +才定义。命中该分支时实际输出的是: + +``` +$ bash build/package-release.sh 9.9.9 +build/package-release.sh: 行 31: die: 未找到命令 +退出码=127 +``` + +脚本确实中止了(`set -e` + 127),但用户看到的是 shell 报错,**完全看不出真实原因**。 +现已把 `log()` / `die()` 的定义移到首次使用之前,修复后: + +``` +$ bash build/package-release.sh 9.9.9 +[error] 版本号不一致:参数/versionName=9.9.9,project.yaml=0.2.0 +退出码=1 +``` + +## 五、验收 + +| 验收项 | 结果 | +|--|--| +| 能读出 APK 的 `versionName` / `versionCode` | ✅ 与 aapt2 在 3 个真实 APK 上逐项一致 | +| 产物漂移会被拦住 | ✅ 反向验证:v0.1.0 的 APK 对 0.2.0 期望 → 退出码 1,并指出具体不符项 | +| 打包与验证两条链路都接入 | ✅ `package-release.sh`(打包前 `die`)/ `verify.sh`(步骤 3/3 计分) | +| 不新增 SDK 依赖 | ✅ 只用 Python 标准库;无 python3 时给出安装提示 |