Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dfd90f7ca | ||
|
|
95429e8ce3 | ||
|
|
c207e7dadd | ||
|
|
31b6ddd0fa | ||
|
|
ad18427928 |
@@ -9,17 +9,40 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
**阶段三已完成**: T-AND-001 -> T-AND-012 全部落地(含重连状态机与 MediaPipe 崩溃修复)。
|
**阶段三已完成**: T-AND-001 -> T-AND-016 全部落地(含重连状态机与**真实 WebSocket 重连链路测试**、MediaPipe 崩溃修复、APK 产物版本元数据校验、native 库体检)。
|
||||||
|
|
||||||
| 验收项 | 结果 |
|
| 验收项 | 结果 |
|
||||||
|--|--|
|
|--|--|
|
||||||
| 单元测试 | OK **56 项全部通过**(JVM,无需设备;新增重连策略与 MediaPipe 诊断用例) |
|
| 单元测试 | OK **64 项全部通过**(JVM,无需设备;含 MockWebServer 真实 WebSocket 的重连链路集成测试) |
|
||||||
| **跨端金标准** | OK Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** |
|
| **跨端金标准** | OK Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** |
|
||||||
| `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) |
|
| `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) |
|
||||||
| 签名校验 | OK `apksigner` 退出码 0, v2 方案,CN=OpenDance Studio Dev |
|
| 签名校验 | 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.1 (versionCode 201), minSdk 29(Android 10+)/ targetSdk 34 |
|
||||||
| 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) |
|
| 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) |
|
||||||
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
|
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
|
||||||
|
| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.1`/`versionCode=201`,与 `project.yaml` 一致(T-AND-013) |
|
||||||
|
| native 库体检 | OK ELF 类型匹配双 ABI、`DT_NEEDED` 闭包完整(无缺失依赖)、`PT_LOAD` 页对齐 `0x4000`(16 KB 页兼容)、`zipalign -P 16` 通过(T-AND-015) |
|
||||||
|
|
||||||
|
## 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 +88,7 @@ bash build/package-release.sh # 打发布包
|
|||||||
### 2. 获取代码与本地配置
|
### 2. 获取代码与本地配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://<Gitea 地址>/<owner>/opendance-android.git
|
git clone https://gitea.kasugano.cn/zijunchiang/opendance-android.git
|
||||||
cd opendance-android
|
cd opendance-android
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -255,12 +278,53 @@ app/src/main/java/com/opendance/studio/
|
|||||||
(= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从
|
(= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从
|
||||||
`contracts/VERSION` 解析后注入 `BuildConfig`;Kotlin 界面代码里不出现任何版本号字面量。
|
`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.1.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/websocket-keypoints.md`](contracts/websocket-keypoints.md) - 239 字节关键点帧(本端为**发送方**)
|
||||||
- [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) - MediaPipe 33 点索引与坐标语义
|
- [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) - MediaPipe 33 点索引与坐标语义
|
||||||
- [`contracts/open-source-credits.md`](contracts/open-source-credits.md) - 关于页必须展示的开源声明
|
- [`contracts/open-source-credits.md`](contracts/open-source-credits.md) - 关于页必须展示的开源声明
|
||||||
|
|
||||||
`contracts/` 是**只读快照**。任何跨端契约变更必须先回到
|
`contracts/` 是**只读快照**。任何跨端契约变更必须先回到
|
||||||
[`opendance-web`](https://<Gitea 地址>/<owner>/opendance-web) 修改并提升版本。
|
[`opendance-web`](https://gitea.kasugano.cn/zijunchiang/opendance-web) 修改并提升版本。
|
||||||
|
|
||||||
## 跨端一致性:金标准字节向量测试
|
## 跨端一致性:金标准字节向量测试
|
||||||
|
|
||||||
|
|||||||
+34
-2
@@ -1,5 +1,5 @@
|
|||||||
================================================================
|
================================================================
|
||||||
OpenDance Studio -- Android 辅助 App v0.2.0 使用说明
|
OpenDance Studio -- Android 辅助 App v0.2.1 使用说明
|
||||||
================================================================
|
================================================================
|
||||||
|
|
||||||
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
|
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
|
||||||
@@ -45,7 +45,7 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
|
|||||||
|
|
||||||
2) 获取代码与本地配置
|
2) 获取代码与本地配置
|
||||||
|
|
||||||
git clone https://<Gitea 地址>/<owner>/opendance-android.git
|
git clone https://gitea.kasugano.cn/zijunchiang/opendance-android.git
|
||||||
cd opendance-android
|
cd opendance-android
|
||||||
|
|
||||||
local.properties 是本地文件(已在 .gitignore 中,不入库),内容:
|
local.properties 是本地文件(已在 .gitignore 中,不入库),内容:
|
||||||
@@ -125,6 +125,19 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
|
|||||||
App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
|
App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
|
||||||
Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
|
Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
|
||||||
|
|
||||||
|
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) 自检清单
|
7) 自检清单
|
||||||
|
|
||||||
./gradlew testDebugUnitTest
|
./gradlew testDebugUnitTest
|
||||||
@@ -138,6 +151,25 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
|
|||||||
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
|
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
|
||||||
预期:退出码 0(无输出即通过)
|
预期:退出码 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.1.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
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
预期:Success
|
预期:Success
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
|
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
|
||||||
// versionName 用 X.Y.Z,两者同一次发布一起更新。
|
// versionName 用 X.Y.Z,两者同一次发布一起更新。
|
||||||
versionCode = 200
|
versionCode = 201
|
||||||
versionName = "0.2.0"
|
versionName = "0.2.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
|
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
|
||||||
@@ -225,6 +225,11 @@ dependencies {
|
|||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21")
|
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,用于验证
|
// Robolectric:在 JVM 上跑 Android 框架 + Compose,用于验证
|
||||||
// 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷
|
// 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷
|
||||||
testImplementation("org.robolectric:robolectric:4.13")
|
testImplementation("org.robolectric:robolectric:4.13")
|
||||||
|
|||||||
@@ -146,6 +146,12 @@ class PoseWebSocketClient(
|
|||||||
this.autoReconnect = autoReconnect
|
this.autoReconnect = autoReconnect
|
||||||
reconnectCounter.set(0)
|
reconnectCounter.set(0)
|
||||||
lastPongAtMs = 0L
|
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)")
|
ConnectionLog.record("connect() -> $targetUrl(autoReconnect=$autoReconnect)")
|
||||||
val host = runCatching { URI(targetUrl).host }.getOrNull()
|
val host = runCatching { URI(targetUrl).host }.getOrNull()
|
||||||
if (host != null && !isIpLiteral(host)) {
|
if (host != null && !isIpLiteral(host)) {
|
||||||
@@ -268,6 +274,9 @@ class PoseWebSocketClient(
|
|||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
// response != null 时说明 HTTP 升级被拒(例如路径/子协议不对)
|
// response != null 时说明 HTTP 升级被拒(例如路径/子协议不对)
|
||||||
val httpCode = response?.code ?: 0
|
val httpCode = response?.code ?: 0
|
||||||
|
// 记成 0(未收到关闭帧):契约 §7 里 0 属于「异常断开 → 应重连」,
|
||||||
|
// 同时让界面「最近一次断开原因」显示的是**本次**失败,而不是上一次的旧值。
|
||||||
|
recordClose(0, if (httpCode > 0) "HTTP $httpCode:$reason" else reason)
|
||||||
update(
|
update(
|
||||||
state = State.STOPPED,
|
state = State.STOPPED,
|
||||||
error = if (httpCode > 0) "HTTP $httpCode:$reason" else reason,
|
error = if (httpCode > 0) "HTTP $httpCode:$reason" else reason,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+215
@@ -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 "============================================================"
|
||||||
Executable
+55
@@ -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
|
||||||
Executable
+217
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -23,6 +23,11 @@ set -euo pipefail
|
|||||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$PROJECT_DIR"
|
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)"
|
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)"
|
GRADLE_VERSION="$(sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' app/build.gradle.kts | head -1)"
|
||||||
VERSION="${1:-$GRADLE_VERSION}"
|
VERSION="${1:-$GRADLE_VERSION}"
|
||||||
@@ -36,11 +41,14 @@ OUT_DIR="$PROJECT_DIR/dist-release"
|
|||||||
STAGE="$OUT_DIR/$NAME"
|
STAGE="$OUT_DIR/$NAME"
|
||||||
APK="app/build/outputs/apk/release/app-release.apk"
|
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"
|
[ -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,校验魔数
|
# APK 是 zip,校验魔数
|
||||||
MAGIC=$(head -c 2 "$APK" | od -An -tx1 | tr -d ' \n')
|
MAGIC=$(head -c 2 "$APK" | od -An -tx1 | tr -d ' \n')
|
||||||
[ "$MAGIC" = "504b" ] || die "$APK 不是合法 APK(魔数 $MAGIC)"
|
[ "$MAGIC" = "504b" ] || die "$APK 不是合法 APK(魔数 $MAGIC)"
|
||||||
@@ -90,3 +98,6 @@ echo ""
|
|||||||
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
||||||
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip"
|
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip"
|
||||||
echo " 再 adb install -r $NAME/app-release.apk(Android 10+)"
|
echo " 再 adb install -r $NAME/app-release.apk(Android 10+)"
|
||||||
|
echo ""
|
||||||
|
echo "下一步(文档 §7.5:每次发布都要有一条读产物本身的验收):"
|
||||||
|
echo " bash build/accept-release.sh $ZIP"
|
||||||
|
|||||||
+30
-1
@@ -38,7 +38,7 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo " 3/3 产物校验(签名 / 包信息 / 模型资源)"
|
echo " 3/3 产物校验(签名 / 版本元数据 / 包信息 / 模型资源 / ABI / R8)"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
|
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
|
||||||
# 优先选用与 compileSdk 匹配的 build-tools,缺失时退回最新版
|
# 优先选用与 compileSdk 匹配的 build-tools,缺失时退回最新版
|
||||||
@@ -77,6 +77,16 @@ else
|
|||||||
|
|
||||||
"$BT/aapt2" dump badging "$RELEASE_APK" 2>/dev/null | head -1 | sed 's/^/ /'
|
"$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
|
if unzip -l "$RELEASE_APK" | grep -q "assets/pose_landmarker_lite.task"; then
|
||||||
echo "✓ MediaPipe 模型已随包分发"
|
echo "✓ MediaPipe 模型已随包分发"
|
||||||
else
|
else
|
||||||
@@ -108,6 +118,25 @@ else
|
|||||||
fi
|
fi
|
||||||
done
|
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 输出) ----
|
# ---- T-AND-012:R8 不得裁掉 MediaPipe(seeds 由 -printseeds 输出) ----
|
||||||
SEEDS="app/build/outputs/mapping/release/seeds.txt"
|
SEEDS="app/build/outputs/mapping/release/seeds.txt"
|
||||||
if [ -f "$SEEDS" ]; then
|
if [ -f "$SEEDS" ]; then
|
||||||
|
|||||||
+18
-2
@@ -1,5 +1,5 @@
|
|||||||
project: opendance-android-app
|
project: opendance-android-app
|
||||||
version: 0.2.0
|
version: 0.2.1
|
||||||
repository: opendance-android
|
repository: opendance-android
|
||||||
default_branch: main
|
default_branch: main
|
||||||
platform: Android 10+(minSdk 29 / targetSdk 34)
|
platform: Android 10+(minSdk 29 / targetSdk 34)
|
||||||
@@ -74,9 +74,25 @@ tasks:
|
|||||||
file: tasks/012-mediapipe-crash.md
|
file: tasks/012-mediapipe-crash.md
|
||||||
scope: MediaPipe 初始化崩溃修复 + 诊断
|
scope: MediaPipe 初始化崩溃修复 + 诊断
|
||||||
status: done
|
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
|
||||||
|
|
||||||
deliverable:
|
deliverable:
|
||||||
artifact: opendance-android-v0.2.0.apk
|
artifact: opendance-android-v0.2.1.apk
|
||||||
packaging: Gradle 命令行 / Android Studio
|
packaging: Gradle 命令行 / Android Studio
|
||||||
includes:
|
includes:
|
||||||
- app-release.apk
|
- app-release.apk
|
||||||
|
|||||||
@@ -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 时给出安装提示 |
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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 兼容」,无法证明某台具体设备上的实际行为;
|
||||||
|
- 因此本检查**不替代**真机验收,只是把「打包配置错误」这一类彻底挡在发布前。
|
||||||
@@ -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 秒以上。
|
||||||
Reference in New Issue
Block a user