#!/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))