6 Commits
Author SHA1 Message Date
zijunchiang c0d0937023 fix(reconnect): 合规接收端被周期性误判「链路已死」(v0.2.2)+ 可注入计时覆盖退避封顶
一、测试抓到的真实缺陷(T-AND-017)
契约 §3.6 明确:「接收端收到任何一帧都算活动,ping **无需额外应答**即可保活,
pong 只用于诊断」—— 即"合规但不回 pong"的接收端是**合法**的。
但 PoseWebSocketClient.onOpen 里写的是 lastPongAtMs = timing.nowMs(),
于是心跳循环的守卫 if (lastPongAtMs > 0 && silentFor > limit) **恒为真**:
对任何接收端都会「判定链路已死 → 主动断链 → 重连」,默认参数
(heartbeatIntervalMs=5000)下即**每 15 秒断一次**,正是用户抱怨的「频繁重连」。

为什么以前没暴露:我们自己的接收端(Windows PoseServer)**会**回 pong
(T-WIN-020 实测 9 次 ping 全部收到 pong),这条路径在自家联调里永远走不到。

修复:onOpen 复位 lastPongAtMs = 0L(0 = 本连接还没收到过 pong),
只有「曾经回过 pong、之后又不回了」才判定链路已死 —— 这正是该诊断本来的意图。

二、可注入计时 Timing(时钟 + 等待)
契约 §7.1 的退避序列 0.5s→1→2→4→8→15s 封顶若要端到端验证,真实等待约 30 秒;
「连续 3 个心跳周期没收到 pong」也要等几十秒。抽成接口后:
  * 生产默认 SystemTiming = 真实时钟 + 真实 delay,行为与抽取前完全一致
    (10 处 System.currentTimeMillis() 与 2 处 delay() 改走 timing);
  * 测试注入「可控时钟 + 立即返回的等待」,毫秒级跑完整条序列。

三、新增 4 项 JVM 用例(PoseWebSocketBackoffTest,无需设备)
  * 退避序列在 15 秒封顶且不无限增长:连续失败下断言第 1..8 次等待分别落在
    500/1000/2000/4000/8000/15000/15000/15000 的 ±20% 区间内,且真实耗时 < 5 秒;
  * 等待走注入实现:前三次重连真实耗时 < 1.5 秒(真实退避需 3.5 秒);
  * **合规但从不回 pong 的接收端不会被判定链路已死**(修复前此处失败):
    走过 6 个心跳周期仍为 streaming、无「判定链路已死」日志;
  * 曾经回过 pong 之后停止回复时判定链路已死并重连:诊断仍然有效。

实测:./gradlew :app:testDebugUnitTest → 7 个测试类 / 68 项用例 / 0 失败(原 64)。
build/verify.sh 全绿(签名、APK 元数据 0.2.2/202、native 体检、ABI、R8、dex)。
版本号 0.2.1/201 → 0.2.2/202。

写测试踩的两个坑(已写入任务文档):
  1. 不要在瞬时重连下读 stats.nextRetryMs(等待立即返回后已被清零,与重连循环竞争),
     等待时长应取注入实现记录的事实;
  2. 虚拟时钟需要一个真实让出点(FakeTiming.await 里 2ms 真实 delay),
     否则心跳循环会在网络回调落地前把时钟推过阈值。
2026-09-18 02:34:54 +08:00
zijunchiang 6dfd90f7ca fix(reconnect): 上一次会话的关闭码污染下一次会话(v0.2.1)+ 重连链路的真实 WebSocket 集成测试
ReconnectPolicyTest(11 项)验的是**纯函数**「给定关闭码应该怎么退避」,而 ⑦
「连接频繁重连」的缺陷往往出在**调用点**。新增 PoseWebSocketReconnectTest(8 项):
用 MockWebServer.withWebSocketUpgrade 在 JVM 上起**真实** WebSocket 服务端
(能按 §3.2 回 welcome、按 §3.6 回 pong、按指定关闭码关连接),无需设备即可覆盖
「该重连的」与「不该重连的」两条分支。Robolectric 只用来提供 android.os.Build 桩。

它抓到的真实缺陷(本版修复):
  上一次会话的关闭码会污染下一次会话,导致重新开始推流后失去自动重连能力。
  复现:第一次连接被对端以 1001 关闭(按 §7 不重连,正确)→ 用户再点「开始推流」
  → 这一次是网络层失败(服务端还没起来),按 §7 **应当**重连 → 但 connect() 只重置了
  reconnectCounter 与 lastPongAtMs,**没有清 lastCloseCode** → scheduleReconnect()
  仍按 1001 判定 → 直接 stopped,**再也不会自动重连**。
  测试失败信息(修复前):
    ✗ 上一次 1001 关闭后重新连接,遇到网络失败仍应按策略重连
      AssertionError: 当前 state=STOPPED,lastCloseCode=1001,attempts=0

修复(PoseWebSocketClient.kt):
  * connect():清掉会话级关闭状态(lastCloseCode=0、lastCloseReason="");
  * onFailure():记录 recordClose(0, …)(契约 §7 中 0=未收到关闭帧 → 应重连),
    顺带让界面「最近一次断开原因」显示本次失败而不是旧值。

用例清单:握手→streaming 与 welcome 保活参数解析、按协商间隔发 ping 收 pong、
1011 按退避重连且等待落在策略 ±20% 区间、成功后计数清零、1001/1002 不重连、
主动断开不重连、上一次 1001 后再连遇网络失败仍应重连(本次回归锁)、
服务端未启动时 1.5s 内 ≤3 次尝试(退避生效)。

实测:./gradlew :app:testDebugUnitTest → 6 个测试类 / 64 项用例 / 0 失败
(原 56 项);build/verify.sh 全绿(签名、APK 版本元数据 0.2.1/201、native 库体检、
ABI、R8、dex)。版本号 0.2.0/200 → 0.2.1/201。

踩坑:第一版 MockWebServer 夹具的 101 响应没有回显 Sec-WebSocket-Protocol
(RFC 6455 要求),客户端记录的 negotiatedProtocol 为空导致断言失败 —— 那是
**夹具**不合规而非客户端问题,已在夹具里补上。写「真实对端」夹具时要按 RFC/契约
把服务端做对,否则会把夹具缺陷误判成产品缺陷。
2026-09-17 05:44:03 +08:00
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
zijunchiang c207e7dadd test(accept): 发布包级端到端验收(读 zip 内 APK)+ 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)。

新增 build/accept-release.sh,对一个 zip(或已解压目录)跑完:
  1) sha256(有 SHA256SUMS 时);
  2) 解压 + 文件清单(apk / README.txt / THIRD_PARTY.md / contracts/VERSION);
  3) APK 的 AndroidManifest versionName/versionCode = 发布版本(复用
     check-apk-version.sh —— 安装界面与应用信息页读的就是它);
  4) ABI 恰为 arm64-v8a + armeabi-v7a、无模拟器 ABI,且两个 ABI 下都有
     MediaPipe native 库(T-AND-012 崩溃根因之一);
  5) assets/pose_landmarker_lite.task 在包内;dex 里仍有
     poselandmarker/PoseLandmarker(R8 没整体裁掉);
  6) 有 apksigner 时顺带验签;
  7) 包内 contracts/VERSION 与仓库逐项一致(抓「APK 用旧契约构建、快照拷了新的」)。
package-release.sh 结尾打印下一步验收命令;README 补用法。

实测(对**已发布**的 v0.2.0 资产重新下载后跑):sha256 OK、版本元数据 0.2.0/200、
ABI 恰为两个真机 ABI、两个 ABI 下都有 MediaPipe native、模型资源在、dex 命中
PoseLandmarker、apksigner 通过、契约快照与仓库一致 → 发布包级验收全部通过。

反向验证(真实负例):
  * 篡改包内 contracts/VERSION 的 data-models=0.6.0 →
    ✗ data-models:包内 0.6.0,仓库 0.7.0,退出码 1;
  * 期望版本写 0.2.1 对 v0.2.0 的包 → ✗ versionName 是 '0.2.0',期望 '0.2.1'
    且 ✗ versionCode 是 200,期望 201。

诚实声明:**装到真机跑 30 分钟**这条脚本替代不了,仍属设备实测项;本脚本能证明的是
「装得上、不会因打包配置而崩」。verify.sh 仍全绿。
2026-09-17 04:51:16 +08:00
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
zijunchiang ad18427928 docs: 补齐部署必需的 Gitea 域名与属主(不再脱敏成占位符)
域名与仓库属主属于「部署必需且非隐私」的信息,此前写成 `<Gitea 地址>/<owner>`
会让读者无法照抄 clone 命令。补成真实值:gitea.kasugano.cn / zijunchiang
(口令与访问令牌仍为占位符)。README.md 的 clone 地址与跨仓库链接、
README.txt 的 clone 地址均已更新。
2026-09-17 03:07:25 +08:00
18 changed files with 1968 additions and 25 deletions
+91 -5
View File
@@ -9,17 +9,62 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
## 状态
**阶段三已完成**: T-AND-001 -> T-AND-012 全部落地(含重连状态机与 MediaPipe 崩溃修复)。
**阶段三已完成**: T-AND-001 -> T-AND-017 全部落地(含重连状态机与**真实 WebSocket 重连链路测试**、MediaPipe 崩溃修复、APK 产物版本元数据校验、native 库体检)。
| 验收项 | 结果 |
|--|--|
| 单元测试 | OK **56 项全部通过**(JVM,无需设备;新增重连策略与 MediaPipe 诊断用例) |
| 单元测试 | OK **68 项全部通过**(JVM,无需设备;含 MockWebServer 真实 WebSocket 的重连链路集成测试) |
| **跨端金标准** | OK Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** |
| `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) |
| 签名校验 | OK `apksigner` 退出码 0, v2 方案,CN=OpenDance Studio Dev |
| 包信息 | OK `com.opendance.studio` v0.2.0, minSdk 29(Android 10+)/ targetSdk 34 |
| 包信息 | OK `com.opendance.studio` v0.2.2 (versionCode 202), 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.2`/`versionCode=202`,与 `project.yaml` 一致(T-AND-013) |
| native 库体检 | OK ELF 类型匹配双 ABI、`DT_NEEDED` 闭包完整(无缺失依赖)、`PT_LOAD` 页对齐 `0x4000`(16 KB 页兼容)、`zipalign -P 16` 通过(T-AND-015) |
## v0.2.2 修订(退避封顶端到端验证 + 修「合规接收端被周期性误判链路已死」)
**缺陷(T-AND-017)**:契约 §3.6 明确「接收端收到任何一帧都算活动,`ping` **无需额外应答**
即可保活,`pong` 只用于诊断」—— 即"合规但不回 `pong`"的接收端是**合法**的。
但 `onOpen` 里写的是 `lastPongAtMs = now`,于是心跳循环的守卫
`if (lastPongAtMs > 0 && silentFor > limit)` **恒为真**:对任何接收端都会
「判定链路已死 → 主动断链 → 重连」,默认参数下**每 15 秒一次** —— 正是"频繁重连"的症状。
**为什么以前没暴露**:我们自己的 Windows 接收端**会**回 `pong`,这条路径在自家联调里走不到。
**修复**:`onOpen` 复位 `lastPongAtMs = 0L`(0 = 本连接还没收到过 pong),
只有"曾经回过 pong、之后又不回了"才判定链路已死。
**新增可注入计时 `Timing`**(时钟 + 等待,生产默认与原来完全一致):
契约 §7.1 的退避序列真实等待约 30 秒,写在测试里既慢又易 flaky。
注入可控时钟后,`PoseWebSocketBackoffTest`(4 项,无需设备)可以在**毫秒级**断言
`0.5→1→2→4→8→15→15s`(各 ±20% 抖动区间)并确认没有真实等待;
另有两条用例分别锁住「合规不回 pong **不**被误判」与「回过 pong 后停止回复仍能正确诊断」。
```
$ ./gradlew :app:testDebugUnitTest
测试类 7 个,用例 68 项,失败 0 # 原 64 项
```
## v0.2.1 修订(重连链路的真实 WebSocket 集成测试 + 一处真实缺陷)
**缺陷**:上一次会话的关闭码会**污染下一次会话**,导致重新开始推流后失去自动重连能力。
复现路径:第一次连接被对端以 `1001` 关闭(按契约 §7 **不重连**,正确)→ 用户再次点
「开始推流」→ 这一次是**网络层失败**(服务端还没起来),按 §7 **应当**重连 →
但 `connect()` 只重置了重连计数与 pong 时间,**没有清 `lastCloseCode`**,
`scheduleReconnect()` 仍按 1001 判定 → 直接 `stopped`,**再也不会自动重连**。
**修复**:`connect()` 清掉会话级关闭状态(`lastCloseCode = 0`、`lastCloseReason = ""`);
`onFailure()` 记录 `recordClose(0, …)`(0 = 未收到关闭帧 → 应重连),
顺带让界面「最近一次断开原因」显示本次失败而不是旧值。
**为什么之前没发现**:`ReconnectPolicyTest`(11 项)验的是**纯函数**「给定关闭码怎么退避」,
而缺陷在**调用点**(关闭码没被正确重置)。新增
`app/src/test/java/com/opendance/studio/PoseWebSocketReconnectTest.kt`(8 项):
用 `MockWebServer.withWebSocketUpgrade` 在 JVM 上起**真实** WebSocket 服务端,
覆盖「该重连的」(1011 / 服务器未启动)与「不该重连的」(1000/1001/1002 / 主动断开)两条分支,
并断言重连等待取自策略且落在 ±20% 区间、成功后计数清零。
```
## 快速开始
@@ -65,7 +110,7 @@ bash build/package-release.sh # 打发布包
### 2. 获取代码与本地配置
```bash
git clone https://<Gitea 地址>/<owner>/opendance-android.git
git clone https://gitea.kasugano.cn/zijunchiang/opendance-android.git
cd opendance-android
```
@@ -255,12 +300,53 @@ app/src/main/java/com/opendance/studio/
(= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从
`contracts/VERSION` 解析后注入 `BuildConfig`;Kotlin 界面代码里不出现任何版本号字面量。
**native 库体检(T-AND-015)**:`ExceptionInInitializerError` 这类「一装上就崩」的
问题大多出在 native 库打包上,而这几项**无设备也能查**:
```bash
python3 build/check_native_libs.py dist-release/opendance-android-v0.2.1/app-release.apk
# -> 每个 ABI 下每个 .so 的 ELF 类型 / DT_NEEDED 闭包 / PT_LOAD 页对齐
# -> ✓ native 库体检通过:arm64-v8a, armeabi-v7a | ELF 类型匹配、依赖闭包完整、16 KB 页对齐
```
纯 Python 标准库实现(不需要 readelf / NDK),已接入 `verify.sh` 与 `accept-release.sh`。
与 `readelf` 在 6 个 `.so` 上逐项交叉验证一致;三条检测分支都用真实负例反向验证过
(错 ABI 的 .so、缺失依赖、页对齐不足)。**它不替代真机**,只把「打包配置错误」挡在发布前。
**发布包级验收(T-AND-014)**:`build/verify.sh` 验的是本地刚构建的 APK,
而用户装的是 zip 里的 `app-release.apk`。发布前对包再跑一次:
```bash
bash build/accept-release.sh dist-release/opendance-android-v0.2.2.zip
# -> sha256 / 文件清单 / AndroidManifest versionName+versionCode / ABI+MediaPipe native
# / 模型资源 / dex 中的 PoseLandmarker / 验签 / 包内契约快照与仓库一致
# -> 发布包级验收全部通过
```
已对**已发布的 v0.2.0 资产**(重新下载)跑通,并用「篡改包内 `contracts/VERSION`」
与「期望版本写错」两个负例反向验证过。
**产物侧复核(T-AND-013)**:`versionName` 是**构建配置**,而安装界面 / 应用信息页 /
`dumpsys package` 读的是 **APK 的 `AndroidManifest`**。构建缓存、复用旧 APK、
打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(**Windows 客户端**
的 v0.2.1 就踩过同类坑:配置文件全一致,exe 属性页显示 0.1.0)。因此新增:
```bash
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
# -> AndroidManifest versionName = 0.2.1 / versionCode = 201
# -> ✓ APK 版本元数据一致:0.2.1(versionCode 201)
```
`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) - 关于页必须展示的开源声明
`contracts/` 是**只读快照**。任何跨端契约变更必须先回到
[`opendance-web`](https://<Gitea 地址>/<owner>/opendance-web) 修改并提升版本。
[`opendance-web`](https://gitea.kasugano.cn/zijunchiang/opendance-web) 修改并提升版本。
## 跨端一致性:金标准字节向量测试
+49 -2
View File
@@ -1,5 +1,5 @@
================================================================
OpenDance Studio -- Android 辅助 App v0.2.0 使用说明
OpenDance Studio -- Android 辅助 App v0.2.2 使用说明
================================================================
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
@@ -45,7 +45,7 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
2) 获取代码与本地配置
git clone https://<Gitea 地址>/<owner>/opendance-android.git
git clone https://gitea.kasugano.cn/zijunchiang/opendance-android.git
cd opendance-android
local.properties 是本地文件(已在 .gitignore 中,不入库),内容:
@@ -125,6 +125,34 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
6.5) v0.2.2 修订
1. 修一处真实缺陷(T-AND-017): 契约 §3.6 明确「ping 无需额外应答即可保活,
pong 只用于诊断」,即"合规但不回 pong"的接收端是合法的;但客户端的
onOpen 里把 lastPongAtMs 设成了当前时刻,于是"连续 3 个心跳周期没收到 pong
就判定链路已死"对任何接收端都会触发 —— 默认参数下每 15 秒主动断一次并重连,
正是"频繁重连"的症状。现改为 0(= 本连接还没收到过 pong),
只有"曾经回过 pong、之后又不回了"才判定链路已死。
2. 新增可注入计时 Timing(时钟 + 等待),生产默认与原来完全一致;
测试注入可控时钟后,契约 §7.1 的退避序列(0.5→1→2→4→8→15s 封顶)能在
毫秒级端到端验证,不再需要等 30 秒。
3. 新增 4 项 JVM 用例(无需设备):退避封顶序列逐值断言、注入实现不产生真实等待、
合规不回 pong 不被误判、回过 pong 后停止回复仍能正确诊断。
4. 单元测试 68 项(原 64)。
6.5) v0.2.1 修订
1. 重连链路的真实 WebSocket 集成测试(T-AND-016): 用 MockWebServer 的
withWebSocketUpgrade 在 JVM 上起真实 WebSocket 服务端,覆盖「该重连的」
(异常关闭码 1011 / 服务端未启动)与「不该重连的」(1000/1001/1002 /
用户主动断开)两条分支,并断言重连等待取自策略、落在 ±20% 区间、
成功后计数清零。无需设备。
2. 修一处真实缺陷: 上一次会话的关闭码会污染下一次会话 —— 第一次被 1001 关闭后
用户重新开始推流,若这一次是网络层失败,客户端仍按 1001 判定「不重连」,
于是再也不会自动重连,只能反复手点。现在 connect() 会清掉会话级关闭状态,
网络失败按「未收到关闭帧(0)」记录并按 §7 重连。
3. 单元测试 64 项(原 56 项)。
7) 自检清单
./gradlew testDebugUnitTest
@@ -138,6 +166,25 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
预期:退出码 0(无输出即通过)
0a) native 库体检(T-AND-015;无设备也能查「一装上就崩」的打包问题)
python3 build/check_native_libs.py app/build/outputs/apk/release/app-release.apk
预期:「✓ native 库体检通过」——每个 ABI 下每个 .so 的 ELF 类型、
依赖闭包(DT_NEEDED,非系统库必须同 ABI 自带)、页对齐(16 KB 兼容)。
0) 发布包级验收(对**发布包**而不是本地构建;文档 §7.5)
bash build/accept-release.sh dist-release/opendance-android-v0.2.2.zip
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
读包内 APK 的 AndroidManifest 版本元数据、核对 ABI 与 MediaPipe native 库、
模型资源、dex 里的 PoseLandmarker、验签,以及包内契约快照与仓库是否一致。
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
预期:AndroidManifest versionName = <project.yaml 的 version>
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
+7 -2
View File
@@ -28,8 +28,8 @@ android {
targetSdk = 34
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
// versionName 用 X.Y.Z,两者同一次发布一起更新。
versionCode = 200
versionName = "0.2.0"
versionCode = 202
versionName = "0.2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
@@ -225,6 +225,11 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21")
// MockWebServer:JVM 上起**真实** WebSocket 服务端(支持 withWebSocketUpgrade),
// 用来验证「断线 → 按契约 §7.1 退避重连 / 特定关闭码不重连」这条只在真机上
// 才暴露的链路。版本与 okhttp 保持一致,避免协议实现偏斜。
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
// Robolectric:在 JVM 上跑 Android 框架 + Compose,用于验证
// 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷
testImplementation("org.robolectric:robolectric:4.13")
@@ -47,9 +47,35 @@ import kotlin.random.Random
* 5. **可诊断**:close code / 子协议协商结果 / 首帧时间写入 [ConnectionLog],
* 界面可一键导出。
*/
/**
* 计时与等待的可注入实现(T-AND-017)。
*
* 为什么需要:[契约 §7.1](../contracts/websocket-keypoints.md) 的退避序列
* `0.5s→1→2→4→8→15s 封顶` 若要**端到端**验证,真实等待需要 ~30 秒;
* 而"缺 pong 判定链路已死"(连续 3 个心跳周期)也要等几十秒。
* 把「现在几点」与「等多久」抽出来之后,测试可以注入可控时钟 + 立即返回的等待,
* 在毫秒级把这两条路径跑完整 —— 否则这类逻辑只能靠纯函数单测或真机观察。
*
* 生产实现就是 [SystemTiming](真实 `System.currentTimeMillis()` + `delay()`),
* 行为与抽取前完全一致。
*/
interface Timing {
fun nowMs(): Long
suspend fun await(delayMs: Long)
}
object SystemTiming : Timing {
override fun nowMs(): Long = System.currentTimeMillis()
override suspend fun await(delayMs: Long) {
delay(delayMs)
}
}
class PoseWebSocketClient(
private val scope: CoroutineScope,
private val subprotocol: String = BuildConfig.WS_SUBPROTOCOL,
/** 计时/等待实现;测试注入可控时钟,生产用 [SystemTiming] */
private val timing: Timing = SystemTiming,
) {
/** 契约 §8 的连接状态(wireName 与接收端 GDScript 侧同名,便于对照日志) */
@@ -146,6 +172,12 @@ class PoseWebSocketClient(
this.autoReconnect = autoReconnect
reconnectCounter.set(0)
lastPongAtMs = 0L
// **每次新会话都清掉上一次的关闭码**:`scheduleReconnect()` 是按
// `lastCloseCode` 判断「该不该重连」的(契约 §7:1000/1001/1002 不重连),
// 若这里不清,上一次的 1001 会把本次会话的**网络失败**也判成「对端主动关闭」,
// 于是新版一启动就不再自动重连,用户只能反复手点。
// 由 T-AND-016 的 JVM 集成测试(MockWebServer + 真实 WebSocket)复现并锁住。
_stats.value = _stats.value.copy(lastCloseCode = 0, lastCloseReason = "")
ConnectionLog.record("connect() -> $targetUrl(autoReconnect=$autoReconnect)")
val host = runCatching { URI(targetUrl).host }.getOrNull()
if (host != null && !isIpLiteral(host)) {
@@ -221,7 +253,14 @@ class PoseWebSocketClient(
reconnectCounter.set(0)
fpsWindowStart = 0L
fpsWindowFrames = 0
lastPongAtMs = System.currentTimeMillis()
// **0 = 本连接还没收到过任何 pong**。
// 契约 §3.6 明确:接收端收到任何一帧都算活动,`ping` **无需额外应答**即可保活,
// `pong` 只用于诊断 —— 也就是说"合规但不回 pong"的接收端是合法的。
// 原先这里写成 `lastPongAtMs = now`,于是下面的"连续 3 个心跳周期没收到 pong
// 就判定链路已死"对**任何**接收端都会触发:15 秒重连一次,正是"频繁重连"的症状。
// 现在只有"曾经回过 pong、之后又不回了"才会判定链路已死(由 T-AND-017 的
// JVM 集成测试两条对应用例锁住)。
lastPongAtMs = 0L
_stats.value = _stats.value.copy(
state = State.HANDSHAKING,
url = url,
@@ -268,6 +307,9 @@ class PoseWebSocketClient(
stopHeartbeat()
// response != null 时说明 HTTP 升级被拒(例如路径/子协议不对)
val httpCode = response?.code ?: 0
// 记成 0(未收到关闭帧):契约 §7 里 0 属于「异常断开 → 应重连」,
// 同时让界面「最近一次断开原因」显示的是**本次**失败,而不是上一次的旧值。
recordClose(0, if (httpCode > 0) "HTTP $httpCode:$reason" else reason)
update(
state = State.STOPPED,
error = if (httpCode > 0) "HTTP $httpCode:$reason" else reason,
@@ -280,7 +322,7 @@ class PoseWebSocketClient(
_stats.value = _stats.value.copy(
lastCloseCode = code,
lastCloseReason = reason,
lastDisconnectAtMs = System.currentTimeMillis(),
lastDisconnectAtMs = timing.nowMs(),
)
ConnectionLog.record("断开:${ReconnectPolicy.closeCodeLabel(code)}")
}
@@ -303,7 +345,7 @@ class PoseWebSocketClient(
ConnectionLog.record("安排第 $attempt 次重连,等待 ${delayMs}ms")
reconnectJob = scope.launch {
delay(delayMs)
timing.await(delayMs)
if (!manualClose && autoReconnect) {
openSocket(initial = false)
}
@@ -341,13 +383,13 @@ class PoseWebSocketClient(
val interval = _stats.value.heartbeatIntervalMs.coerceAtLeast(
ReconnectPolicy.MIN_HEARTBEAT_MS,
)
delay(interval)
timing.await(interval)
val state = _stats.value.state
if (state != State.STREAMING && state != State.DEGRADED && state != State.HANDSHAKING) {
continue
}
sendPing()
val silentFor = System.currentTimeMillis() - lastPongAtMs
val silentFor = timing.nowMs() - lastPongAtMs
val limit = interval * ReconnectPolicy.MISSED_PONG_LIMIT
if (lastPongAtMs > 0 && silentFor > limit) {
ConnectionLog.record(
@@ -370,7 +412,7 @@ class PoseWebSocketClient(
sendJson(
JSONObject().apply {
put("type", "ping")
put("sentAtMs", System.currentTimeMillis())
put("sentAtMs", timing.nowMs())
},
)
}
@@ -407,7 +449,7 @@ class PoseWebSocketClient(
sentCounter.incrementAndGet()
if (_stats.value.firstFrameAtMs == 0L) {
val now = System.currentTimeMillis()
val now = timing.nowMs()
_stats.value = _stats.value.copy(firstFrameAtMs = now)
ConnectionLog.record("streaming:首帧已发出")
}
@@ -416,7 +458,7 @@ class PoseWebSocketClient(
}
fpsWindowFrames++
val now = System.currentTimeMillis()
val now = timing.nowMs()
if (fpsWindowStart == 0L) fpsWindowStart = now
val elapsed = now - fpsWindowStart
val fps = if (elapsed >= FPS_WINDOW_MS) {
@@ -538,10 +580,10 @@ class PoseWebSocketClient(
onRemoteConfig?.invoke(json)
}
"pong" -> {
lastPongAtMs = System.currentTimeMillis()
lastPongAtMs = timing.nowMs()
pongCounter.incrementAndGet()
val sentAt = json.optLong("sentAtMs", 0L)
val rtt = if (sentAt > 0) System.currentTimeMillis() - sentAt else -1L
val rtt = if (sentAt > 0) timing.nowMs() - sentAt else -1L
if (rtt >= 0) {
_stats.value = _stats.value.copy(
roundTripMs = rtt,
@@ -0,0 +1,231 @@
package com.opendance.studio
import com.opendance.studio.net.PoseWebSocketClient
import com.opendance.studio.net.ReconnectPolicy
import com.opendance.studio.net.Timing
import com.opendance.studio.util.ConnectionLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger
/**
* T-AND-017:**退避封顶**与**缺 pong 判定链路已死**的端到端验证(JVM,无需设备)
*
* 为什么单有 `ReconnectPolicyTest` 不够:`ReconnectPolicy` 只是纯函数
* 「第 n 次应该等多久」,而契约 §7.1 真正承诺的是**整条序列**
* `0.5s → 1 → 2 → 4 → 8 → 15s 封顶`(之后一直是 15s)——
* 那需要连续 7 次失败重连,真实等待约 **30 秒**,写在集成测试里既慢又易 flaky。
*
* 因此给 [PoseWebSocketClient] 抽了一个可注入的 [Timing]
* (「现在几点」+「等多久」)。生产默认是真实时钟与 `delay()`,行为不变;
* 测试注入**可控时钟 + 立即返回的等待**,于是:
* * 30 秒的退避序列可以在毫秒级跑完并逐值断言;
* * 「连续 3 个心跳周期没收到 pong 就判定链路已死」也能在不等待的情况下触发。
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PoseWebSocketBackoffTest {
/** 可控时钟 + 立即返回的等待:记录每次等待时长,并推进虚拟时钟 */
private class FakeTiming : Timing {
var now = 1_000_000L
val awaited = Collections.synchronizedList(mutableListOf<Long>())
override fun nowMs(): Long = now
override suspend fun await(delayMs: Long) {
awaited.add(delayMs)
now += delayMs.coerceAtLeast(1L)
// 只让出 2ms *真实*时间:网络回调(pong / welcome)需要有机会落地,
// 否则心跳循环会在 pong 到达之前就把虚拟时钟推过阈值。
// 2ms × 个位数次数,整体仍远小于真实退避的 30 秒。
kotlinx.coroutines.delay(2)
}
fun snapshot(): List<Long> = synchronized(awaited) { awaited.toList() }
}
private lateinit var server: MockWebServer
private lateinit var scope: CoroutineScope
private val clients = mutableListOf<PoseWebSocketClient>()
@Before
fun setUp() {
ConnectionLog.clear()
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
clients.forEach { runCatching { it.disconnect() } }
scope.cancel()
runCatching { server.shutdown() }
ConnectionLog.clear()
}
private fun newClient(timing: Timing): PoseWebSocketClient {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
return PoseWebSocketClient(scope, timing = timing).also { clients.add(it) }
}
private fun wsUrl(): String = server.url("/pose").toString().replace("http://", "ws://")
private fun waitUntil(timeoutMs: Long = 8_000, what: String, cond: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (cond()) return
Thread.sleep(10)
}
throw AssertionError("等待超时(${timeoutMs}ms):$what")
}
@Test
fun `退避序列在 15 秒封顶,且不会无限增长`() {
// 每个连接都直接失败:用 SocketPolicy.DISCONNECT_AT_START(升级前断开)
repeat(12) {
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
}
val timing = FakeTiming()
val client = newClient(timing)
val t0 = System.currentTimeMillis()
client.connect(wsUrl())
// 等到第 8 次重连被安排(真实等待为 0,因为 FakeTiming 立即返回)
waitUntil(what = "安排到第 8 次重连") { client.stats.value.reconnectAttempts >= 8 }
val elapsed = System.currentTimeMillis() - t0
val delays = timing.snapshot()
assertTrue("应记录到至少 8 次等待(实际 ${delays.size})", delays.size >= 8)
// 契约 §7.1:min(500ms * 2^(n-1), 15000ms) × (1 ± 20%)
val expectedBase = listOf(500L, 1_000L, 2_000L, 4_000L, 8_000L, 15_000L, 15_000L, 15_000L)
for ((index, base) in expectedBase.withIndex()) {
val actual = delays[index]
val low = (base * (1 - ReconnectPolicy.JITTER_RATIO)).toLong()
val high = (base * (1 + ReconnectPolicy.JITTER_RATIO)).toLong()
assertTrue(
"第 ${index + 1} 次重连等待应落在 [$low, $high](基础值 ${base}ms),实际 ${actual}ms",
actual in low..high,
)
}
assertTrue(
"封顶后必须一直是 15000ms(不得增长);实测前 8 次:$delays",
delays.take(8).drop(5).all { it in 12_000L..18_000L },
)
assertTrue(
"虚拟时钟应被推着走(证明等待走的是注入实现而非真实 delay);" +
"真实耗时 ${elapsed}ms 应远小于 30 秒",
elapsed < 5_000,
)
}
@Test
fun `等待走注入实现——真实 delay 不会被调用(耗时对照)`() {
repeat(4) { server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) }
val timing = FakeTiming()
val client = newClient(timing)
val t0 = System.currentTimeMillis()
client.connect(wsUrl())
waitUntil(what = "安排到第 3 次重连") { client.stats.value.reconnectAttempts >= 3 }
val elapsed = System.currentTimeMillis() - t0
// 真实退避前三次合计 3.5 秒;注入实现下应远小于 1 秒
assertTrue("注入 FakeTiming 后不应真的等待(实测 ${elapsed}ms)", elapsed < 1_500)
// 注意:不要在瞬时重连下断言 stats.nextRetryMs —— 等待立即返回后,
// 客户端已经进入下一次尝试并把该字段清零,读取会与重连循环竞争(第一版就踩了)。
// 等待时长本身由注入实现记录,是可靠的事实来源。
assertTrue("注入实现应记录到等待(实际 ${timing.snapshot()})", timing.snapshot().size >= 3)
}
/** 造一个只回 welcome、pong 行为可配置的接收端 */
private fun enqueueReceiver(replyToPings: (n: Int) -> Boolean) {
val pingCount = AtomicInteger(0)
server.enqueue(
MockResponse()
.setHeader("Sec-WebSocket-Protocol", "opendance.pose.v1")
.withWebSocketUpgrade(object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
when {
text.contains("\"hello\"") -> webSocket.send(
"""{"type":"welcome","protocol":"opendance.pose.v1",""" +
""""sessionId":"s1","expectedFps":30,""" +
""""silentTimeoutMs":3000,"heartbeatIntervalMs":1000}""",
)
text.contains("\"ping\"") -> {
if (replyToPings(pingCount.incrementAndGet())) {
webSocket.send("""{"type":"pong","sentAtMs":1,"serverAtMs":2}""")
}
}
}
}
}),
)
}
/**
* 契约 §3.6:接收端收到任何一帧都算活动,**`ping` 无需额外应答即可保活**,
* `pong` 只用于诊断 —— 所以"合规但不回 pong"的接收端**不能**被判定为链路已死。
*
* 这是本轮修掉的一个真实缺陷:`onOpen` 里把 `lastPongAtMs` 设成了当前时刻,
* 于是"连续 3 个心跳周期没收到 pong"对任何接收端都会触发 —— 默认参数下
* **每 15 秒主动断一次并重连**,正是用户抱怨的"频繁重连"。
*/
@Test
fun `合规但从不回 pong 的接收端不会被判定链路已死`() {
enqueueReceiver { false } // 从不回 pong
val timing = FakeTiming()
val client = newClient(timing)
client.connect(wsUrl())
waitUntil(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
// 让虚拟时钟走过 6 个心跳周期(远超 MISSED_PONG_LIMIT=3)
waitUntil(timeoutMs = 10_000, what = "心跳发出 6 次") { timing.snapshot().size >= 6 }
assertTrue(
"不应出现「判定链路已死」(契约 §3.6:pong 可选):${ConnectionLog.entries()}",
ConnectionLog.entries().none { it.contains("判定链路已死") },
)
assertEquals("连接应仍然打开", PoseWebSocketClient.State.STREAMING, client.stats.value.state)
assertEquals("本连接一次 pong 都没收到", 0, client.stats.value.pongsReceived)
}
/** 反过来:曾经回过 pong、之后不回了 —— 这时"链路已死"的判断是有效的诊断。 */
@Test
fun `曾经回过 pong 之后停止回复时判定链路已死并重连`() {
enqueueReceiver { n -> n == 1 } // 只在第一次回 pong
val timing = FakeTiming()
val client = newClient(timing)
client.connect(wsUrl())
waitUntil(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
waitUntil(timeoutMs = 10_000, what = "出现「判定链路已死」日志") {
ConnectionLog.entries().any { it.contains("判定链路已死") }
}
assertTrue(
"日志应说明是连续 ${ReconnectPolicy.MISSED_PONG_LIMIT} 个周期未收到 pong",
ConnectionLog.entries().any {
it.contains("连续 ${ReconnectPolicy.MISSED_PONG_LIMIT} 个心跳周期未收到 pong")
},
)
waitUntil(timeoutMs = 10_000, what = "进入重连") {
client.stats.value.state == PoseWebSocketClient.State.RECONNECTING ||
client.stats.value.reconnectAttempts > 0
}
}
}
@@ -0,0 +1,311 @@
package com.opendance.studio
import com.opendance.studio.net.PoseWebSocketClient
import com.opendance.studio.net.ReconnectPolicy
import com.opendance.studio.util.ConnectionLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* T-AND-016:**重连链路**的 JVM 集成测试(真实 WebSocket,不需要设备)
*
* 为什么单有 [com.opendance.studio.ReconnectPolicyTest] 不够:
* `ReconnectPolicy` 是纯函数,测的是「给定关闭码应该怎么退避」;
* 而 ⑦「频繁重连」的缺陷往往出在**调用点**——关闭码是否被正确记录、
* 重连计数是否在成功后清零、上一次的关闭码会不会污染下一次会话。
* 这些只有把**真实的 OkHttp WebSocket 接到一个真实的服务端**上才能验出来。
*
* 这里用 MockWebServer 的 `withWebSocketUpgrade` 起真服务端:
* * 能按契约 §3.2 回 `welcome`(含 silentTimeoutMs / heartbeatIntervalMs);
* * 能按契约 §3.6 回 `pong`;
* * 能用**指定关闭码**关连接(1001 对端离开 / 1011 内部错误 / …),
* 从而分别验证「不重连」与「按退避重连」两条分支。
*
* 注:Robolectric 只用来提供 `android.os.Build.*` 等桩(客户端构造时会读机型名)。
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PoseWebSocketReconnectTest {
private lateinit var server: MockWebServer
private lateinit var scope: CoroutineScope
private val clients = mutableListOf<PoseWebSocketClient>()
/** 服务端侧收到过的文本消息(hello / ping / stats / bye) */
private val serverTexts = CopyOnWriteArrayList<String>()
/** 服务端侧见过的连接次数(含重连) */
private val serverOpens = AtomicInteger(0)
private var welcomeSilentTimeoutMs = 4_500L
private var closeCodeOnHello = 0
private var closeAfterHelloDelayMs = 0L
@Before
fun setUp() {
ConnectionLog.clear()
serverTexts.clear()
serverOpens.set(0)
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
clients.forEach { runCatching { it.disconnect() } }
scope.cancel()
runCatching { server.shutdown() }
ConnectionLog.clear()
}
// ------------------------------------------------------------------ 夹具
private fun enqueueWebSocket() {
server.enqueue(
// 必须回显子协议:RFC 6455 要求服务端在 101 响应里带上选中的
// Sec-WebSocket-Protocol,客户端据此记录「协商结果」。
// (第一版夹具没带,客户端 negotiatedProtocol 就是空串 —— 那是**夹具**不合规,
// 不是客户端的问题;这条断言因此改成先在夹具里把服务端做对。)
MockResponse()
.setHeader("Sec-WebSocket-Protocol", "opendance.pose.v1")
.withWebSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
serverOpens.incrementAndGet()
}
override fun onMessage(webSocket: WebSocket, text: String) {
serverTexts.add(text)
when {
text.contains("\"hello\"") -> {
webSocket.send(
"""{"type":"welcome","protocol":"opendance.pose.v1",""" +
""""sessionId":"sess-$serverOpens","expectedFps":30,""" +
""""silentTimeoutMs":$welcomeSilentTimeoutMs,""" +
""""heartbeatIntervalMs":${ReconnectPolicy.heartbeatIntervalMs(welcomeSilentTimeoutMs)}}""",
)
if (closeCodeOnHello != 0) {
// 模拟「对端主动关闭 / 服务重启」等场景
Thread {
Thread.sleep(closeAfterHelloDelayMs)
webSocket.close(closeCodeOnHello, "test-close-$closeCodeOnHello")
}.start()
}
}
text.contains("\"ping\"") -> {
webSocket.send("""{"type":"pong","sentAtMs":1,"serverAtMs":2}""")
}
}
}
}),
)
}
private fun newClient(): PoseWebSocketClient {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
return PoseWebSocketClient(scope).also { clients.add(it) }
}
private fun wsUrl(): String = server.url("/pose").toString().replace("http://", "ws://")
/** 轮询等待条件成立(避免依赖固定 sleep 造成的偶发失败) */
private fun waitFor(timeoutMs: Long = 6_000, what: String, cond: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (cond()) return true
Thread.sleep(25)
}
throw AssertionError("等待超时(${timeoutMs}ms):$what")
}
private fun waitUntilOrFalse(timeoutMs: Long, cond: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (cond()) return true
Thread.sleep(25)
}
return false
}
// ------------------------------------------------------------------ 用例
@Test
fun `握手成功后进入 streaming 并解析 welcome 的保活参数`() {
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
val stats = client.stats.value
assertTrue("sessionId 应来自 welcome(实际 '${stats.serverSessionId}')", stats.serverSessionId.isNotEmpty())
assertEquals("opendance.pose.v1", stats.negotiatedProtocol)
assertEquals(welcomeSilentTimeoutMs, stats.silentTimeoutMs)
assertEquals(ReconnectPolicy.heartbeatIntervalMs(welcomeSilentTimeoutMs), stats.heartbeatIntervalMs)
assertTrue(
"hello 必须带 deviceId(契约 §3.1,接收端据此去重)",
serverTexts.any { it.contains("\"type\":\"hello\"") && it.contains("\"deviceId\"") },
)
}
@Test
fun `按协商的心跳间隔发送应用层 ping 并收到 pong`() {
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
waitFor(timeoutMs = 8_000, what = "收到 pong") { client.stats.value.pongsReceived >= 1 }
assertTrue(
"服务端必须真的收到 ping 文本帧(协议层 ping 对应用层不可见,不能替代)",
serverTexts.any { it.contains("\"type\":\"ping\"") },
)
}
@Test
fun `异常关闭码(1011)会按退避策略重连且成功后计数清零`() {
closeCodeOnHello = 1011
enqueueWebSocket() // 第一次连接:握手后立刻被服务端以 1011 关闭
enqueueWebSocket() // 重连后的第二次连接
val client = newClient()
client.connect(wsUrl())
// 第一次连接被关闭 → 进入 reconnecting 且尝试次数为 1,等待时间落在策略区间内
waitFor(what = "进入 reconnecting") { client.stats.value.state == PoseWebSocketClient.State.RECONNECTING }
val firstAttempt = client.stats.value.reconnectAttempts
assertEquals(1, firstAttempt)
val expected = ReconnectPolicy.baseDelayMs(1) // 500ms
val low = (expected * (1 - ReconnectPolicy.JITTER_RATIO)).toLong() // 400ms
val high = (expected * (1 + ReconnectPolicy.JITTER_RATIO)).toLong() // 600ms
assertTrue(
"第 1 次重连等待应落在策略区间 [$low, $high](实际 ${client.stats.value.nextRetryMs})",
client.stats.value.nextRetryMs in low..high,
)
// 真的重连上了(服务端看到第 2 次 upgrade),并且计数清零、状态回到 streaming
waitFor(timeoutMs = 8_000, what = "第二次连接建立") { serverOpens.get() >= 2 }
waitFor(what = "重连后回到 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
assertEquals("连接成功后重连计数必须清零(契约 §7.1)", 0, client.stats.value.reconnectAttempts)
}
@Test
fun `1001 对端离开不重连`() {
closeCodeOnHello = 1001
enqueueWebSocket()
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertEquals(1001, client.stats.value.lastCloseCode)
assertTrue("1001 不应重连(契约 §7)", client.stats.value.reconnectAttempts == 0)
val reconnected = waitUntilOrFalse(2_000) { serverOpens.get() >= 2 }
assertTrue("1001 之后不允许再建立新连接", !reconnected)
assertTrue(
"日志里应记录「不重连」便于真机排障",
ConnectionLog.entries().any { it.contains("不重连") },
)
}
@Test
fun `1002 协议错误不重连`() {
closeCodeOnHello = 1002
enqueueWebSocket()
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertEquals(1002, client.stats.value.lastCloseCode)
assertTrue("1002 不应重连(重连也不会成功)", !waitUntilOrFalse(2_000) { serverOpens.get() >= 2 })
}
@Test
fun `用户主动 disconnect 不重连`() {
enqueueWebSocket()
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "进入 streaming") { client.stats.value.state == PoseWebSocketClient.State.STREAMING }
client.disconnect()
waitFor(what = "进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertTrue("主动断开不应重连", !waitUntilOrFalse(2_000) { serverOpens.get() >= 2 })
}
/**
* 本用例是 ⑦ 的关键回归:**上一次会话的关闭码不能污染下一次会话**。
*
* 复现路径(修复前):
* 1. 第一次连接被对端以 1001 关闭 → lastCloseCode = 1001,按契约不重连(正确);
* 2. 用户再次点「开始推流」→ connect();
* 3. 这一次是**网络层失败**(服务端还没起来 / 被拒),应当按 §7 重连;
* 4. 但 `connect()` 没有清理 lastCloseCode,scheduleReconnect() 仍按 1001 判定
* → 直接 stopped,**再也不会自动重连**,用户只能反复手点。
*/
@Test
fun `上一次 1001 关闭后重新连接,遇到网络失败仍应按策略重连`() {
// 1. 第一次:对端以 1001 关闭
closeCodeOnHello = 1001
enqueueWebSocket()
val client = newClient()
client.connect(wsUrl())
waitFor(what = "第一次连接进入 stopped") { client.stats.value.state == PoseWebSocketClient.State.STOPPED }
assertEquals(1001, client.stats.value.lastCloseCode)
// 2. 用户重新开始推流,但这一轮服务端 connect 就会失败(直接关掉 MockWebServer)
closeCodeOnHello = 0
server.shutdown()
client.connect(server.url("/pose").toString().replace("http://", "ws://"))
// 3. 网络失败必须触发重连(进入 reconnecting 或至少 attempts > 0)
val recovered = waitUntilOrFalse(6_000) {
client.stats.value.state == PoseWebSocketClient.State.RECONNECTING ||
client.stats.value.reconnectAttempts > 0
}
assertTrue(
"上一次的 1001 不应影响本次会话:网络失败后必须按 §7 重连" +
"(当前 state=${client.stats.value.state}," +
"lastCloseCode=${client.stats.value.lastCloseCode}," +
"attempts=${client.stats.value.reconnectAttempts})",
recovered,
)
}
@Test
fun `服务端未启动时的连续重连尝试遵守退避(不会疯狂重连)`() {
server.shutdown() // 没有服务端
val client = newClient()
val t0 = System.currentTimeMillis()
client.connect(server.url("/pose").toString().replace("http://", "ws://"))
waitFor(timeoutMs = 6_000, what = "至少安排一次重连") { client.stats.value.reconnectAttempts >= 1 }
// 1.5 秒内不应超过 3 次尝试:0.5s → 1s → 2s,说明退避生效
Thread.sleep(1_500)
val attempts = client.stats.value.reconnectAttempts
val elapsed = System.currentTimeMillis() - t0
assertTrue(
"退避必须生效:${elapsed}ms 内只应有少量尝试(实际 $attempts)",
attempts <= 3,
)
assertNotNull(client.stats.value.lastError)
}
}
+215
View File
@@ -0,0 +1,215 @@
#!/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 "============================================================"
+55
View File
@@ -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 <apk> [期望版本 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 <apk> [期望版本 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
+217
View File
@@ -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 <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))
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""APK native 库体检(T-AND-015):纯标准库解析 ELF,不需要 readelf / NDK。
为什么需要它(T-AND-012 的「先按 ABI / native / R8 排查」):
`ExceptionInInitializerError` / `UnsatisfiedLinkError` 这类「一装上就崩」的问题,
根因绝大多数落在 native 库上,而它们**都能在没有设备的情况下查出来**:
1. **ABI 与 ELF 机器类型不匹配** —— 把 x86_64 的 .so 放进 `lib/arm64-v8a/`,
安装能过、一加载就崩;
2. **DT_NEEDED 依赖缺失** —— 例如只打包了 `libmediapipe_jni.so` 却没带
`libc++_shared.so`(MediaPipe 早期版本的经典坑)。系统库由 Android 提供,
非系统库必须**同 ABI 目录内**自带;
3. **16 KB 页面对齐** —— Android 15 起部分设备页大小为 16 KB,`PT_LOAD` 的
`p_align` 若仍是 4 KB(0x1000),这些设备上会直接加载失败(Google Play
2025-11 起也要求 16 KB 对齐);
4. **路径/命名**:.so 必须直接位于 `lib/<abi>/` 下(子目录不会被安装)。
本脚本把这些一次查完,并给出可读结论。**它不能替代真机**(运行时行为、
GPU/驱动差异仍需设备),但能把「打包配置错误」这一类彻底挡在发布前。
用法:
python3 build/check_native_libs.py <apk> [--expected-abis arm64-v8a,armeabi-v7a]
[--min-page-align 16384] [--json]
退出码:0 通过 / 1 有问题 / 2 用法或文件问题。
"""
from __future__ import annotations
import argparse
import json
import struct
import sys
import zipfile
# Android 系统提供的库(不需要 App 自带)
SYSTEM_LIBS = {
"libc.so", "libm.so", "libdl.so", "liblog.so", "libz.so", "libstdc++.so",
"libandroid.so", "libjnigraphics.so", "libEGL.so", "libGLESv1_CM.so",
"libGLESv2.so", "libOpenSLES.so", "libOpenMAXAL.so", "libnativewindow.so",
"libvulkan.so", "libsync.so", "libcutils.so", "libutils.so", "libui.so",
"libgui.so", "libbinder.so", "libhardware.so", "libcamera2ndk.so",
"libmediandk.so", "libaaudio.so", "libneuralnetworks.so", "libOpenCL.so",
}
# ABI → 期望的 (ELF class, e_machine)
ABI_MACHINE = {
"arm64-v8a": (2, 183), # ELFCLASS64, EM_AARCH64
"armeabi-v7a": (1, 40), # ELFCLASS32, EM_ARM
"x86_64": (2, 62), # ELFCLASS64, EM_X86_64
"x86": (1, 3), # ELFCLASS32, EM_386
}
MACHINE_NAME = {183: "AArch64", 40: "ARM", 62: "x86-64", 3: "Intel 80386"}
class ElfError(Exception):
pass
# 人类可读输出;--json 模式下静音,保证 stdout 是**纯 JSON**(便于脚本消费)
EMIT = True
def emit(*parts) -> None:
if EMIT:
print(*parts)
def parse_elf(data: bytes) -> dict:
"""解析出 {elf_class, machine, is_64, needed[], soname, page_aligns[]}"""
if len(data) < 64 or data[:4] != b"\x7fELF":
raise ElfError("不是 ELF 文件(魔数 %r)" % data[:4])
elf_class = data[4] # 1=32bit, 2=64bit
data_encoding = data[5] # 1=LE, 2=BE
if data_encoding != 1:
raise ElfError("只支持小端 ELF(EI_DATA=%d)" % data_encoding)
is_64 = elf_class == 2
if not is_64 and elf_class != 1:
raise ElfError("未知的 EI_CLASS=%d" % elf_class)
machine = struct.unpack_from("<H", data, 18)[0]
if is_64:
e_phoff, = struct.unpack_from("<Q", data, 32)
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 54)
e_shoff, = struct.unpack_from("<Q", data, 40)
else:
e_phoff, = struct.unpack_from("<I", data, 28)
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 42)
e_shoff, = struct.unpack_from("<I", data, 32)
loads = [] # (vaddr, offset, filesz)
page_aligns = []
dynamic = None
for i in range(e_phnum):
base = e_phoff + i * e_phentsize
if base + e_phentsize > len(data):
raise ElfError("程序头越界")
p_type, = struct.unpack_from("<I", data, base)
# 程序头的字段顺序(ELF64):type@0 flags@4 offset@8 vaddr@16 paddr@24
# filesz@32 memsz@40 align@48
# 注意第三个 QWORD 是 **p_paddr** 而不是 p_filesz —— 第一版就是在这里
# 取错字段,导致 vaddr→offset 映射永远失败、DT_NEEDED 解析为空,
# 于是「依赖闭包完整」变成了一句**假通过**(靠和 readelf 交叉比对才发现)。
if is_64:
p_offset, p_vaddr = struct.unpack_from("<QQ", data, base + 8)
p_filesz, = struct.unpack_from("<Q", data, base + 32)
p_align, = struct.unpack_from("<Q", data, base + 48)
else:
# ELF32:type@0 offset@4 vaddr@8 paddr@12 filesz@16 memsz@20
# flags@24 align@28
p_offset, p_vaddr = struct.unpack_from("<II", data, base + 4)
p_filesz, = struct.unpack_from("<I", data, base + 16)
p_align, = struct.unpack_from("<I", data, base + 28)
if p_type == 1: # PT_LOAD
loads.append((p_vaddr, p_offset, p_filesz))
page_aligns.append(p_align)
elif p_type == 2: # PT_DYNAMIC
dynamic = (p_offset, p_filesz)
needed: list[str] = []
soname = ""
if dynamic is not None:
d_off, d_size = dynamic
entry_size = 16 if is_64 else 8
strtab_vaddr = strsz = None
entries = []
for off in range(d_off, min(d_off + d_size, len(data)), entry_size):
if is_64:
d_tag, d_val = struct.unpack_from("<QQ", data, off)
else:
d_tag, d_val = struct.unpack_from("<II", data, off)
if d_tag == 0:
break
entries.append((d_tag, d_val))
if d_tag == 5: # DT_STRTAB
strtab_vaddr = d_val
elif d_tag == 10: # DT_STRSZ
strsz = d_val
def vaddr_to_offset(vaddr: int) -> int | None:
for v, o, sz in loads:
if v <= vaddr < v + sz:
return o + (vaddr - v)
return None
strtab_off = vaddr_to_offset(strtab_vaddr) if strtab_vaddr is not None else None
if strtab_off is not None:
limit = strtab_off + (strsz or 0)
for d_tag, d_val in entries:
if d_tag not in (1, 14): # DT_NEEDED / DT_SONAME
continue
start = strtab_off + d_val
end = data.find(b"\0", start, limit if limit > start else len(data))
if end < 0:
continue
name = data[start:end].decode("utf-8", "replace")
if d_tag == 1:
needed.append(name)
else:
soname = name
return {
"elf_class": elf_class,
"machine": machine,
"needed": needed,
"soname": soname,
"page_aligns": page_aligns,
}
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("apk")
parser.add_argument("--expected-abis", default="arm64-v8a,armeabi-v7a")
parser.add_argument("--min-page-align", type=int, default=16384)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv[1:])
expected_abis = [a.strip() for a in args.expected_abis.split(",") if a.strip()]
global EMIT
EMIT = not args.json
try:
zf = zipfile.ZipFile(args.apk)
except (OSError, zipfile.BadZipFile) as exc:
print("✗ 无法读取 APK:%s" % exc, file=sys.stderr)
return 2
libs: dict[str, list[str]] = {}
for name in zf.namelist():
parts = name.split("/")
if len(parts) == 3 and parts[0] == "lib" and name.endswith(".so"):
libs.setdefault(parts[1], []).append(parts[2])
for abi in libs:
libs[abi].sort()
problems: list[str] = []
notes: list[str] = []
if not libs:
print("✗ APK 里没有任何 lib/<abi>/*.so", file=sys.stderr)
return 1
found_abis = sorted(libs.keys())
if found_abis != sorted(expected_abis):
problems.append("ABI 集合为 %s,期望 %s" % (found_abis, sorted(expected_abis)))
report: dict = {"abis": {}}
emit(" APK native 库体检:%s" % args.apk)
for abi in found_abis:
emit(" [%s]" % abi)
entries = []
abi_files = set(libs[abi])
for lib in libs[abi]:
raw = zf.read("lib/%s/%s" % (abi, lib))
try:
info = parse_elf(raw)
except ElfError as exc:
problems.append("%s/%s:%s" % (abi, lib, exc))
emit(" ✗ %-32s %s" % (lib, exc))
continue
want_class, want_machine = ABI_MACHINE.get(abi, (None, None))
machine_name = MACHINE_NAME.get(info["machine"], str(info["machine"]))
if want_machine is not None and (info["elf_class"], info["machine"]) != (want_class, want_machine):
problems.append(
"%s/%s:ELF class=%d machine=%s,与该 ABI 不符(该 ABI 下会加载失败)"
% (abi, lib, info["elf_class"], machine_name)
)
emit(" ✗ %-32s ELF 类型不符:class=%d machine=%s" % (lib, info["elf_class"], machine_name))
else:
emit(" ✓ %-32s ELF%s %s" % (lib, "64" if info["elf_class"] == 2 else "32", machine_name))
# 依赖闭包:非系统库必须在同 ABI 目录内自带
missing = [
dep for dep in info["needed"]
if dep not in SYSTEM_LIBS and dep not in abi_files
]
if missing:
problems.append("%s/%s:缺少依赖 %s(同 ABI 目录内没有)" % (abi, lib, ", ".join(missing)))
emit(" ✗ 缺少依赖:%s" % ", ".join(missing))
else:
extra = [d for d in info["needed"] if d not in SYSTEM_LIBS]
emit(" ✓ 依赖闭包完整(需自带 %s;系统库 %d 项)" % (
", ".join(extra) if extra else "无", len(info["needed"]) - len(extra)))
# 16 KB 页面对齐
bad_align = [a for a in info["page_aligns"] if a < args.min_page_align]
if bad_align:
problems.append(
"%s/%s:PT_LOAD p_align=%s < %d,Android 15+ 的 16 KB 页设备会加载失败"
% (abi, lib, ", ".join(hex(a) for a in bad_align), args.min_page_align)
)
emit(" ✗ 页对齐不足:%s" % ", ".join(hex(a) for a in bad_align))
else:
aligns = sorted(set(info["page_aligns"]))
emit(" ✓ 页对齐 %s(≥ %d,16 KB 页兼容)" % (
" ".join(hex(a) for a in aligns), args.min_page_align))
entries.append({"lib": lib, **{k: info[k] for k in ("machine", "elf_class", "needed", "soname")},
"page_aligns": info["page_aligns"]})
report["abis"][abi] = entries
# libc++ 共享库的经典坑:若某个 .so 需要它,必须自带
for abi, libs_here in libs.items():
if "libc++_shared.so" in libs_here:
notes.append("%s 自带 libc++_shared.so" % abi)
report["problems"] = problems
report["notes"] = notes
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
if problems:
print("")
for p in problems:
print(" ✗ %s" % p, file=sys.stderr)
print(" ✗ native 库体检发现 %d 个问题" % len(problems), file=sys.stderr)
return 1
emit(" ✓ native 库体检通过:%s | ELF 类型匹配、依赖闭包完整、16 KB 页对齐" % ", ".join(found_abis))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+14 -3
View File
@@ -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)"
@@ -90,3 +98,6 @@ echo ""
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip"
echo " 再 adb install -r $NAME/app-release.apk(Android 10+)"
echo ""
echo "下一步(文档 §7.5:每次发布都要有一条读产物本身的验收):"
echo " bash build/accept-release.sh $ZIP"
+30 -1
View File
@@ -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
@@ -108,6 +118,25 @@ else
fi
done
# ---- T-AND-015:native 库体检(ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)----
NPY="${PYTHON_BIN:-python3}"
if command -v "$NPY" >/dev/null 2>&1; then
# 期望 ABI 用上面已经算好的 EXPECTED_ABIS(项目声明的真机 ABI),
# 而不是 APK 自己的 ABI 列表 —— 否则「ABI 是否符合预期」这条就自我循环了
if "$NPY" "$PROJECT_DIR/build/check_native_libs.py" "$RELEASE_APK" \
--expected-abis "$(echo "$EXPECTED_ABIS" | tr '\n' ',' | sed 's/,$//')" \
>/tmp/apk_native.txt 2>&1; then
grep -E "✓ native 库体检通过" /tmp/apk_native.txt | sed 's/^/ /'
else
echo "✗ native 库体检未通过:"
sed 's/^/ /' /tmp/apk_native.txt | tail -20
FAILED=1
fi
else
echo "✗ 未找到 $NPY,无法做 native 库体检"
FAILED=1
fi
# ---- T-AND-012:R8 不得裁掉 MediaPipe(seeds 由 -printseeds 输出) ----
SEEDS="app/build/outputs/mapping/release/seeds.txt"
if [ -f "$SEEDS" ]; then
+22 -2
View File
@@ -1,5 +1,5 @@
project: opendance-android-app
version: 0.2.0
version: 0.2.2
repository: opendance-android
default_branch: main
platform: Android 10+(minSdk 29 / targetSdk 34)
@@ -74,9 +74,29 @@ 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
- id: T-AND-014
file: tasks/014-release-acceptance.md
scope: 发布包级端到端验收(读 zip 内 APK:版本元数据 / ABI+MediaPipe native / 模型资源 / R8 / 契约快照)
status: done
- id: T-AND-015
file: tasks/015-native-libs.md
scope: APK native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐),纯标准库实现
status: done
- id: T-AND-016
file: tasks/016-reconnect-integration.md
scope: JVM 内以真实 WebSocket(MockWebServer)验证重连链路 + 修「上一次关闭码污染下一次会话」
status: done
- id: T-AND-017
file: tasks/017-backoff-and-pong.md
scope: 可注入计时 + 退避封顶端到端验证 + 修「合规不回 pong 的接收端被周期性误判链路已死」
status: done
deliverable:
artifact: opendance-android-v0.2.0.apk
artifact: opendance-android-v0.2.2.apk
packaging: Gradle 命令行 / Android Studio
includes:
- app-release.apk
+84
View File
@@ -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` 节点;`<manifest>` 的属性名(`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 时给出安装提示 |
+63
View File
@@ -0,0 +1,63 @@
# T-AND-014:发布包级端到端验收(读 zip 内的 APK)
- **状态**: done
- **依赖**: T-AND-013(APK 版本元数据)/ T-AND-009(打包)
- **文档**: v1.6 §6.3 / §7.5 / §11
- **契约**: 不涉及(验收流程)
## 为什么需要
`build/verify.sh` 验的是 `app/build/outputs/` 里**刚构建**的 APK,而用户装的是
zip 里的 `app-release.apk`。两者之间隔着打包与分发;本项目另两端都因此翻过车
(Web v0.7.4 的实体注册、Windows v0.2.1 的 PE 版本元数据),所以三端统一要求:
**每次发布都要有一条读产物本身的验收**(文档 §7.5)。
## 实现:`build/accept-release.sh`
对一个 zip(或已解压目录)跑完:
| 步骤 | 检查 |
|--|--|
| 1 | sha256 校验(有 `SHA256SUMS` 时) |
| 2 | 解压 + 文件清单(`app-release.apk` / `README.txt` / `THIRD_PARTY.md` / `contracts/VERSION`) |
| 3 | **APK 的 `AndroidManifest` `versionName`/`versionCode`** = 发布版本(复用 `build/check-apk-version.sh`;安装界面与应用信息页读的就是它) |
| 4 | ABI 恰为 `arm64-v8a` + `armeabi-v7a`(无模拟器 ABI),且**两个 ABI 下都有 MediaPipe native 库**(T-AND-012 崩溃根因之一) |
| 5 | `assets/pose_landmarker_lite.task` 随包分发;**dex 里仍有 `poselandmarker/PoseLandmarker`**(R8 没整体裁掉) |
| 6 | 有 `apksigner` 时顺带验签(可选,不影响结论) |
| 7 | 包内 `contracts/VERSION` 与**仓库** `contracts/VERSION` 逐项一致(抓「APK 用旧契约构建、快照拷了新的」) |
## 实测(对**已发布**的 v0.2.0 资产,重新下载后跑)
```
[accept] sha256 校验:opendance-android-v0.2.0.zip: OK
✓ app-release.apk / README.txt / THIRD_PARTY.md / contracts/VERSION
✓ APK 版本元数据一致:0.2.0(versionCode 200)
✓ ABI 恰为 arm64-v8a + armeabi-v7a(真机 ABI)
✓ lib/arm64-v8a 下有 MediaPipe native 库
✓ lib/armeabi-v7a 下有 MediaPipe native 库
✓ MediaPipe 模型资源随包分发(assets/pose_landmarker_lite.task)
✓ dex 中存在 poselandmarker/PoseLandmarker(R8 未整体裁掉 MediaPipe;命中 1 个 dex)
✓ apksigner 验签通过(退出码 0)
✓ 包内契约快照与仓库 contracts/VERSION 一致
发布包级验收全部通过
```
## 反向验证
| 负例 | 结果 |
|--|--|
| 把包内 `contracts/VERSION` 的 `data-models` 改成 `0.6.0` | ✗ `data-models:包内 0.6.0,仓库 0.7.0`,退出码 1 |
| 期望版本写 `0.2.1` 对 v0.2.0 的包 | ✗ `versionName 是 '0.2.0',期望 '0.2.1'` + `versionCode 是 200,期望 201` |
## 仍未覆盖(诚实声明)
**装到真机跑 30 分钟**这条只能真机做,脚本无法替代。本脚本能证明的是:
包能验签安装(ABI 与 native 库齐备、模型资源在、R8 没裁掉类、版本元数据正确),
即「安装后不会因打包配置而崩」;运行时稳定性仍需设备实测。
## 用法
```bash
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
# 或对已解压目录:bash build/accept-release.sh dist-release/opendance-android-v0.2.0
```
+73
View File
@@ -0,0 +1,73 @@
# T-AND-015:APK native 库体检(ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)
- **状态**: done
- **依赖**: T-AND-012(MediaPipe 初始化崩溃修复)/ T-AND-014(发布包级验收)
- **文档**: v1.6 §6.6 / §7.5
- **契约**: 不涉及(打包体检)
## 为什么需要
T-AND-012 的结论是「`ExceptionInInitializerError` 的根因在**集成配置**(native 解压 + R8 + ABI),
不是 MediaPipe 本身不稳定」。但当时的核查是**手工一次性**的:`unzip` 看 `lib/`、`grep` seeds、
`strings` 找类名。手工核查的问题是**下次照样可能漏**,而且漏了也不会有人发现 ——
这类缺陷只会在用户装上手机、点开相机的那一刻暴露。
本任务把它变成**可重复、可反向验证**的产物级检查:纯 Python 标准库解析 ELF,
不依赖 readelf / NDK,因此任何装了 python3 的机器(含 CI)都能跑。
## 检查项(都在「无设备也能查」的范围内)
| 项 | 为什么它会导致「一装上就崩」 |
|--|--|
| **ABI 与 ELF 类型匹配** | 把 x86_64 的 `.so` 放进 `lib/arm64-v8a/`:安装能过,一加载就 `UnsatisfiedLinkError` |
| **`DT_NEEDED` 依赖闭包** | 只打包 `libmediapipe_jni.so` 却没带它依赖的 `libc++_shared.so` 是 MediaPipe 的经典坑;非系统库必须**同 ABI 目录内**自带(系统库由 Android 提供,见脚本内白名单) |
| **16 KB 页对齐** | Android 15 起部分设备页大小为 16 KB;`PT_LOAD` 的 `p_align` 若仍是 4 KB(`0x1000`),这些设备上直接加载失败(Google Play 2025-11 起也要求 16 KB 对齐) |
| **zip 层对齐** | `zipalign -c -P 16 -v 4`(有 build-tools 时跑) |
实现:`build/check_native_libs.py`(新增,纯标准库)。已接入
`build/accept-release.sh`(发布包)与 `build/verify.sh`(本地构建)。
## 实测(已发布的 `opendance-android-v0.2.0.zip` 内 APK)
```
[arm64-v8a]
✓ libandroidx.graphics.path.so ELF64 AArch64 | 系统库 3 项 | 页对齐 0x4000
✓ libimage_processing_util_jni.so ELF64 AArch64 | 系统库 6 项 | 页对齐 0x4000
✓ libmediapipe_tasks_jni.so ELF64 AArch64 | 系统库 9 项 | 页对齐 0x4000
[armeabi-v7a]
✓ libandroidx.graphics.path.so ELF32 ARM | 系统库 3 项 | 页对齐 0x4000
✓ libimage_processing_util_jni.so ELF32 ARM | 系统库 6 项 | 页对齐 0x4000
✓ libmediapipe_tasks_jni.so ELF32 ARM | 系统库 9 项 | 页对齐 0x4000
✓ native 库体检通过 | ✓ zipalign -c -P 16 -v 4 通过
```
结论(对当前产物):**没有缺失的 native 依赖**(`libmediapipe_tasks_jni.so` 只依赖
`libdl/libjnigraphics/libEGL/libGLESv2/libm/libz/liblog/libandroid/libc`,全部系统库),
**两个 ABI 都已是 16 KB 页对齐**,ABI 目录内 ELF 类型正确。
也就是说:打包配置这一侧的「一装上就崩」风险已被排除;剩下的只能靠真机实测。
### 与 readelf 交叉验证
解析器输出与 `LC_ALL=C readelf -d/-lW` 在 6 个 `.so` 上**逐项一致**
(`DT_NEEDED` 集合、`PT_LOAD` `p_align`、`e_machine`)。
> **这次交叉验证抓到了解析器自己的一个 bug**:第一版程序头字段偏移写错 ——
> ELF64 的 `p_offset/p_vaddr` 之后第三个 QWORD 是 **`p_paddr`** 而不是 `p_filesz`
> (ELF32 同理)。后果是 `vaddr → offset` 映射永远失败、`DT_NEEDED` 解析为空,
> 于是页面照常打印「✓ 依赖闭包完整(需自带 无;系统库 **0** 项)」——
> 一句**假通过**。`0 项` 这个数字是唯一的破绽,靠和 readelf 比对才暴露出来。
> 教训:**「通过」的输出里必须带可核对的数量/取值**,否则假通过和真通过长得一模一样。
## 反向验证(三条例都实测会失败)
| 负例 | 结果 |
|--|--|
| 把宿主 x86_64 的 `libz.so.1` 塞进 `lib/arm64-v8a/libbogus.so` | ✗ 三条同时报:`ELF class=2 machine=x86-64,与该 ABI 不符`、`缺少依赖 libc.so.6`、`PT_LOAD p_align=0x1000 … < 16384` |
| 把 `libz.so` 从系统库白名单里去掉(证明闭包这条分支是活的) | ✗ `arm64-v8a/libmediapipe_tasks_jni.so:缺少依赖 libz.so`、`armeabi-v7a/...` 同样报 |
| `--expected-abis` 传成单个 ABI | ✗ `ABI 集合为 [...],期望 [...]` |
## 仍未覆盖(诚实声明)
- **运行时行为**:库能加载 ≠ 推理能跑。GPU/驱动差异、真机内存、连续 30 分钟稳定性仍需设备;
- **设备页大小**:这里只保证「16 KB 兼容」,无法证明某台具体设备上的实际行为;
- 因此本检查**不替代**真机验收,只是把「打包配置错误」这一类彻底挡在发布前。
+83
View File
@@ -0,0 +1,83 @@
# T-AND-016:重连链路的 JVM 集成测试(真实 WebSocket)+ 修一处真实缺陷
- **状态**: done
- **依赖**: T-AND-011(连接状态机与重连)
- **文档**: v1.6 §6.5 / §7.6
- **契约**: `websocket-keypoints 0.2.0` §7 / §7.1 / §8
## 为什么需要
`ReconnectPolicyTest`(11 项)验的是**纯函数**「给定关闭码应该怎么退避」;
但 ⑦「连接频繁重连」的缺陷往往出在**调用点**:关闭码有没有被正确记录、
重连计数有没有在成功后清零、上一次的状态会不会污染下一次会话。
这些只有把**真实的 OkHttp WebSocket 接到真实服务端**上才能验出来。
因此新增 `PoseWebSocketReconnectTest`(8 项):用 `MockWebServer.withWebSocketUpgrade`
在 JVM 上起**真实** WebSocket 服务端(能按 §3.2 回 `welcome`、按 §3.6 回 `pong`、
按指定关闭码关连接),无需设备即可覆盖两条分支:**该重连的**与**不该重连的**。
Robolectric 只用来提供 `android.os.Build.*` 桩。
## 它抓到的真实缺陷(本版修复)
**上一次会话的关闭码会污染下一次会话,导致新版一启动就失去自动重连能力。**
复现路径:
1. 第一次连接被对端以 `1001` 关闭 → `lastCloseCode = 1001`,按契约 §7 **不重连**(正确);
2. 用户再次点「开始推流」→ `connect()`;
3. 这一次是**网络层失败**(服务端还没起来 / 被拒),按 §7 **应当**重连;
4. 但 `connect()` 只重置了 `reconnectCounter` 与 `lastPongAtMs`,**没有清 `lastCloseCode`**;
`scheduleReconnect()` 仍按 1001 判定 → 直接 `stopped`,**再也不会自动重连**。
测试输出的失败信息(修复前):
```
✗ 上一次 1001 关闭后重新连接,遇到网络失败仍应按策略重连
java.lang.AssertionError: 上一次的 1001 不应影响本次会话:网络失败后必须按 §7 重连
(当前 state=STOPPED,lastCloseCode=1001,attempts=0)
```
**修复**(`PoseWebSocketClient.kt`):
| 位置 | 改动 |
|--|--|
| `connect()` | 清掉会话级关闭状态:`lastCloseCode = 0`、`lastCloseReason = ""`(每次新会话都从干净状态开始) |
| `onFailure()` | 记录 `recordClose(0, …)`(契约 §7 中 0 = 未收到关闭帧 → 应重连),同时让界面「最近一次断开原因」显示**本次**失败而不是旧值 |
## 用例清单(8 项)
| 用例 | 验的是什么 |
|--|--|
| 握手成功 → `streaming`,解析 `welcome` 的 `sessionId` / 保活参数,`hello` 带 `deviceId` | §3.1 / §3.2 发送端一侧 |
| 按协商间隔发送应用层 `ping` 并收到 `pong` | §1.1 / §3.6(协议层 ping 不能替代) |
| 异常关闭码 `1011` → 按退避重连、等待时间落在策略区间、成功后计数清零 | §7 / §7.1 / §8 |
| `1001` 对端离开 → `stopped` 且**不重连**,日志记录「不重连」 | §7 |
| `1002` 协议错误 → **不重连** | §7 |
| 用户主动 `disconnect()` → **不重连** | §7 |
| **上一次 1001 后再连、遇网络失败仍应重连** | 本次修复的缺陷(回归锁) |
| 服务端未启动时的连续尝试遵守退避(1.5 s 内 ≤3 次) | §7.1「不会疯狂重连」 |
## 实测
```
$ ./gradlew :app:testDebugUnitTest
BUILD SUCCESSFUL
测试类 6 个,用例 64 项,失败 0,跳过 0
(新增 8 项;修复前该项失败,其余 7 项通过)
```
`build/verify.sh` 全绿(含签名、版本元数据、native 库体检、ABI、R8、dex)。
## 踩坑记录
**夹具也要合规**:第一版 MockWebServer 的 101 响应没有回显
`Sec-WebSocket-Protocol`(RFC 6455 要求服务端带上选中的子协议),于是客户端记录的
`negotiatedProtocol` 是空串,断言失败。那是**夹具**不合规,不是客户端问题 ——
已在夹具里补上 `setHeader("Sec-WebSocket-Protocol", …)`。
写这类"真实对端"夹具时,要按 RFC / 契约把服务端做对,否则会把夹具缺陷误判成产品缺陷。
## 未覆盖(诚实声明)
- 真机 Wi-Fi 抖动、切网、进程被杀、Doze 省电等**真实断网场景**仍需设备实测;
- 契约 §7.1 的**封顶 15 s** 行为由纯函数单测覆盖(穷举 1..8 次),
集成测试只验证「等待时间取自策略且落在 ±20% 区间」,否则单测要跑 30 秒以上。
+82
View File
@@ -0,0 +1,82 @@
# T-AND-017:退避封顶与「pong 可选」语义的 JVM 验证(+ 修一处真实缺陷)
- **状态**: done
- **依赖**: T-AND-011(连接状态机与重连)/ T-AND-016(重连链路集成测试)
- **文档**: v1.6 §6.5 / §7.6.1
- **契约**: `websocket-keypoints 0.2.0` §1.1 / §3.6 / §7.1
## 一、可注入的计时(`Timing`)
契约 §7.1 的退避序列 `0.5s→1→2→4→8→15s 封顶` 若要**端到端**验证,真实等待约 **30 秒**;
「连续 3 个心跳周期没收到 pong」也要等几十秒。这类时限逻辑写在测试里既慢又易 flaky,
于是给 `PoseWebSocketClient` 抽了一个可注入的 `Timing`:
```kotlin
interface Timing {
fun nowMs(): Long
suspend fun await(delayMs: Long)
}
object SystemTiming : Timing { // 生产默认:真实时钟 + 真实 delay
override fun nowMs(): Long = System.currentTimeMillis()
override suspend fun await(delayMs: Long) { delay(delayMs) }
}
```
构造函数新增 `timing: Timing = SystemTiming`,行为与抽取前**完全一致**(10 处
`System.currentTimeMillis()` 与 2 处 `delay()` 改走 `timing`)。
测试注入「可控时钟 + 立即返回的等待」,于是:
* 30 秒的退避序列在**毫秒级**跑完并逐值断言(0.5/1/2/4/8/15/15s,各自 ±20% 抖动区间);
* 「缺 pong 判定链路已死」不用等待即可触发。
## 二、测试抓到的真实缺陷:**合规的接收端会被周期性误判为链路已死**
契约 §3.6 明确:
> 接收端收到任何一帧(二进制或文本)都算活动,`ping` **无需额外应答**即可保活;
> `pong` 只用于诊断。
也就是说「合规但不回 `pong`」的接收端是**合法**的。但 `onOpen` 里写的是:
```kotlin
lastPongAtMs = timing.nowMs() // ← 缺陷:连接一建立就等于"刚收到过 pong"
```
于是心跳循环里的守卫 `if (lastPongAtMs > 0 && silentFor > limit)` **恒为真**,
对**任何**接收端都会触发「判定链路已死 → 主动断链 → 重连」:
默认参数(`heartbeatIntervalMs = 5000`)下即 **每 15 秒断一次**,
正是用户抱怨的「频繁重连」。
**为什么以前没暴露**:我们自己的接收端(Windows `PoseServer`)**会**回 `pong`
(T-WIN-020 实测 9 次 ping 全部收到 pong),所以这条路径在自家联调里永远走不到;
只有契约允许的另一种实现才会踩。
**修复**:`onOpen` 复位为 `lastPongAtMs = 0L`(0 = 本连接还没收到过 pong)。
只有「曾经回过 pong、之后又不回了」才判定链路已死 —— 这正是该诊断本来的意图。
## 三、用例(`PoseWebSocketBackoffTest`,4 项)
| 用例 | 验的是什么 |
|--|--|
| 退避序列在 15 秒封顶,且不会无限增长 | 注入 `FakeTiming` 后用真实 MockWebServer 制造连续失败,断言第 1..8 次等待分别落在 500/1000/2000/4000/8000/15000/15000/15000 ±20% 内;**且真实耗时 < 5 秒** |
| 等待走注入实现——真实 delay 不会被调用 | 注入实现下前三次重连的真实耗时应 < 1.5 秒(真实退避需要 3.5 秒) |
| **合规但从不回 pong 的接收端不会被判定链路已死** | 契约 §3.6 语义:走过 6 个心跳周期后仍为 `streaming`、无「判定链路已死」日志(**修复前此处失败**) |
| 曾经回过 pong 之后停止回复时判定链路已死并重连 | 诊断仍然有效:日志出现「连续 3 个心跳周期未收到 pong」,随后进入重连 |
实测:`./gradlew :app:testDebugUnitTest` → **68 项用例 / 0 失败**(原 64)。
## 四、写测试踩的两个坑
1. **不要在瞬时重连下读 `stats.nextRetryMs`**:等待立即返回后,客户端已经进入下一次
尝试并把该字段清零,断言会与重连循环竞争(第一版就失败了)。
等待时长应取**注入实现记录的事实**,而不是状态字段的瞬时值。
2. **虚拟时钟需要一个真实让出点**:`FakeTiming.await` 若只推虚拟时钟而不让出,
心跳循环会在网络回调(`pong` / `welcome`)落地之前就把时钟推过阈值,
于是「回过一次 pong」的用例永远等不到那次 pong。加 2ms 真实 `delay` 即可,
整体耗时仍远小于真实退避的 30 秒。
## 五、未覆盖(诚实声明)
* 真机 Wi-Fi 抖动 / 切网 / 进程被杀 / Doze 省电仍需设备实测;
* `Timing` 只覆盖「时钟与等待」;OkHttp 自身的连接超时/写超时仍走真实时间
(那部分由 MockWebServer 场景覆盖,不涉及长等待)。