Files
opendance-android/build/accept-release.sh
T
zijunchiang 95429e8ce3 test(native): APK native 库体检——ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐(T-AND-015)
T-AND-012 的结论是「ExceptionInInitializerError 的根因在集成配置(native 解压 +
R8 + ABI),不是 MediaPipe 本身不稳定」,但当时的核查是**手工一次性**的(unzip 看
lib/、grep seeds、strings 找类名)——下次照样可能漏,且漏了也没人发现,只会在用户
装上手机、点开相机那一刻暴露。

新增 build/check_native_libs.py(纯 Python 标准库解析 ELF,不需要 readelf / NDK),
检查三项**无设备也能查**的「一装上就崩」根因:
  * ABI 与 ELF 类型匹配(把 x86_64 的 .so 放进 lib/arm64-v8a/ → 一加载就崩);
  * DT_NEEDED 依赖闭包(非系统库必须同 ABI 目录内自带;MediaPipe 早期版本的
    libc++_shared.so 就是这么缺的);
  * PT_LOAD 页对齐 ≥ 16 KB(Android 15 起部分设备页 16 KB,4 KB 对齐会直接加载失败;
    Google Play 2025-11 起也要求)。
外加 zipalign -c -P 16 -v 4(有 build-tools 时)。已接入 verify.sh 与 accept-release.sh。

实测(已发布的 opendance-android-v0.2.0.zip 内 APK):
  双 ABI 下 6 个 .so 全部 ELF 类型匹配、依赖闭包完整(libmediapipe_tasks_jni.so 只依赖
  libdl/libjnigraphics/libEGL/libGLESv2/libm/libz/liblog/libandroid/libc,全是系统库)、
  PT_LOAD p_align=0x4000(16 KB 兼容)、zipalign -P 16 通过。
  结论:打包配置这一侧的「一装上就崩」风险已排除,剩下的只能靠真机实测。

与 readelf 交叉验证(6 个 .so 的 DT_NEEDED / p_align / e_machine 逐项一致)——
这次交叉验证抓到解析器自己的 bug:第一版程序头偏移写错(ELF64 的
p_offset/p_vaddr 之后第三个 QWORD 是 p_paddr 而非 p_filesz),导致 vaddr→offset
映射失败、DT_NEEDED 解析为空,页面照样打印「✓ 依赖闭包完整(系统库 0 项)」——
一句**假通过**,唯一破绽就是那个 0。教训:通过输出必须带可核对的数量/取值。

反向验证(三条分支都用真实负例证明会失败):
  * 注入宿主 x86_64 的 libz.so.1 到 lib/arm64-v8a/libbogus.so → 同时报 ELF 类型不符、
    缺依赖 libc.so.6、页对齐 0x1000 < 16384;
  * 把 libz.so 从系统库白名单去掉 → 报缺少依赖 libz.so(证明闭包分支是活的);
  * --expected-abis 传错 → 报 ABI 集合不符。

verify.sh 全绿;对**已发布资产**重新下载后跑完整 accept-release.sh 全部通过。
未新发版本:APK 本身未变(本任务是检查工具与文档),README 的新增项随下次发布进包。
2026-09-17 05:25:04 +08:00

216 lines
8.7 KiB
Bash
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 bash
# ============================================================
# OpenDance Studio Android 辅助 App —— **发布包级端到端验收**(T-AND-014)
#
# 为什么需要它:`build/verify.sh` 验的是 `app/build/outputs/` 里**刚构建**的 APK,
# 而交付给用户的是 zip 里的 `app-release.apk`。两者之间隔着打包与分发;
# 装到手机上的是后者。本项目在另两端都因此翻过车(Web v0.7.4 的实体注册、
# Windows v0.2.1 的 PE 版本元数据),所以三端统一要求:
# **每次发布都要有一条读产物本身的验收**(文档 §7.5)。
#
# 本脚本对一个 zip(或已解压目录)跑完:
# 1. sha256 校验(有 SHA256SUMS 时);
# 2. 解压 + 文件清单(apk / README / THIRD_PARTY / contracts);
# 3. APK 的 **AndroidManifest versionName/versionCode** = 本次发布版本
# (build/check-apk-version.sh;安装界面与应用信息页读的就是它);
# 4. ABI 恰为 arm64-v8a + armeabi-v7a(无模拟器 ABI),且两个 ABI 下都有
# MediaPipe native 库(T-AND-012 的崩溃根因之一);
# 5. MediaPipe 模型资源随包分发、dex 里仍有 PoseLandmarker(R8 没裁掉);
# 6. 有 apksigner 时顺带验签(可选,不影响结论);
# 7. 包内 contracts/VERSION 与仓库 contracts/VERSION 逐项一致
# (抓「APK 用旧契约构建、快照却拷了新的」)。
#
# 用法:
# bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
# bash build/accept-release.sh <zip 或已解压目录> 0.2.0 # 显式期望版本
#
# 可选环境变量:
# ANDROID_HOME / ANDROID_SDK_ROOT 找 apksigner 用(找不到就跳过验签)
# WORK_DIR 解压目录(默认 mktemp -d)
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
TARGET="${1:-}"
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
EXPECTED="${2:-$YAML_VERSION}"
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
FAILED=0
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
echo "用法:bash build/accept-release.sh <发布包 zip 或已解压目录> [期望版本]" >&2
exit 2
fi
if [ -z "$EXPECTED" ]; then
echo "✗ 无法确定期望版本(project.yaml 里没有 version,也没有显式传入)" >&2
exit 2
fi
log "目标:$TARGET"
log "期望版本:$EXPECTED"
log "工作目录:$WORK_DIR"
mkdir -p "$WORK_DIR"
# ---- 1. sha256 + 解压 ----
if [ -f "$TARGET" ]; then
# SHA256SUMS 里是裸文件名,校验时 cwd 必须切到包所在目录;sums 文件用绝对路径
TARGET_DIR_ABS="$(cd "$(dirname "$TARGET")" && pwd)"
SUMS="$TARGET_DIR_ABS/SHA256SUMS"
if [ -f "$SUMS" ]; then
log "sha256 校验($SUMS):"
( cd "$TARGET_DIR_ABS" && sha256sum -c "$SUMS" --ignore-missing ) || fail "sha256 校验失败"
else
log "未找到 $SUMS,跳过 sha256 校验"
fi
log "解压…"
unzip -qq -o "$TARGET" -d "$WORK_DIR" || { fail "解压失败"; exit 1; }
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
else
PACKAGE_DIR="$TARGET"
fi
[ -n "$PACKAGE_DIR" ] && [ -d "$PACKAGE_DIR" ] || { fail "找不到解压后的包目录"; exit 1; }
log "包目录:$PACKAGE_DIR"
# ---- 2. 文件清单 ----
log "文件清单:"
for required in app-release.apk README.txt THIRD_PARTY.md contracts/VERSION; do
if [ -f "$PACKAGE_DIR/$required" ]; then
printf ' ✓ %s\n' "$required"
else
fail "缺少 $required"
fi
done
APK="$PACKAGE_DIR/app-release.apk"
if [ ! -f "$APK" ]; then
echo "(没有 APK,后续检查无法进行)" >&2
exit 1
fi
# ---- 3. APK 版本元数据 ----
log "APK 版本元数据(AndroidManifest):"
bash "$PROJECT_DIR/build/check-apk-version.sh" "$APK" "$EXPECTED" \
|| fail "APK 的 versionName/versionCode 不是 v$EXPECTED"
# ---- 4. ABI 与 MediaPipe native 库 ----
log "ABI 与 native 库:"
ABIS="$(unzip -l "$APK" | grep -oE 'lib/[^/]+/' | sed 's#lib/##;s#/##' | sort -u)"
ABIS_ONE_LINE="$(echo "$ABIS" | tr '\n' ' ')"
echo " 包含 ABI:$ABIS_ONE_LINE"
if [ "$ABIS" = "$(printf 'arm64-v8a\narmeabi-v7a')" ]; then
echo " ✓ ABI 恰为 arm64-v8a + armeabi-v7a(真机 ABI)"
else
fail "ABI 与预期不符(预期 arm64-v8a / armeabi-v7a)"
fi
if echo "$ABIS" | grep -qE "x86"; then
fail "release APK 含模拟器 ABI(包体翻倍且与 abiFilters 不符)"
fi
for abi in arm64-v8a armeabi-v7a; do
if unzip -l "$APK" | grep -q "lib/$abi/.*mediapipe"; then
echo " ✓ lib/$abi 下有 MediaPipe native 库"
else
fail "lib/$abi 下缺少 MediaPipe native 库(真机安装后必然初始化失败)"
fi
done
# ---- 4b. native 库体检(T-AND-015:ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)----
log "native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐):"
NPY="${PYTHON_BIN:-}"
if [ -z "$NPY" ]; then
for candidate in python3 python; do command -v "$candidate" >/dev/null 2>&1 && { NPY="$candidate"; break; }; done
fi
if [ -n "$NPY" ]; then
"$NPY" "$PROJECT_DIR/build/check_native_libs.py" "$APK" \
--expected-abis "$(echo "$ABIS" | tr '\n' ',')" || fail "native 库体检未通过"
else
fail "未找到 python3,无法做 native 库体检(apt-get install -y python3)"
fi
# 16 KB 页对齐的 zip 层检查(Android 15 起部分设备页 16 KB)
SDK_FOR_ZIPALIGN="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
ZIPALIGN="$(ls -d "$SDK_FOR_ZIPALIGN"/build-tools/*/zipalign 2>/dev/null | sort -V | tail -1)"
if [ -n "$ZIPALIGN" ]; then
if "$ZIPALIGN" -c -P 16 -v 4 "$APK" >"$WORK_DIR/zipalign.txt" 2>&1; then
echo " ✓ zipalign -c -P 16 -v 4 通过(4 字节对齐 + 16 KB 页对齐)"
else
fail "zipalign -P 16 校验失败(见 $WORK_DIR/zipalign.txt)"
fi
else
log "未找到 zipalign,跳过 zip 对齐检查(不影响结论)"
fi
# ---- 5. 模型资源与 R8 保留 ----
log "MediaPipe 模型与 R8 保留:"
if unzip -l "$APK" | grep -q "assets/pose_landmarker_lite.task"; then
echo " ✓ MediaPipe 模型资源随包分发(assets/pose_landmarker_lite.task)"
else
fail "APK 缺少 MediaPipe 模型资源"
fi
DEX_HIT=0
for entry in $(unzip -Z1 "$APK" | grep -E '^classes[0-9]*\.dex$'); do
if unzip -p "$APK" "$entry" | grep -qa "poselandmarker/PoseLandmarker"; then
DEX_HIT=$((DEX_HIT + 1))
fi
done
if [ "$DEX_HIT" -gt 0 ]; then
echo " ✓ dex 中存在 poselandmarker/PoseLandmarker(R8 未整体裁掉 MediaPipe;命中 $DEX_HIT 个 dex)"
else
fail "dex 里找不到 PoseLandmarker(R8 把 MediaPipe 裁掉了,release 必崩)"
fi
# ---- 6. 验签(可选)----
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
APKSIGNER="$(ls -d "$SDK"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1)"
if [ -n "$APKSIGNER" ]; then
log "签名校验($APKSIGNER):"
if "$APKSIGNER" verify "$APK" >"$WORK_DIR/apksigner.txt" 2>&1; then
echo " ✓ apksigner 验签通过(退出码 0)"
else
fail "apksigner 验签失败"
sed 's/^/ /' "$WORK_DIR/apksigner.txt" | tail -5
fi
else
log "未找到 apksigner,跳过验签(不影响结论)"
fi
# ---- 7. 契约快照与仓库一致 ----
log "包内契约快照:"
snapshot_ok=1
if [ -f "$PACKAGE_DIR/contracts/VERSION" ]; then
sed 's/^/ /' "$PACKAGE_DIR/contracts/VERSION"
# 与仓库当前的 contracts/VERSION 逐项比对:抓「APK 用旧契约构建、快照拷了新的」
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
key="${line%%:*}"; value="${line#*:}"
key="$(printf '%s' "$key" | tr -d '[:space:]')"
value="$(printf '%s' "$value" | tr -d '[:space:]')"
repo_value="$(sed -n "s/^${key}:[[:space:]]*//p" contracts/VERSION | head -1)"
if [ -z "$repo_value" ]; then
fail "仓库 contracts/VERSION 缺少 $key"
snapshot_ok=0
elif [ "$repo_value" != "$value" ]; then
fail "$key:包内 $value,仓库 $repo_value"
snapshot_ok=0
fi
done <"$PACKAGE_DIR/contracts/VERSION"
[ "$snapshot_ok" = 1 ] && echo " ✓ 包内契约快照与仓库 contracts/VERSION 一致"
else
fail "包内缺少 contracts/VERSION"
fi
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "============================================================"
echo " 发布包级验收失败(工作目录:$WORK_DIR)"
echo "============================================================"
exit 1
fi
echo "============================================================"
echo " 发布包级验收全部通过(工作目录:$WORK_DIR)"
echo "============================================================"