Files
opendance-android/build/check_apk_version.py
T
zijunchiang 31b6ddd0fa test(packaging): APK 产物版本元数据校验(T-AND-013)+ 修 die 在定义前被调用
文档 §11.2 要求「版本号单一来源 + 可核对」。本仓库原有校验只覆盖**构建配置**
(gradle verifyReleaseVersion 比 versionName ↔ project.yaml)。但版本号真正被
系统与用户看到的地方是 **APK 的 AndroidManifest**:安装界面、应用信息页、
dumpsys package 读的都是它。构建缓存、复用旧 APK、打包时拷错文件都会造成
「配置全对、产物是旧版」——配置层校验抓不到。

这不是假设:同批工作里 Windows 端 v0.2.1 的发布包就是配置文件全一致、
exe 属性页显示 0.1.0(opendance-windows/tasks/017-pe-version.md)。

新增:
  * build/check_apk_version.py:直接解析 APK 内 AndroidManifest.xml(二进制 AXML)
    的 versionName / versionCode。只依赖 Python 标准库,不需要 Android SDK/aapt2。
    AXML 解析:顶层 ResChunk_header → 字符串池(UTF-8 与 UTF-16 都要处理)→
    START_ELEMENT;属性名本身就在字符串池里,按文本匹配即可,无需资源映射表;
  * build/check-apk-version.sh:薄包装,期望版本缺省取 project.yaml,
    并校验 versionCode == 10000*主 + 100*次 + 修订(§11.1);
  * build/verify.sh 步骤 3/3 新增「版本元数据」计分项(原先只 aapt2 打印一行);
  * build/package-release.sh 打包前校验源 APK,不一致即 die。

交叉验证(与 aapt2 dump badging 逐项一致):
  v0.1.0 APK -> versionCode 1 / versionName 0.1.0
  v0.2.0 APK -> versionCode 200 / versionName 0.2.0
  app/build/outputs/apk/release/app-release.apk -> 同 v0.2.0
反向验证(用真实旧产物当负例):
  bash build/check-apk-version.sh dist-release/opendance-android-v0.1.0/app-release.apk
    ✗ versionName 是 '0.1.0',期望 '0.2.0'
    ✗ versionCode 是 1,期望 200
    退出码 1

顺带修复:build/package-release.sh 第 31/33 行用 die 报版本不一致,但 die()
到第 40 行才定义。命中该分支时输出的是
    build/package-release.sh: 行 31: die: 未找到命令   (退出码 127)
脚本确实中止了,但用户完全看不出真实原因。现把 log()/die() 移到首次使用之前:
    [error] 版本号不一致:参数/versionName=9.9.9,project.yaml=0.2.0  (退出码 1)

未新发版本:APK 内容与 v0.2.0 完全一致(产物侧校验证明其 versionName/versionCode
本就正确),本次改动只涉及构建/校验脚本与文档,故不为它提版本号;
README.txt 的新增自检项会随下一次 Android 发布进入发布包。
2026-09-17 04:29:52 +08:00

218 lines
8.6 KiB
Python
Executable File
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.
#!/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 <apk> [--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("<HHI", data, offset)
if chunk_type != RES_STRING_POOL:
raise AxmlError("offset %d 处不是字符串池(type=0x%04x)" % (offset, chunk_type))
string_count, _style_count, flags, strings_start, _styles_start = struct.unpack_from(
"<IIIII", data, offset + 8
)
utf8 = bool(flags & (1 << 8))
base = offset + strings_start
offsets = struct.unpack_from("<%dI" % string_count, data, offset + header_size)
out: list[str] = []
for rel in offsets:
pos = base + rel
if utf8:
# u16 字符数(高位为 1 表示后面还有一字节的字节数)、再 u8 字节数
first = struct.unpack_from("<H", data, pos)[0]
pos += 2
char_len = first
if first & 0x80:
char_len = ((first & 0x7F) << 8) | data[pos]
pos += 1
byte_len = data[pos]
pos += 1
del char_len # 只用字节数读取,避免两处长度不一致时误截断
out.append(data[pos : pos + byte_len].decode("utf-8", "replace"))
else:
first = struct.unpack_from("<H", data, pos)[0]
pos += 2
char_len = first
if first & 0x8000:
char_len = ((first & 0x7FFF) << 16) | struct.unpack_from("<H", data, pos)[0]
pos += 2
out.append(data[pos : pos + char_len * 2].decode("utf-16-le", "replace"))
return out
def read_manifest_versions(apk_path: str) -> tuple[str | None, int | None]:
"""读 <manifest> 标签的 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("<HHI", raw, 0)[2] # 用 header size 定位第一个子 chunk
node_offset, header_size, _file_size = struct.unpack_from("<HHI", raw, 0)
del node_offset
if offset <= 0 or offset >= len(raw):
offset = header_size if header_size else 8
strings = _parse_string_pool(raw, offset)
pos = offset + struct.unpack_from("<HHI", raw, offset)[2]
while pos + 8 <= len(raw):
chunk_type, _chunk_header_size, chunk_size = struct.unpack_from("<HHI", raw, pos)
if chunk_size == 0:
break
if chunk_type == RES_XML_START_ELEMENT:
# ResXMLTree_node(16) + ResXMLTree_attrExt(20) + attributeCount * 20
(ns_idx, name_idx) = struct.unpack_from("<II", raw, pos + 16)
attr_start, attr_size, attr_count = struct.unpack_from("<HHH", raw, pos + 16 + 8)
tag = strings[name_idx] if name_idx < len(strings) else ""
del ns_idx
if tag == "manifest":
version_name: str | None = None
version_code: int | None = None
attr_base = pos + 16 + attr_start
for i in range(attr_count):
a = attr_base + i * attr_size
a_name_idx, a_raw_idx = struct.unpack_from("<II", raw, a + 4)
v_size, _res0, data_type, data = struct.unpack_from("<HBBI", raw, a + 12)
del v_size, _res0
a_name = strings[a_name_idx] if a_name_idx < len(strings) else ""
if a_name == "versionName":
if data_type == TYPE_STRING and data < len(strings):
version_name = strings[data]
elif a_raw_idx < len(strings):
version_name = strings[a_raw_idx]
elif a_name == "versionCode":
if data_type in (TYPE_INT_DEC, TYPE_INT_HEX):
version_code = data
return version_name, version_code
pos += chunk_size
raise AxmlError("AndroidManifest.xml 里没有找到 <manifest> 标签")
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))