feat(net,pose): 连接状态机与保活对齐 + MediaPipe 崩溃修复(T-AND-010/011/012)
契约 websocket-keypoints 0.1.0 -> 0.2.0(向后兼容,子协议名与帧 version 不变;
先在 opendance-web 修改并提升版本,再同步三端快照)。
T-AND-011:连接状态异常、频繁重连(查实 3 个根因)
1. 保活用错了层:Windows 端有 5s 静默超时(只看应用层收到的帧),而本端只有
OkHttp 的协议层 ping —— 它由底层库自动应答、**不暴露给应用层**,根本不能续命。
画面里没人时 5s 必被踢、再重连,循环往复。
- 契约新增 §1.1(welcome/config 声明 silentTimeoutMs 与 heartbeatIntervalMs,
且心跳必须 < 静默超时的一半)与 §3.6(应用层 ping/pong);
- Windows 端超时 5s -> 15s、期望心跳 5s,并在 welcome/config 中下发;
- 本端按声明发 ping,连续 3 个周期无 pong 才判定链路已死并主动重连。
2. 不该重连时也重连:契约新增 §7 关闭码策略,1000/1001/1002 与主动停止一律不重连。
3. frame_id 恒为 0:PoseDetector.detect() 算了 safeTimestamp 却忘了写回
lastTimestampMs,接收端的丢包检测因此完全失效。已修。
其他对齐:
- 契约新增 §8 连接状态机 idle -> resolving -> connecting -> handshaking ->
streaming -> degraded -> reconnecting -> stopped,两端同名便于对照日志;
resolving 只对主机名生效,局域网直连 IP 直接进 connecting;
- 退避与抖动按 §7.1 冻结:0.5s -> 1 -> 2 -> 4 -> 8 -> 15s 封顶 + ±20% 抖动;
- 契约新增 §5 设备去重:hello 携带稳定 deviceId(ANDROID_ID 的 SHA-256 前 16 位,
不含硬件序列号),Windows 端据此去重、旧连接以 1000 关闭;
- 新增 util/ConnectionLog(环形缓冲,最近 500 条:状态迁移 / close code /
子协议协商 / 首帧时间 / 保活参数),运行页可一键导出(FileProvider)。
T-AND-012:MediaPipe 初始化崩溃(ExceptionInInitializerError: null)
- 诊断:新增 pose/PoseDiagnostics 展开 cause 链取真因(通常 UnsatisfiedLinkError)、
区分 native 加载失败、比对设备 ABI 与 APK 内 ABI 并给出可执行结论;
- native 解压:packaging { jniLibs { useLegacyPackaging = true } }(AGP 8 release
默认 extractNativeLibs=false,MediaPipe JNI 对它不兼容);
- R8:补 -keepclasseswithmembernames 的 native 方法保留规则,并 -printseeds 输出
seeds 供发布前核对(实测保留 20169 条 MediaPipe 条目);
- 构建期新增 verifyReleaseAbi:release ABI 必须恰为 arm64-v8a/armeabi-v7a、
每个 ABI 下必须有 MediaPipe native 库、模型资源必须存在;
- 未换库:诊断指向集成配置而非 MediaPipe 本身,保留 33 点契约零成本。
T-AND-010:版本号展示与单一来源
- 版本唯一来源 versionName(= project.yaml 的 version);versionCode 按
10000*主+100*次+修订 递增(0.2.0 -> 200);
- 契约版本改为构建期解析 contracts/VERSION 后注入 BuildConfig(六项各自独立
递增,原先的单个 CONTRACT_VERSION 常量已失真);界面不再手写任何版本号;
- 新增 checkReleaseConsistency 并挂到 preBuild,三者不一致直接 BUILD FAILED。
README/DSH/THIRD_PARTY/tasks 同步更新(含「导出连接日志」「保活参数」
「最近断开原因」等新增可观察项与排查表)。
测试:56 项全部通过(新增 ReconnectPolicyTest 11 项、PoseDiagnosticsTest 12 项);
build/verify.sh 全通过(单元测试 -> 构建 -> 签名/ABI/native/seeds/dex 校验)
This commit is contained in:
@@ -64,7 +64,7 @@ opendance-android/
|
|||||||
├── gradlew / gradle/wrapper/ # Gradle 8.9 wrapper
|
├── gradlew / gradle/wrapper/ # Gradle 8.9 wrapper
|
||||||
├── project.yaml / DSH.md
|
├── project.yaml / DSH.md
|
||||||
├── tasks/ # 001~009 单任务说明书
|
├── tasks/ # 001~009 单任务说明书
|
||||||
├── contracts/ # 只读契约快照(来源 opendance-web v0.1.0)
|
├── contracts/ # 只读契约快照(来源 opendance-web;六项各自独立版本)
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── build.gradle.kts # 依赖、签名、R8、ABI
|
│ ├── build.gradle.kts # 依赖、签名、R8、ABI
|
||||||
│ ├── proguard-rules.pro # MediaPipe / OkHttp 的 keep 规则
|
│ ├── proguard-rules.pro # MediaPipe / OkHttp 的 keep 规则
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
**阶段三已完成**: T-AND-001 -> T-AND-009 全部落地。
|
**阶段三已完成**: T-AND-001 -> T-AND-012 全部落地(含重连状态机与 MediaPipe 崩溃修复)。
|
||||||
|
|
||||||
| 验收项 | 结果 |
|
| 验收项 | 结果 |
|
||||||
|--|--|
|
|--|--|
|
||||||
| 单元测试 | OK **30 项全部通过**(JVM,无需设备) |
|
| 单元测试 | OK **56 项全部通过**(JVM,无需设备;新增重连策略与 MediaPipe 诊断用例) |
|
||||||
| **跨端金标准** | 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.1.0, minSdk 29(Android 10+)/ targetSdk 34 |
|
| 包信息 | OK `com.opendance.studio` v0.2.0, 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) |
|
||||||
|
|
||||||
@@ -172,9 +172,11 @@ IP 地址:192.0.2.10 # RFC 5737 文档示例网段,实际填你电脑
|
|||||||
|
|
||||||
| 位置 | 可观察现象 |
|
| 位置 | 可观察现象 |
|
||||||
|--|--|
|
|--|--|
|
||||||
| App 运行页 | 「连接状态」为**已连接**(或短暂「连接中」后转「已连接」) |
|
| App 运行页 | 「连接状态」为**推流中**(`streaming`;短暂经过 解析地址 → 连接中 → 握手中) |
|
||||||
| App 运行页 | 「目标」显示 `ws://<IP>:<端口>/pose`;服务端返回 welcome 后出现「会话」ID |
|
| App 运行页 | 「目标」显示 `ws://<IP>:<端口>/pose`;服务端返回 welcome 后出现「会话」ID 与「协商子协议」 |
|
||||||
| App 运行页 | 「已发送」帧数持续增长;「发送帧率」「识别帧率」大于 0;「推理耗时」为毫秒数 |
|
| App 运行页 | 「已发送」帧数持续增长;「发送帧率」「识别帧率」大于 0;「推理耗时」为毫秒数 |
|
||||||
|
| App 运行页 | 「保活」显示心跳与静默超时(由服务端在 welcome 中声明),心跳必须小于静默超时的一半 |
|
||||||
|
| App 运行页 | 断开时「最近断开」显示 close code 与原因(如 `1001 对端离开`),而不是只有"未连接" |
|
||||||
| App 运行页 | 「人体识别」为**已识别**,叠加层出现骨架(黄点 = 置信度好,红点 = 置信度低) |
|
| App 运行页 | 「人体识别」为**已识别**,叠加层出现骨架(黄点 = 置信度好,红点 = 置信度低) |
|
||||||
| Windows 客户端 | 显示已连接并收到关键点,VRM 模型随动作实时动起来 |
|
| Windows 客户端 | 显示已连接并收到关键点,VRM 模型随动作实时动起来 |
|
||||||
|
|
||||||
@@ -187,7 +189,9 @@ IP 地址:192.0.2.10 # RFC 5737 文档示例网段,实际填你电脑
|
|||||||
| 3 | `bash build/build-apk.sh all` | debug 与 release 均 `OK`; release 显示 `已签名(APK Signature Scheme v2)` |
|
| 3 | `bash build/build-apk.sh all` | debug 与 release 均 `OK`; release 显示 `已签名(APK Signature Scheme v2)` |
|
||||||
| 4 | `$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk` | 退出码 0(无输出即通过) |
|
| 4 | `$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk` | 退出码 0(无输出即通过) |
|
||||||
| 5 | `unzip -l app/build/outputs/apk/release/app-release.apk \| grep pose_landmarker_lite.task` | 列出 `assets/pose_landmarker_lite.task`(约 5.7 MB, Stored 未压缩) |
|
| 5 | `unzip -l app/build/outputs/apk/release/app-release.apk \| grep pose_landmarker_lite.task` | 列出 `assets/pose_landmarker_lite.task`(约 5.7 MB, Stored 未压缩) |
|
||||||
| 6 | `adb install -r app/build/outputs/apk/debug/app-debug.apk` | `Success` |
|
| 6 | `./gradlew verifyReleaseAbi` | release APK 的 ABI 恰为 `[arm64-v8a, armeabi-v7a]`,且每个 ABI 下都有 MediaPipe native 库 |
|
||||||
|
| 7 | `grep -c "^com.google.mediapipe" app/build/outputs/mapping/release/seeds.txt` | 大于 0(说明 R8 没有裁掉 MediaPipe;为 0 则 release 必崩) |
|
||||||
|
| 8 | `adb install -r app/build/outputs/apk/debug/app-debug.apk` | `Success` |
|
||||||
|
|
||||||
其中 `KeypointFrameTest.goldenVectorMatchesWebImplementation` 必须通过:
|
其中 `KeypointFrameTest.goldenVectorMatchesWebImplementation` 必须通过:
|
||||||
它断言本端 Kotlin 序列化器与 Web 端 TypeScript 实现产出**逐位相同**的 239 字节帧。
|
它断言本端 Kotlin 序列化器与 Web 端 TypeScript 实现产出**逐位相同**的 239 字节帧。
|
||||||
@@ -200,7 +204,10 @@ IP 地址:192.0.2.10 # RFC 5737 文档示例网段,实际填你电脑
|
|||||||
| 相机预览正常但「人体识别」一直是「未识别到人体」 | 光线不足、人不在画面内、或分辨率/模式不合适 | 站到画面中央并改善光照;确认分辨率不是过低;观察「推理耗时」是否异常大 |
|
| 相机预览正常但「人体识别」一直是「未识别到人体」 | 光线不足、人不在画面内、或分辨率/模式不合适 | 站到画面中央并改善光照;确认分辨率不是过低;观察「推理耗时」是否异常大 |
|
||||||
| 「连接状态」一直「已断开 / 重连中」,「测试连接」失败 | IP 或端口填错;Windows 客户端未启动;防火墙拦截入站 8765;手机与电脑不在同一网段(AP 隔离) | 核对连接页「本机地址」与电脑 IP 是否同网段;启动客户端;在 Windows 防火墙放行 OpenDance 的 TCP 8765 入站;关闭路由器 AP 隔离 |
|
| 「连接状态」一直「已断开 / 重连中」,「测试连接」失败 | IP 或端口填错;Windows 客户端未启动;防火墙拦截入站 8765;手机与电脑不在同一网段(AP 隔离) | 核对连接页「本机地址」与电脑 IP 是否同网段;启动客户端;在 Windows 防火墙放行 OpenDance 的 TCP 8765 入站;关闭路由器 AP 隔离 |
|
||||||
| 「扫描局域网」扫不到主机 | 手机未连 WiFi、路由器 AP 隔离、或客户端未监听 8765 | 确认手机连的是 WiFi 而非蜂窝;改为手动填 IP;检查客户端监听端口与防火墙 |
|
| 「扫描局域网」扫不到主机 | 手机未连 WiFi、路由器 AP 隔离、或客户端未监听 8765 | 确认手机连的是 WiFi 而非蜂窝;改为手动填 IP;检查客户端监听端口与防火墙 |
|
||||||
| 点「开始推流」立即闪退,或提示 MediaPipe 初始化失败 | MediaPipe 原生库加载 / 静态初始化失败抛出的是 **Error** 子类(`UnsatisfiedLinkError`、`ExceptionInInitializerError`),不是 `Exception`;常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器)或模型资源缺失 | 改用与设备 ABI 匹配的包(真机用 release,模拟器调试用 debug);确认 APK 内含 `assets/pose_landmarker_lite.task`。仓库已在 `PoseDetector.initialize()` 中 `catch Throwable` 兜底并把原因显示在运行页;`CrashLogger` 会把未捕获堆栈写入应用私有目录,并在连接页展示,可截图反馈,或用 `adb logcat -s PoseDetector` 查看 |
|
| 点「开始推流」立即闪退,或提示 MediaPipe 初始化失败 | MediaPipe 原生库加载 / 静态初始化失败抛出的是 **Error** 子类(`UnsatisfiedLinkError`、`ExceptionInInitializerError`),不是 `Exception`;常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器)、`extractNativeLibs=false`、R8 裁掉 MediaPipe 或模型资源缺失 | **先看 App 给出的诊断**:`PoseDiagnostics` 会把 cause 链最内层的真因、设备 ABI、APK 内 ABI 与结论一起显示在运行页(例如"APK 只包含 arm64-v8a/armeabi-v7a,而模拟器(x86_64)需要 x86_64")。真机用 release、模拟器调试用 debug;仓库已用 `jniLibs.useLegacyPackaging = true` 处理 native 解压,并有 `-keepclasseswithmembernames` + `-printseeds` 防止 R8 裁掉 MediaPipe |
|
||||||
|
| 画面里没有人 / 相机暂停一段时间后连接被断开 | 这是**静默超时**(服务端在 welcome 里声明 `silentTimeoutMs`),即使没有关键点,App 也会按 `heartbeatIntervalMs` 发应用层 `ping` | 正常情况下不会触发。若仍频繁断开,运行页「保活」一行会显示两端的参数;契约要求心跳小于静默超时的一半,可在 Windows 端 `pose_server.gd` 调整并在两端核对日志 |
|
||||||
|
| 断网恢复后不自动重连 | 契约 §7 规定:**只有真正断开才重连**;服务端主动关闭(`1000`/`1001`)或协议错误(`1002`)以及用户主动停止都**不**重连,避免"重连风暴" | 运行页「最近断开」会写明 close code 与原因。若原因是 `1001 对端离开`,说明 Windows 客户端那边重启或主动关闭了推流,需要在电脑端恢复后再点一次「开始推流」 |
|
||||||
|
| Windows 端「已连接 N 台」忽大忽小 | 同一次闪断被计成多台设备 | 契约 §3.1 要求 hello 携带稳定的 `deviceId`(本端取 ANDROID_ID 的哈希,不含硬件序列号),接收端按它去重。核对两端日志中的 deviceId 是否一致 |
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -233,7 +240,20 @@ app/src/main/java/com/opendance/studio/
|
|||||||
└── ui/ 连接页 / 运行页 / 关于页
|
└── ui/ 连接页 / 运行页 / 关于页
|
||||||
```
|
```
|
||||||
|
|
||||||
## 依赖的契约(opendance-web v0.1.0 冻结)
|
## 依赖的契约(opendance-web 冻结,逐项独立版本)
|
||||||
|
|
||||||
|
| 契约 | 版本 |
|
||||||
|
|--|--|
|
||||||
|
| `websocket-keypoints` | 0.2.0(本端为发送方) |
|
||||||
|
| `skeleton-topology` | 0.2.0 |
|
||||||
|
| `rest-api` | 0.5.0 |
|
||||||
|
| `data-models` | 0.7.0 |
|
||||||
|
| `model-formats` | 0.5.0 |
|
||||||
|
| `database` | 0.4.0 |
|
||||||
|
|
||||||
|
版本号来源统一(文档 §11.2):应用版本取 `app/build.gradle.kts` 的 `versionName`
|
||||||
|
(= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从
|
||||||
|
`contracts/VERSION` 解析后注入 `BuildConfig`;Kotlin 界面代码里不出现任何版本号字面量。
|
||||||
|
|
||||||
- [`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 点索引与坐标语义
|
||||||
|
|||||||
+16
-8
@@ -1,5 +1,5 @@
|
|||||||
================================================================
|
================================================================
|
||||||
OpenDance Studio -- Android 辅助 App v0.1.0 使用说明
|
OpenDance Studio -- Android 辅助 App v0.2.0 使用说明
|
||||||
================================================================
|
================================================================
|
||||||
|
|
||||||
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
|
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
|
||||||
@@ -288,9 +288,16 @@ Android Studio 路径:
|
|||||||
|
|
||||||
./gradlew testDebugUnitTest
|
./gradlew testDebugUnitTest
|
||||||
|
|
||||||
30 项单元测试(JVM,无需设备),核心是金标准字节向量测试:
|
56 项单元测试(JVM,无需设备)。除金标准字节向量测试外,另有两组针对
|
||||||
用 opendance-web 已通过验收的 TypeScript 实现产出的 239 字节向量,
|
本次缺陷的用例:
|
||||||
断言本端 Kotlin 实现逐位相同 -- 这是三端契约一致性的硬证据。
|
- ReconnectPolicyTest(11 项):退避序列 0.5s->1->2->4->8->15s 封顶 + ±20%
|
||||||
|
抖动;契约 §7 的「只有真正断开才重连」(1000/1001/1002 与主动停止都不重连);
|
||||||
|
心跳间隔必须严格小于静默超时的一半;
|
||||||
|
- PoseDiagnosticsTest(12 项):从 ExceptionInInitializerError 的 cause 链
|
||||||
|
翻出 UnsatisfiedLinkError 真因;ABI 比对(release 包装在 x86_64 模拟器必崩);
|
||||||
|
环状 cause 不死循环。
|
||||||
|
金标准字节向量测试仍是契约一致性的硬证据:用 opendance-web 已通过验收的
|
||||||
|
TypeScript 实现产出的 239 字节向量,断言本端 Kotlin 实现逐位相同。
|
||||||
|
|
||||||
测试报告:app/build/reports/tests/testDebugUnitTest/index.html
|
测试报告:app/build/reports/tests/testDebugUnitTest/index.html
|
||||||
|
|
||||||
@@ -302,16 +309,17 @@ Android Studio 路径:
|
|||||||
gradle/wrapper/ Gradle 8.9 wrapper
|
gradle/wrapper/ Gradle 8.9 wrapper
|
||||||
project.yaml 任务清单与状态
|
project.yaml 任务清单与状态
|
||||||
DSH.md 本项目 DSH 执行约束
|
DSH.md 本项目 DSH 执行约束
|
||||||
tasks/ 单任务说明书(T-AND-001 ~ 009)
|
tasks/ 单任务说明书(T-AND-001 ~ 012)
|
||||||
contracts/ 只读契约快照(来源 opendance-web v0.1.0)
|
contracts/ 只读契约快照(来源 opendance-web;六项各自独立版本)
|
||||||
app/src/main/java/com/opendance/studio/
|
app/src/main/java/com/opendance/studio/
|
||||||
MainActivity.kt 三页导航
|
MainActivity.kt 三页导航
|
||||||
model/ 239 字节帧编解码 + 33 点拓扑
|
model/ 239 字节帧编解码 + 33 点拓扑
|
||||||
camera/ CameraX 采集
|
camera/ CameraX 采集
|
||||||
pose/ MediaPipe 端侧推理
|
pose/ MediaPipe 端侧推理
|
||||||
net/ WebSocket 推流 + 主机发现
|
net/ WebSocket 推流 + 主机发现 + 重连策略
|
||||||
data/ 设置
|
data/ 设置(含稳定 deviceId)
|
||||||
ui/ 连接页 / 运行页 / 关于页
|
ui/ 连接页 / 运行页 / 关于页
|
||||||
|
util/ 崩溃日志 + 连接日志(可导出)
|
||||||
app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型
|
app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型
|
||||||
build/ 打包与验证脚本
|
build/ 打包与验证脚本
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# 第三方开源项目声明
|
# 第三方开源项目声明
|
||||||
|
|
||||||
**OpenDance Studio Android 辅助 App v0.1.0**
|
**OpenDance Studio Android 辅助 App v0.2.0**
|
||||||
|
|
||||||
本软件使用了以下开源项目,特此声明。完整锚点见 `contracts/open-source-credits.md`
|
本软件使用了以下开源项目,特此声明。完整锚点见 `contracts/open-source-credits.md`
|
||||||
(三端共用,任一端新增依赖须回到 `opendance-web` 更新该表)。
|
(三端共用,任一端新增依赖须回到 `opendance-web` 更新该表)。
|
||||||
|
|||||||
+141
-6
@@ -1,9 +1,16 @@
|
|||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
// OpenDance Studio —— Android 辅助 App(应用模块)
|
// OpenDance Studio —— Android 辅助 App(应用模块)
|
||||||
//
|
//
|
||||||
// 契约:contracts/websocket-keypoints.md v0.1.0(本端为**发送方**)
|
// 契约:contracts/websocket-keypoints.md v0.2.0(本端为**发送方**)
|
||||||
// contracts/skeleton-topology.md v0.1.0(MediaPipe 33 点)
|
// contracts/skeleton-topology.md v0.2.0(MediaPipe 33 点)
|
||||||
|
//
|
||||||
|
// 版本号与契约版本的**单一来源**(文档 §11.2 第 1 条):
|
||||||
|
// - 应用版本 -> app/build.gradle.kts 的 versionName(= 本次要发布的版本)
|
||||||
|
// - 契约版本 -> contracts/VERSION(构建期解析后注入 BuildConfig)
|
||||||
|
// 两者都不允许在 Kotlin 界面代码里再手写一份;文件末尾的
|
||||||
|
// `checkReleaseConsistency` 任务会在打包前校验与 project.yaml 一致。
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -19,13 +26,49 @@ android {
|
|||||||
applicationId = "com.opendance.studio"
|
applicationId = "com.opendance.studio"
|
||||||
minSdk = 29 // Android 10(文档 T-AND-009 验收要求 Android 10+)
|
minSdk = 29 // Android 10(文档 T-AND-009 验收要求 Android 10+)
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
|
||||||
versionName = "0.1.0"
|
// versionName 用 X.Y.Z,两者同一次发布一起更新。
|
||||||
|
versionCode = 200
|
||||||
|
versionName = "0.2.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
// 契约版本,供关于页与日志显示
|
|
||||||
buildConfigField("String", "CONTRACT_VERSION", "\"0.1.0\"")
|
|
||||||
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
|
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
|
||||||
|
|
||||||
|
// ---- 契约版本:从 contracts/VERSION 解析,作为唯一来源注入 BuildConfig ----
|
||||||
|
// 六项契约各自独立递增(rest-api 0.5.0 时 skeleton-topology 可能还是 0.2.0),
|
||||||
|
// 因此注入每一项 + 一个用于展示的汇总串,界面不再手写任何版本号。
|
||||||
|
val contractVersions = linkedMapOf<String, String>()
|
||||||
|
val versionFile = rootProject.file("contracts/VERSION")
|
||||||
|
versionFile.readLines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains(":") }
|
||||||
|
.forEach { line ->
|
||||||
|
val key = line.substringBefore(":").trim()
|
||||||
|
val value = line.substringAfter(":").trim()
|
||||||
|
if (key.isNotEmpty() && value.isNotEmpty()) contractVersions[key] = value
|
||||||
|
}
|
||||||
|
check(contractVersions.isNotEmpty()) {
|
||||||
|
"无法从 ${versionFile.path} 解析契约版本:请确认该文件存在且格式为 `key: value`"
|
||||||
|
}
|
||||||
|
contractVersions.forEach { (key, value) ->
|
||||||
|
val field = "CONTRACT_" + key.uppercase().replace('-', '_')
|
||||||
|
buildConfigField("String", field, "\"$value\"")
|
||||||
|
}
|
||||||
|
buildConfigField(
|
||||||
|
"String",
|
||||||
|
"CONTRACT_VERSION",
|
||||||
|
"\"" + contractVersions["websocket-keypoints"] + "\"",
|
||||||
|
)
|
||||||
|
buildConfigField(
|
||||||
|
"String[]",
|
||||||
|
"CONTRACT_VERSION_KEYS",
|
||||||
|
contractVersions.keys.joinToString(", ") { "\"$it\"" }.let { "new String[]{$it}" },
|
||||||
|
)
|
||||||
|
buildConfigField(
|
||||||
|
"String[]",
|
||||||
|
"CONTRACT_VERSION_VALUES",
|
||||||
|
contractVersions.values.joinToString(", ") { "\"$it\"" }.let { "new String[]{$it}" },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +162,14 @@ android {
|
|||||||
androidResources {
|
androidResources {
|
||||||
noCompress += "task"
|
noCompress += "task"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// T-AND-012:release 默认 extractNativeLibs=false(native 库直接从 APK mmap),
|
||||||
|
// MediaPipe 的 JNI 加载对这一点有历史兼容问题,会表现为
|
||||||
|
// ExceptionInInitializerError / UnsatisfiedLinkError。
|
||||||
|
// 显式使用传统打包(等价 android:extractNativeLibs="true")。
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
@@ -190,3 +241,87 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// T-AND-010 / T-AND-012:发布前自检
|
||||||
|
//
|
||||||
|
// 1) 版本号与 project.yaml 一致(文档 §11.2:产物名 / tag / README / 界面必须同源);
|
||||||
|
// 2) release APK 的 ABI 必须与 abiFilters 一致,且每个 ABI 目录下都要有
|
||||||
|
// MediaPipe 的 native 库 —— 这正是 "debug 好用、release 崩" 的典型根因
|
||||||
|
// (在 x86_64 模拟器上装 release 包必然缺库)。
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 从 project.yaml 取 `version:` */
|
||||||
|
fun projectYamlVersion(): String {
|
||||||
|
val file = rootProject.file("project.yaml")
|
||||||
|
if (!file.exists()) return ""
|
||||||
|
return file.readLines()
|
||||||
|
.firstOrNull { it.trimStart().startsWith("version:") }
|
||||||
|
?.substringAfter(":")
|
||||||
|
?.trim()
|
||||||
|
?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每次构建都先做版本/契约一致性校验,避免版本漂移进入产物
|
||||||
|
tasks.matching { it.name == "preBuild" }.configureEach { dependsOn("checkReleaseConsistency") }
|
||||||
|
|
||||||
|
tasks.register("checkReleaseConsistency") {
|
||||||
|
group = "verification"
|
||||||
|
description = "校验 versionName 与 project.yaml / 契约快照一致(文档 §11.2)"
|
||||||
|
doLast {
|
||||||
|
val yamlVersion = projectYamlVersion()
|
||||||
|
val name = android.defaultConfig.versionName
|
||||||
|
if (yamlVersion.isNotEmpty() && yamlVersion != name) {
|
||||||
|
throw GradleException("versionName=$name 与 project.yaml 的 version=$yamlVersion 不一致")
|
||||||
|
}
|
||||||
|
val wsVersion = android.defaultConfig.buildConfigFields["CONTRACT_WEBSOCKET_KEYPOINTS"]?.value?.trim('"')
|
||||||
|
if (wsVersion != "0.2.0") {
|
||||||
|
throw GradleException("contracts/VERSION 的 websocket-keypoints=$wsVersion,期望 0.2.0")
|
||||||
|
}
|
||||||
|
val code = android.defaultConfig.versionCode
|
||||||
|
val expected = name!!.split(".").let {
|
||||||
|
it[0].toInt() * 10000 + it[1].toInt() * 100 + it[2].toInt()
|
||||||
|
}
|
||||||
|
logger.lifecycle("[consistency] versionName=$name versionCode=$code 契约(websocket-keypoints)=$wsVersion")
|
||||||
|
if (code != expected) {
|
||||||
|
throw GradleException("versionCode=$code 与 versionName=$name 不匹配(应为 $expected)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 release APK 里的 native 库:
|
||||||
|
* - ABI 目录集合必须等于 abiFilters(多一个或少一个都算失败);
|
||||||
|
* - 每个 ABI 目录下必须存在 MediaPipe 的 native 库;
|
||||||
|
* - assets 里的 MediaPipe 模型存在。
|
||||||
|
*/
|
||||||
|
tasks.register("verifyReleaseAbi") {
|
||||||
|
group = "verification"
|
||||||
|
description = "校验 release APK 的 ABI 与 MediaPipe native 库(T-AND-012)"
|
||||||
|
dependsOn("assembleRelease")
|
||||||
|
doLast {
|
||||||
|
val apk = file("build/outputs/apk/release/app-release.apk")
|
||||||
|
if (!apk.exists()) throw GradleException("未生成 ${apk.path}")
|
||||||
|
val expected = setOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
// 注意:脚本里 `java` 被 JavaPluginExtension 遮蔽,必须用顶部 import 的 ZipFile
|
||||||
|
val entries: List<String> = ZipFile(apk).use { zip ->
|
||||||
|
zip.entries().asSequence().map { entry -> entry.name }.toList()
|
||||||
|
}
|
||||||
|
val abis: Set<String> = entries.filter { name -> name.startsWith("lib/") }
|
||||||
|
.map { name -> name.removePrefix("lib/").substringBefore("/") }
|
||||||
|
.toSet()
|
||||||
|
logger.lifecycle("[abi] release APK 包含 ABI:$abis")
|
||||||
|
if (abis != expected) {
|
||||||
|
throw GradleException("release APK 的 ABI=$abis,期望 $expected")
|
||||||
|
}
|
||||||
|
abis.forEach { abi: String ->
|
||||||
|
val hasJni = entries.any { name -> name.startsWith("lib/$abi/") && name.contains("mediapipe") }
|
||||||
|
if (!hasJni) throw GradleException("lib/$abi/ 下缺少 MediaPipe native 库")
|
||||||
|
logger.lifecycle("[abi] $abi 下 MediaPipe native 库存在")
|
||||||
|
}
|
||||||
|
val model = entries.count { name -> name == "assets/pose_landmarker_lite.task" }
|
||||||
|
if (model != 1) throw GradleException("assets/pose_landmarker_lite.task 数量为 $model(期望 1)")
|
||||||
|
logger.lifecycle("[abi] MediaPipe 模型资源存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+13
@@ -7,8 +7,21 @@
|
|||||||
# 删掉这些规则会导致 release 包在真机上运行时崩溃(debug 包不会暴露该问题)。
|
# 删掉这些规则会导致 release 包在真机上运行时崩溃(debug 包不会暴露该问题)。
|
||||||
|
|
||||||
# ---------------------------------------------------------------- MediaPipe
|
# ---------------------------------------------------------------- MediaPipe
|
||||||
|
#
|
||||||
|
# T-AND-012:release 崩溃(ExceptionInInitializerError / UnsatisfiedLinkError)
|
||||||
|
# 的一大来源就是 R8 把 MediaPipe 的类裁掉或改名。以下规则逐条对应:
|
||||||
|
# 1. 整个包保持原样(JNI 按类名/方法名查找);
|
||||||
|
# 2. **native 方法所在的类与其声明类名必须保留**(仅 keep class 不够,
|
||||||
|
# R8 仍可能裁剪方法名,导致 JNI 注册失败);
|
||||||
|
# 3. 生成 seeds 文件,发布前可用 -printseeds 核对关键类没被裁掉。
|
||||||
-keep class com.google.mediapipe.** { *; }
|
-keep class com.google.mediapipe.** { *; }
|
||||||
-keep interface com.google.mediapipe.** { *; }
|
-keep interface com.google.mediapipe.** { *; }
|
||||||
|
-keepclasseswithmembernames class com.google.mediapipe.** {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
-dontwarn com.google.mediapipe.**
|
-dontwarn com.google.mediapipe.**
|
||||||
|
|
||||||
# MediaPipe 使用 AutoValue 生成不可变类,依赖注解与泛型签名
|
# MediaPipe 使用 AutoValue 生成不可变类,依赖注解与泛型签名
|
||||||
|
|||||||
@@ -34,6 +34,20 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
T-AND-011:导出连接日志时通过 FileProvider 分享 filesDir 下的文本文件。
|
||||||
|
Android 7+ 不允许直接以 file:// 分享,必须走 content://。
|
||||||
|
-->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.opendance.studio.data
|
package com.opendance.studio.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用设置(SharedPreferences)。
|
* 应用设置(SharedPreferences)。
|
||||||
@@ -11,6 +13,7 @@ import androidx.core.content.edit
|
|||||||
class AppSettings(context: Context) {
|
class AppSettings(context: Context) {
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val context: Context = context.applicationContext
|
||||||
|
|
||||||
/** Windows 客户端主机地址 */
|
/** Windows 客户端主机地址 */
|
||||||
var host: String
|
var host: String
|
||||||
@@ -51,8 +54,32 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_AUTO_RECONNECT, true)
|
get() = prefs.getBoolean(KEY_AUTO_RECONNECT, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_AUTO_RECONNECT, value) }
|
set(value) = prefs.edit { putBoolean(KEY_AUTO_RECONNECT, value) }
|
||||||
|
|
||||||
|
/** 契约 §1:ws://<windows-host>:8765/pose */
|
||||||
fun webSocketUrl(): String = "ws://$host:$port/pose"
|
fun webSocketUrl(): String = "ws://$host:$port/pose"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 契约 §3.1 的 hello.deviceId:该安装实例的**稳定标识**,用于接收端去重
|
||||||
|
* (否则一次"闪断重连"会被 Windows 端显示成两台设备)。
|
||||||
|
*
|
||||||
|
* 取 `ANDROID_ID` 的 SHA-256 前 16 位十六进制:ANDROID_ID 在 Android 8+
|
||||||
|
* 按「应用签名 + 用户 + 设备」隔离,不需要任何权限,也不是硬件序列号。
|
||||||
|
* 首次访问生成一次并落盘,之后不再变化(清除应用数据才会变)。
|
||||||
|
*/
|
||||||
|
val deviceId: String
|
||||||
|
get() {
|
||||||
|
val cached = prefs.getString(KEY_DEVICE_ID, null)
|
||||||
|
if (!cached.isNullOrBlank()) return cached
|
||||||
|
val raw = runCatching {
|
||||||
|
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
|
}.getOrNull().orEmpty().ifBlank { "unknown" }
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(("opendance:" + raw).toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
.take(16)
|
||||||
|
prefs.edit { putString(KEY_DEVICE_ID, digest) }
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PREFS_NAME = "opendance_settings"
|
const val PREFS_NAME = "opendance_settings"
|
||||||
const val DEFAULT_HOST = "192.0.2.100"
|
const val DEFAULT_HOST = "192.0.2.100"
|
||||||
@@ -67,6 +94,7 @@ class AppSettings(context: Context) {
|
|||||||
private const val KEY_RESOLUTION = "resolution"
|
private const val KEY_RESOLUTION = "resolution"
|
||||||
private const val KEY_DEVICE_LABEL = "device_label"
|
private const val KEY_DEVICE_LABEL = "device_label"
|
||||||
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
||||||
|
private const val KEY_DEVICE_ID = "device_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.os.Build
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.opendance.studio.BuildConfig
|
import com.opendance.studio.BuildConfig
|
||||||
import com.opendance.studio.model.KeypointFrame
|
import com.opendance.studio.model.KeypointFrame
|
||||||
|
import com.opendance.studio.util.ConnectionLog
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -12,36 +13,56 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.URI
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlin.math.min
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T-AND-005:WebSocket 推流客户端(本端为**发送方**)
|
* T-AND-005 / T-AND-011:WebSocket 推流客户端(本端为**发送方**)
|
||||||
*
|
*
|
||||||
* 契约 contracts/websocket-keypoints.md:
|
* 契约 contracts/websocket-keypoints.md v0.2.0:
|
||||||
* - 端点 ws://<windows-host>:8765/pose,子协议 opendance.pose.v1
|
* - 端点 ws://<windows-host>:8765/pose,子协议 opendance.pose.v1
|
||||||
* - 二进制帧 = 239 字节关键点(见 [KeypointFrame])
|
* - 二进制帧 = 239 字节关键点(见 [KeypointFrame])
|
||||||
* - 文本控制帧 hello / config / stats / bye
|
* - 文本控制帧 hello / welcome / config / stats / ping / pong / bye
|
||||||
*
|
*
|
||||||
* 关键设计:
|
* T-AND-011 的修复要点("连接状态异常、频繁重连"):
|
||||||
* - 二进制帧走 `send(ByteString)`,控制帧走 `send(text)`
|
* 1. **状态机与接收端同名**(契约 §8):idle → resolving → connecting →
|
||||||
* - 自动重连采用指数退避(1s → 2s → 4s … 上限 15s),连接成功即重置
|
* handshaking → streaming → degraded → reconnecting → stopped,
|
||||||
* - `sendFrame` 在非连接态直接丢弃并计数,绝不阻塞相机线程
|
* 界面显示状态 + 最近一次断开原因(close code / reason)。
|
||||||
* - 统计信息(发送帧数、丢弃帧数、FPS、RTT)由本类维护,供运行页展示
|
* 2. **保活**:接收端在 welcome/config 里声明 silentTimeoutMs 与
|
||||||
|
* heartbeatIntervalMs,本端按后者周期发应用层 `ping`。
|
||||||
|
* 协议层 ping(OkHttp 的 `pingInterval`)对接收端的应用层**不可见**,
|
||||||
|
* 不能用来续命 —— 这是原先"画面里没人就掉线"的根因之一。
|
||||||
|
* 3. **只有真正断开才重连**(契约 §7):主动停止 / 1000 / 1001 / 1002 一律不重连。
|
||||||
|
* 4. **退避与抖动**:0.5s→1→2→4→8→15s 封顶 + ±20% 抖动(契约 §7.1)。
|
||||||
|
* 5. **可诊断**:close code / 子协议协商结果 / 首帧时间写入 [ConnectionLog],
|
||||||
|
* 界面可一键导出。
|
||||||
*/
|
*/
|
||||||
class PoseWebSocketClient(
|
class PoseWebSocketClient(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val subprotocol: String = BuildConfig.WS_SUBPROTOCOL,
|
private val subprotocol: String = BuildConfig.WS_SUBPROTOCOL,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
enum class State { IDLE, CONNECTING, CONNECTED, RECONNECTING, CLOSED }
|
/** 契约 §8 的连接状态(wireName 与接收端 GDScript 侧同名,便于对照日志) */
|
||||||
|
enum class State(val wireName: String) {
|
||||||
|
IDLE("idle"),
|
||||||
|
RESOLVING("resolving"),
|
||||||
|
CONNECTING("connecting"),
|
||||||
|
HANDSHAKING("handshaking"),
|
||||||
|
STREAMING("streaming"),
|
||||||
|
DEGRADED("degraded"),
|
||||||
|
RECONNECTING("reconnecting"),
|
||||||
|
STOPPED("stopped"),
|
||||||
|
}
|
||||||
|
|
||||||
data class Stats(
|
data class Stats(
|
||||||
val state: State = State.IDLE,
|
val state: State = State.IDLE,
|
||||||
@@ -52,15 +73,38 @@ class PoseWebSocketClient(
|
|||||||
val sendFps: Float = 0f,
|
val sendFps: Float = 0f,
|
||||||
val roundTripMs: Long = -1,
|
val roundTripMs: Long = -1,
|
||||||
val reconnectAttempts: Int = 0,
|
val reconnectAttempts: Int = 0,
|
||||||
|
val nextRetryMs: Long = 0,
|
||||||
val lastError: String = "",
|
val lastError: String = "",
|
||||||
val serverSessionId: String = "",
|
val serverSessionId: String = "",
|
||||||
)
|
val negotiatedProtocol: String = "",
|
||||||
|
/** 最近一次断开:close code 与原因(契约 §8 要求界面展示) */
|
||||||
|
val lastCloseCode: Int = 0,
|
||||||
|
val lastCloseReason: String = "",
|
||||||
|
val lastDisconnectAtMs: Long = 0,
|
||||||
|
/** 服务端声明的静默超时与期望心跳(契约 §1.1) */
|
||||||
|
val silentTimeoutMs: Long = ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS,
|
||||||
|
val heartbeatIntervalMs: Long =
|
||||||
|
ReconnectPolicy.heartbeatIntervalMs(ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS),
|
||||||
|
/** 本轮连接的首帧时间;0 表示还没发出过关键点帧 */
|
||||||
|
val firstFrameAtMs: Long = 0,
|
||||||
|
val pongsReceived: Int = 0,
|
||||||
|
) {
|
||||||
|
val lastCloseLabel: String
|
||||||
|
get() = if (lastCloseCode == 0 && lastCloseReason.isBlank()) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
ReconnectPolicy.closeCodeLabel(lastCloseCode) +
|
||||||
|
if (lastCloseReason.isNotBlank()) "($lastCloseReason)" else ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 长连接不设读超时
|
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 长连接不设读超时
|
||||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
// 协议层 ping 仍然保留(对中间设备有用),但**不能**替代应用层 ping:
|
||||||
|
// 接收端的静默超时只看应用层收到的数据帧/文本帧。
|
||||||
|
.pingInterval(PROTOCOL_PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -69,21 +113,31 @@ class PoseWebSocketClient(
|
|||||||
|
|
||||||
private var webSocket: WebSocket? = null
|
private var webSocket: WebSocket? = null
|
||||||
private var reconnectJob: Job? = null
|
private var reconnectJob: Job? = null
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
private var url: String = ""
|
private var url: String = ""
|
||||||
private var manualClose = false
|
private var manualClose = false
|
||||||
|
private var autoReconnect = true
|
||||||
|
|
||||||
private val sentCounter = AtomicInteger(0)
|
private val sentCounter = AtomicInteger(0)
|
||||||
private val droppedCounter = AtomicInteger(0)
|
private val droppedCounter = AtomicInteger(0)
|
||||||
private val reconnectCounter = AtomicInteger(0)
|
private val reconnectCounter = AtomicInteger(0)
|
||||||
|
private val pongCounter = AtomicInteger(0)
|
||||||
private val frameIdCounter = AtomicInteger(0)
|
private val frameIdCounter = AtomicInteger(0)
|
||||||
|
|
||||||
/** 首帧时间戳与计数,用于计算发送 FPS */
|
/** 首帧时间戳与计数,用于计算发送 FPS */
|
||||||
private var fpsWindowStart = 0L
|
private var fpsWindowStart = 0L
|
||||||
private var fpsWindowFrames = 0
|
private var fpsWindowFrames = 0
|
||||||
|
|
||||||
|
/** 上次收到 pong 的时刻,用于判定链路是否已死 */
|
||||||
|
@Volatile
|
||||||
|
private var lastPongAtMs = 0L
|
||||||
|
|
||||||
private val deviceLabel: String =
|
private val deviceLabel: String =
|
||||||
"${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
|
"${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
|
||||||
|
|
||||||
|
/** 稳定的设备标识(契约 §3.1),用于接收端去重;不含任何硬件序列号 */
|
||||||
|
var deviceId: String = ""
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 连接
|
// ------------------------------------------------------------------ 连接
|
||||||
|
|
||||||
fun connect(targetUrl: String, autoReconnect: Boolean = true) {
|
fun connect(targetUrl: String, autoReconnect: Boolean = true) {
|
||||||
@@ -91,16 +145,33 @@ class PoseWebSocketClient(
|
|||||||
manualClose = false
|
manualClose = false
|
||||||
this.autoReconnect = autoReconnect
|
this.autoReconnect = autoReconnect
|
||||||
reconnectCounter.set(0)
|
reconnectCounter.set(0)
|
||||||
|
lastPongAtMs = 0L
|
||||||
|
ConnectionLog.record("connect() -> $targetUrl(autoReconnect=$autoReconnect)")
|
||||||
|
val host = runCatching { URI(targetUrl).host }.getOrNull()
|
||||||
|
if (host != null && !isIpLiteral(host)) {
|
||||||
|
// 契约 §8 的 resolving:主机名需要解析;局域网直连 IP 时跳过该状态
|
||||||
|
resolveThenConnect(host, initial = true)
|
||||||
|
} else {
|
||||||
openSocket(initial = true)
|
openSocket(initial = true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var autoReconnect = true
|
/** 是否是 IP 字面量(IPv4 / 带方括号的 IPv6) */
|
||||||
|
private fun isIpLiteral(host: String): Boolean {
|
||||||
|
val bare = host.removePrefix("[").removeSuffix("]")
|
||||||
|
if (bare.contains(':')) return true // IPv6
|
||||||
|
val parts = bare.split('.')
|
||||||
|
return parts.size == 4 && parts.all { part ->
|
||||||
|
part.isNotEmpty() && part.length <= 3 &&
|
||||||
|
part.all { it.isDigit() } && part.toInt() in 0..255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun openSocket(initial: Boolean) {
|
private fun openSocket(initial: Boolean) {
|
||||||
closeSocketQuietly()
|
closeSocketQuietly()
|
||||||
|
stopHeartbeat()
|
||||||
|
|
||||||
val state = if (initial) State.CONNECTING else State.RECONNECTING
|
update(state = State.CONNECTING, error = "")
|
||||||
update(state = state, error = "")
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
@@ -108,18 +179,64 @@ class PoseWebSocketClient(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
Log.i(TAG, "连接 $url(子协议 $subprotocol)")
|
Log.i(TAG, "连接 $url(子协议 $subprotocol)")
|
||||||
|
ConnectionLog.record(
|
||||||
|
if (initial) "connecting $url" else "reconnecting $url(第 ${reconnectCounter.get()} 次)",
|
||||||
|
)
|
||||||
webSocket = client.newWebSocket(request, listener)
|
webSocket = client.newWebSocket(request, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析主机名(契约 §8 的 resolving 状态)。
|
||||||
|
* 传入 IP 字面量时直接跳到 connecting —— 局域网直连是主路径,
|
||||||
|
* 不该为它多引入一次 DNS 往返与一个状态。
|
||||||
|
*/
|
||||||
|
private fun resolveThenConnect(host: String, initial: Boolean) {
|
||||||
|
scope.launch {
|
||||||
|
update(state = State.RESOLVING)
|
||||||
|
ConnectionLog.record("resolving $host")
|
||||||
|
val resolved = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { InetAddress.getByName(host).hostAddress }.getOrNull()
|
||||||
|
}
|
||||||
|
if (resolved == null) {
|
||||||
|
val message = "无法解析主机地址:$host"
|
||||||
|
Log.w(TAG, message)
|
||||||
|
ConnectionLog.record("resolving 失败:$message")
|
||||||
|
update(state = State.STOPPED, error = message)
|
||||||
|
scheduleReconnect()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
ConnectionLog.record("resolving 完成:$host -> $resolved")
|
||||||
|
openSocket(initial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val listener = object : WebSocketListener() {
|
private val listener = object : WebSocketListener() {
|
||||||
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
Log.i(TAG, "已连接:${response.code}")
|
val negotiated = response.header("Sec-WebSocket-Protocol").orEmpty()
|
||||||
|
Log.i(TAG, "已连接:${response.code},子协议=${negotiated.ifBlank { "(未协商)" }}")
|
||||||
|
ConnectionLog.record(
|
||||||
|
"handshaking:HTTP ${response.code},协商子协议=${negotiated.ifBlank { "(未协商)" }}",
|
||||||
|
)
|
||||||
reconnectCounter.set(0)
|
reconnectCounter.set(0)
|
||||||
fpsWindowStart = 0L
|
fpsWindowStart = 0L
|
||||||
fpsWindowFrames = 0
|
fpsWindowFrames = 0
|
||||||
update(state = State.CONNECTED, error = "")
|
lastPongAtMs = System.currentTimeMillis()
|
||||||
|
_stats.value = _stats.value.copy(
|
||||||
|
state = State.HANDSHAKING,
|
||||||
|
url = url,
|
||||||
|
lastError = "",
|
||||||
|
reconnectAttempts = 0,
|
||||||
|
nextRetryMs = 0,
|
||||||
|
negotiatedProtocol = negotiated,
|
||||||
|
firstFrameAtMs = 0,
|
||||||
|
pongsReceived = pongCounter.get(),
|
||||||
|
)
|
||||||
|
if (negotiated.isNotBlank() && negotiated != subprotocol) {
|
||||||
|
ConnectionLog.record("警告:协商到的子协议 $negotiated 与期望 $subprotocol 不符")
|
||||||
|
}
|
||||||
sendHello()
|
sendHello()
|
||||||
|
startHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
@@ -128,35 +245,66 @@ class PoseWebSocketClient(
|
|||||||
|
|
||||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
Log.i(TAG, "服务端关闭:$code $reason")
|
Log.i(TAG, "服务端关闭:$code $reason")
|
||||||
webSocket.close(1000, null)
|
ConnectionLog.record("onClosing code=$code reason=${reason.ifBlank { "(空)" }}")
|
||||||
|
// 回显对端关闭码:若改成固定 1000,onClosed 会把 1001/1011 等
|
||||||
|
// 真实原因覆盖成 1000,进而让"是否重连"的判断失准(契约 §7)。
|
||||||
|
webSocket.close(code, reason.ifBlank { null })
|
||||||
|
recordClose(code, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
Log.i(TAG, "连接已关闭:$code $reason")
|
Log.i(TAG, "连接已关闭:$code $reason")
|
||||||
update(state = State.CLOSED)
|
ConnectionLog.record("onClosed code=$code reason=${reason.ifBlank { "(空)" }}")
|
||||||
|
recordClose(code, reason)
|
||||||
|
stopHeartbeat()
|
||||||
|
update(state = State.STOPPED)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
val reason = t.message ?: t.javaClass.simpleName
|
val reason = t.message ?: t.javaClass.simpleName
|
||||||
Log.w(TAG, "连接失败:$reason")
|
Log.w(TAG, "连接失败:$reason")
|
||||||
update(state = State.CLOSED, error = reason)
|
ConnectionLog.record("onFailure ${t.javaClass.simpleName}: $reason")
|
||||||
|
stopHeartbeat()
|
||||||
|
// response != null 时说明 HTTP 升级被拒(例如路径/子协议不对)
|
||||||
|
val httpCode = response?.code ?: 0
|
||||||
|
update(
|
||||||
|
state = State.STOPPED,
|
||||||
|
error = if (httpCode > 0) "HTTP $httpCode:$reason" else reason,
|
||||||
|
)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun recordClose(code: Int, reason: String) {
|
||||||
|
_stats.value = _stats.value.copy(
|
||||||
|
lastCloseCode = code,
|
||||||
|
lastCloseReason = reason,
|
||||||
|
lastDisconnectAtMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
ConnectionLog.record("断开:${ReconnectPolicy.closeCodeLabel(code)}")
|
||||||
|
}
|
||||||
|
|
||||||
private fun scheduleReconnect() {
|
private fun scheduleReconnect() {
|
||||||
if (manualClose || !autoReconnect) return
|
if (!ReconnectPolicy.shouldReconnect(_stats.value.lastCloseCode, manualClose) || !autoReconnect) {
|
||||||
|
ConnectionLog.record(
|
||||||
|
"不重连(closeCode=${_stats.value.lastCloseCode},manualClose=$manualClose," +
|
||||||
|
"autoReconnect=$autoReconnect)",
|
||||||
|
)
|
||||||
|
update(state = State.STOPPED)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
val attempt = reconnectCounter.incrementAndGet()
|
val attempt = reconnectCounter.incrementAndGet()
|
||||||
val delayMs = min(BASE_RECONNECT_MS shl (attempt - 1), MAX_RECONNECT_MS.toInt()).toLong()
|
// 抖动:±20%,避免同网段多台设备同时重连造成尖峰
|
||||||
update(state = State.RECONNECTING, attempts = attempt)
|
val delayMs = ReconnectPolicy.delayWithJitterMs(attempt, Random.nextDouble())
|
||||||
|
update(state = State.RECONNECTING, attempts = attempt, nextRetryMs = delayMs)
|
||||||
|
ConnectionLog.record("安排第 $attempt 次重连,等待 ${delayMs}ms")
|
||||||
|
|
||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
if (!manualClose && autoReconnect) {
|
if (!manualClose && autoReconnect) {
|
||||||
Log.i(TAG, "第 $attempt 次重连(等待 ${delayMs}ms)")
|
|
||||||
openSocket(initial = false)
|
openSocket(initial = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,18 +314,67 @@ class PoseWebSocketClient(
|
|||||||
manualClose = true
|
manualClose = true
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
|
stopHeartbeat()
|
||||||
|
ConnectionLog.record("disconnect():用户主动停止")
|
||||||
sendBye("user_stop")
|
sendBye("user_stop")
|
||||||
closeSocketQuietly()
|
closeSocketQuietly()
|
||||||
update(state = State.CLOSED)
|
update(state = State.STOPPED)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun closeSocketQuietly() {
|
private fun closeSocketQuietly() {
|
||||||
webSocket?.let {
|
webSocket?.let {
|
||||||
it.cancel()
|
runCatching { it.cancel() }
|
||||||
}
|
}
|
||||||
webSocket = null
|
webSocket = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 保活
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 契约 §1.1:按接收端声明的心跳间隔发送应用层 `ping`。
|
||||||
|
* 画面里没有人 / 相机暂停时,只有它能保住连接。
|
||||||
|
*/
|
||||||
|
private fun startHeartbeat() {
|
||||||
|
stopHeartbeat()
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
while (true) {
|
||||||
|
val interval = _stats.value.heartbeatIntervalMs.coerceAtLeast(
|
||||||
|
ReconnectPolicy.MIN_HEARTBEAT_MS,
|
||||||
|
)
|
||||||
|
delay(interval)
|
||||||
|
val state = _stats.value.state
|
||||||
|
if (state != State.STREAMING && state != State.DEGRADED && state != State.HANDSHAKING) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sendPing()
|
||||||
|
val silentFor = System.currentTimeMillis() - lastPongAtMs
|
||||||
|
val limit = interval * ReconnectPolicy.MISSED_PONG_LIMIT
|
||||||
|
if (lastPongAtMs > 0 && silentFor > limit) {
|
||||||
|
ConnectionLog.record(
|
||||||
|
"连续 ${ReconnectPolicy.MISSED_PONG_LIMIT} 个心跳周期未收到 pong" +
|
||||||
|
"(已静默 ${silentFor}ms),判定链路已死,主动重连",
|
||||||
|
)
|
||||||
|
// 主动取消 socket:OkHttp 会回调 onFailure,从而走正常重连路径
|
||||||
|
closeSocketQuietly()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendPing() {
|
||||||
|
sendJson(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("type", "ping")
|
||||||
|
put("sentAtMs", System.currentTimeMillis())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 发送
|
// ------------------------------------------------------------------ 发送
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +383,10 @@ class PoseWebSocketClient(
|
|||||||
*/
|
*/
|
||||||
fun sendFrame(frame: KeypointFrame.PoseFrame): Boolean {
|
fun sendFrame(frame: KeypointFrame.PoseFrame): Boolean {
|
||||||
val socket = webSocket ?: return drop()
|
val socket = webSocket ?: return drop()
|
||||||
if (_stats.value.state != State.CONNECTED) return drop()
|
val state = _stats.value.state
|
||||||
|
if (state != State.STREAMING && state != State.DEGRADED && state != State.HANDSHAKING) {
|
||||||
|
return drop()
|
||||||
|
}
|
||||||
|
|
||||||
val frameId = frameIdCounter.getAndIncrement()
|
val frameId = frameIdCounter.getAndIncrement()
|
||||||
val bytes = KeypointFrame.encode(
|
val bytes = KeypointFrame.encode(
|
||||||
@@ -199,9 +399,22 @@ class PoseWebSocketClient(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val ok = socket.send(okio.ByteString.of(*bytes))
|
val ok = socket.send(okio.ByteString.of(*bytes))
|
||||||
if (!ok) return drop()
|
if (!ok) {
|
||||||
|
// 发送失败通常意味着底层连接已断;标记 degraded 并让 OkHttp 触发 onFailure
|
||||||
|
markDegraded("发送失败(缓冲区满或连接已断)")
|
||||||
|
return drop()
|
||||||
|
}
|
||||||
|
|
||||||
sentCounter.incrementAndGet()
|
sentCounter.incrementAndGet()
|
||||||
|
if (_stats.value.firstFrameAtMs == 0L) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
_stats.value = _stats.value.copy(firstFrameAtMs = now)
|
||||||
|
ConnectionLog.record("streaming:首帧已发出")
|
||||||
|
}
|
||||||
|
if (_stats.value.state == State.HANDSHAKING) {
|
||||||
|
_stats.value = _stats.value.copy(state = State.STREAMING)
|
||||||
|
}
|
||||||
|
|
||||||
fpsWindowFrames++
|
fpsWindowFrames++
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (fpsWindowStart == 0L) fpsWindowStart = now
|
if (fpsWindowStart == 0L) fpsWindowStart = now
|
||||||
@@ -230,11 +443,20 @@ class PoseWebSocketClient(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 契约 §8:仍连接但异常(发送失败 / 持续丢帧)时进入 degraded */
|
||||||
|
private fun markDegraded(reason: String) {
|
||||||
|
if (_stats.value.state != State.DEGRADED) {
|
||||||
|
ConnectionLog.record("degraded:$reason")
|
||||||
|
}
|
||||||
|
_stats.value = _stats.value.copy(state = State.DEGRADED, lastError = reason)
|
||||||
|
}
|
||||||
|
|
||||||
fun sendHello() {
|
fun sendHello() {
|
||||||
sendJson(
|
sendJson(
|
||||||
JSONObject().apply {
|
JSONObject().apply {
|
||||||
put("type", "hello")
|
put("type", "hello")
|
||||||
put("protocol", subprotocol)
|
put("protocol", subprotocol)
|
||||||
|
put("deviceId", deviceId)
|
||||||
put("device", deviceLabel)
|
put("device", deviceLabel)
|
||||||
put("appVersion", BuildConfig.VERSION_NAME)
|
put("appVersion", BuildConfig.VERSION_NAME)
|
||||||
put("cameraFacing", "front")
|
put("cameraFacing", "front")
|
||||||
@@ -242,6 +464,7 @@ class PoseWebSocketClient(
|
|||||||
put("resolution", RESOLUTION_FIELD)
|
put("resolution", RESOLUTION_FIELD)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
ConnectionLog.record("hello 已发送(deviceId=${deviceId.ifBlank { "(空)" }})")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendStats(fps: Float, dropped: Int, latencyMs: Long, confidenceAvg: Float) {
|
fun sendStats(fps: Float, dropped: Int, latencyMs: Long, confidenceAvg: Float) {
|
||||||
@@ -267,8 +490,9 @@ class PoseWebSocketClient(
|
|||||||
|
|
||||||
private fun sendJson(json: JSONObject) {
|
private fun sendJson(json: JSONObject) {
|
||||||
val socket = webSocket ?: return
|
val socket = webSocket ?: return
|
||||||
if (_stats.value.state != State.CONNECTED) return
|
val state = _stats.value.state
|
||||||
socket.send(json.toString())
|
if (state == State.IDLE || state == State.STOPPED || state == State.RECONNECTING) return
|
||||||
|
runCatching { socket.send(json.toString()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleControlMessage(text: String) {
|
private fun handleControlMessage(text: String) {
|
||||||
@@ -276,19 +500,81 @@ class PoseWebSocketClient(
|
|||||||
val json = JSONObject(text)
|
val json = JSONObject(text)
|
||||||
when (json.optString("type")) {
|
when (json.optString("type")) {
|
||||||
"welcome" -> {
|
"welcome" -> {
|
||||||
|
val silentTimeoutMs = json.optLong("silentTimeoutMs", 0L)
|
||||||
|
val heartbeat = if (json.has("heartbeatIntervalMs")) {
|
||||||
|
json.optLong("heartbeatIntervalMs", 0L)
|
||||||
|
} else {
|
||||||
|
ReconnectPolicy.heartbeatIntervalMs(silentTimeoutMs)
|
||||||
|
}
|
||||||
|
val safe = ReconnectPolicy.isHeartbeatSafe(heartbeat, silentTimeoutMs)
|
||||||
_stats.value = _stats.value.copy(
|
_stats.value = _stats.value.copy(
|
||||||
|
state = State.STREAMING,
|
||||||
serverSessionId = json.optString("sessionId", ""),
|
serverSessionId = json.optString("sessionId", ""),
|
||||||
roundTripMs = 0,
|
roundTripMs = 0,
|
||||||
|
silentTimeoutMs = if (silentTimeoutMs > 0) silentTimeoutMs
|
||||||
|
else ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS,
|
||||||
|
heartbeatIntervalMs = heartbeat.coerceAtLeast(ReconnectPolicy.MIN_HEARTBEAT_MS),
|
||||||
|
)
|
||||||
|
ConnectionLog.record(
|
||||||
|
"welcome:sessionId=${json.optString("sessionId", "")} " +
|
||||||
|
"silentTimeoutMs=$silentTimeoutMs heartbeatIntervalMs=$heartbeat " +
|
||||||
|
"契约§1.1 校验=${if (safe) "通过" else "未通过(用保守值兜底)"}",
|
||||||
|
)
|
||||||
|
if (!safe) {
|
||||||
|
// 服务端没声明或声明不合理时,退回保守默认,绝不比服务端超时还慢
|
||||||
|
_stats.value = _stats.value.copy(
|
||||||
|
silentTimeoutMs = ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS,
|
||||||
|
heartbeatIntervalMs = ReconnectPolicy.heartbeatIntervalMs(
|
||||||
|
ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"config" -> onRemoteConfig?.invoke(json)
|
startHeartbeat()
|
||||||
"bye" -> Log.i(TAG, "服务端要求断开:${json.optString("reason")}")
|
}
|
||||||
|
"config" -> {
|
||||||
|
if (json.has("silentTimeoutMs") || json.has("heartbeatIntervalMs")) {
|
||||||
|
applyRemoteKeepalive(json)
|
||||||
|
}
|
||||||
|
onRemoteConfig?.invoke(json)
|
||||||
|
}
|
||||||
|
"pong" -> {
|
||||||
|
lastPongAtMs = System.currentTimeMillis()
|
||||||
|
pongCounter.incrementAndGet()
|
||||||
|
val sentAt = json.optLong("sentAtMs", 0L)
|
||||||
|
val rtt = if (sentAt > 0) System.currentTimeMillis() - sentAt else -1L
|
||||||
|
if (rtt >= 0) {
|
||||||
|
_stats.value = _stats.value.copy(
|
||||||
|
roundTripMs = rtt,
|
||||||
|
pongsReceived = pongCounter.get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"bye" -> {
|
||||||
|
val reason = json.optString("reason")
|
||||||
|
ConnectionLog.record("服务端要求断开:$reason")
|
||||||
|
_stats.value = _stats.value.copy(lastError = "服务端要求断开:$reason")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "控制帧解析失败:${e.message}")
|
Log.w(TAG, "控制帧解析失败:${e.message}")
|
||||||
|
ConnectionLog.record("控制帧解析失败:${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyRemoteKeepalive(json: JSONObject) {
|
||||||
|
val silent = json.optLong("silentTimeoutMs", _stats.value.silentTimeoutMs)
|
||||||
|
val heartbeat = json.optLong(
|
||||||
|
"heartbeatIntervalMs",
|
||||||
|
ReconnectPolicy.heartbeatIntervalMs(silent),
|
||||||
|
)
|
||||||
|
_stats.value = _stats.value.copy(
|
||||||
|
silentTimeoutMs = silent,
|
||||||
|
heartbeatIntervalMs = heartbeat.coerceAtLeast(ReconnectPolicy.MIN_HEARTBEAT_MS),
|
||||||
|
)
|
||||||
|
ConnectionLog.record("config:保活参数更新 silentTimeoutMs=$silent heartbeatIntervalMs=$heartbeat")
|
||||||
|
startHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
/** 服务端下发 config 时回调(契约 §3.3) */
|
/** 服务端下发 config 时回调(契约 §3.3) */
|
||||||
var onRemoteConfig: ((JSONObject) -> Unit)? = null
|
var onRemoteConfig: ((JSONObject) -> Unit)? = null
|
||||||
|
|
||||||
@@ -296,12 +582,14 @@ class PoseWebSocketClient(
|
|||||||
state: State,
|
state: State,
|
||||||
error: String = _stats.value.lastError,
|
error: String = _stats.value.lastError,
|
||||||
attempts: Int = _stats.value.reconnectAttempts,
|
attempts: Int = _stats.value.reconnectAttempts,
|
||||||
|
nextRetryMs: Long = _stats.value.nextRetryMs,
|
||||||
) {
|
) {
|
||||||
_stats.value = _stats.value.copy(
|
_stats.value = _stats.value.copy(
|
||||||
state = state,
|
state = state,
|
||||||
url = url,
|
url = url,
|
||||||
lastError = error,
|
lastError = error,
|
||||||
reconnectAttempts = attempts,
|
reconnectAttempts = attempts,
|
||||||
|
nextRetryMs = if (state == State.RECONNECTING) nextRetryMs else 0L,
|
||||||
sentFrames = sentCounter.get(),
|
sentFrames = sentCounter.get(),
|
||||||
droppedFrames = droppedCounter.get(),
|
droppedFrames = droppedCounter.get(),
|
||||||
)
|
)
|
||||||
@@ -310,15 +598,20 @@ class PoseWebSocketClient(
|
|||||||
fun release() {
|
fun release() {
|
||||||
disconnect()
|
disconnect()
|
||||||
client.dispatcher.executorService.shutdown()
|
client.dispatcher.executorService.shutdown()
|
||||||
|
client.connectionPool.evictAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PoseWebSocket"
|
private const val TAG = "PoseWebSocket"
|
||||||
private const val CONNECT_TIMEOUT_SECONDS = 8L
|
private const val CONNECT_TIMEOUT_SECONDS = 8L
|
||||||
private const val WRITE_TIMEOUT_SECONDS = 5L
|
private const val WRITE_TIMEOUT_SECONDS = 5L
|
||||||
private const val PING_INTERVAL_SECONDS = 15L
|
|
||||||
private const val BASE_RECONNECT_MS = 1000
|
/**
|
||||||
private const val MAX_RECONNECT_MS = 15000L
|
* 协议层 ping 间隔。**不能**替代应用层 ping:
|
||||||
|
* 接收端(Windows)的静默超时只看应用层收到的帧,
|
||||||
|
* 而 WebSocket 协议层 ping 由底层库自动应答,不暴露给应用层。
|
||||||
|
*/
|
||||||
|
private const val PROTOCOL_PING_INTERVAL_SECONDS = 15L
|
||||||
private const val FPS_WINDOW_MS = 1000L
|
private const val FPS_WINDOW_MS = 1000L
|
||||||
|
|
||||||
/** hello 里的字段由设置填充,运行时由调用方覆盖 */
|
/** hello 里的字段由设置填充,运行时由调用方覆盖 */
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.opendance.studio.net
|
||||||
|
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-AND-011:重连策略(契约 websocket-keypoints.md v0.2.0 §7 / §7.1)
|
||||||
|
*
|
||||||
|
* 抽成纯函数对象的原因:这是"频繁重连"缺陷的核心逻辑,
|
||||||
|
* 必须能在 JVM 上(无需设备、无需网络)逐值核对,而不是靠真机观察。
|
||||||
|
*/
|
||||||
|
object ReconnectPolicy {
|
||||||
|
|
||||||
|
/** 契约 §7.1:首次退避 500ms */
|
||||||
|
const val BASE_DELAY_MS = 500L
|
||||||
|
|
||||||
|
/** 契约 §7.1:退避封顶 15s */
|
||||||
|
const val MAX_DELAY_MS = 15_000L
|
||||||
|
|
||||||
|
/** 契约 §7.1:±20% 抖动 */
|
||||||
|
const val JITTER_RATIO = 0.2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 [attempt] 次重连的基础等待(attempt 从 1 开始):
|
||||||
|
* `min(500ms * 2^(attempt-1), 15000ms)`
|
||||||
|
* 即 0.5s → 1s → 2s → 4s → 8s → 15s(封顶)。
|
||||||
|
* attempt <= 0 时返回 0(没有重连计划)。
|
||||||
|
*/
|
||||||
|
fun baseDelayMs(attempt: Int): Long {
|
||||||
|
if (attempt <= 0) return 0L
|
||||||
|
val shift = attempt - 1
|
||||||
|
// 500 << 5 = 16000 已超过封顶,再大的 attempt 一律封顶
|
||||||
|
if (shift >= 5) return MAX_DELAY_MS
|
||||||
|
return min(BASE_DELAY_MS shl shift, MAX_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 叠加抖动后的实际等待。
|
||||||
|
*
|
||||||
|
* @param randomFraction [0,1) 的随机数;由调用方注入,测试可传固定值确定化。
|
||||||
|
* factor = 1 - 0.2 + 0.4 * fraction ∈ [0.8, 1.2]
|
||||||
|
*/
|
||||||
|
fun delayWithJitterMs(attempt: Int, randomFraction: Double): Long {
|
||||||
|
val base = baseDelayMs(attempt)
|
||||||
|
if (base <= 0L) return 0L
|
||||||
|
val clamped = randomFraction.coerceIn(0.0, 1.0)
|
||||||
|
val factor = 1.0 - JITTER_RATIO + 2 * JITTER_RATIO * clamped
|
||||||
|
return (base * factor).toLong().coerceAtLeast(1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 契约 §7:**只有真正断开才重连**。
|
||||||
|
*
|
||||||
|
* | 关闭码 | 含义 | 是否重连 |
|
||||||
|
* |--|--|--|
|
||||||
|
* | 1000 | 任一方主动结束(含收到 bye) | 否 |
|
||||||
|
* | 1001 | 接收端主动关闭(服务重启 / 静默超时踢人) | 否 |
|
||||||
|
* | 1002 | 协议错误(重连也不会成功) | 否 |
|
||||||
|
* | 其他 / 0(未收到关闭帧)| 网络抖动、对端进程退出 | 是 |
|
||||||
|
*
|
||||||
|
* @param manualClose 用户主动「结束推流」;为 true 时任何情况都不重连
|
||||||
|
*/
|
||||||
|
fun shouldReconnect(closeCode: Int, manualClose: Boolean): Boolean {
|
||||||
|
if (manualClose) return false
|
||||||
|
return when (closeCode) {
|
||||||
|
1000, 1001, 1002 -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭码的中文说明(界面「最近一次断开原因」与日志共用) */
|
||||||
|
fun closeCodeLabel(code: Int): String = when (code) {
|
||||||
|
0 -> "未收到关闭帧(异常断开)"
|
||||||
|
1000 -> "正常关闭(主动结束)"
|
||||||
|
1001 -> "对端离开(服务重启 / 静默超时)"
|
||||||
|
1002 -> "协议错误(子协议或版本不匹配)"
|
||||||
|
1003 -> "对端不支持收到的数据类型"
|
||||||
|
1006 -> "连接异常断开(网络抖动)"
|
||||||
|
1011 -> "对端内部错误"
|
||||||
|
1012 -> "对端服务重启"
|
||||||
|
1013 -> "对端过载,请稍后重试"
|
||||||
|
else -> "关闭码 $code"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保活间隔:契约 §1.1 要求小于静默超时的一半。
|
||||||
|
* 服务端未声明时用保守默认值(超时 5s、心跳 1s)。
|
||||||
|
*/
|
||||||
|
fun heartbeatIntervalMs(silentTimeoutMs: Long): Long {
|
||||||
|
val timeout = if (silentTimeoutMs > 0) silentTimeoutMs else DEFAULT_SILENT_TIMEOUT_MS
|
||||||
|
return (timeout / 3).coerceIn(MIN_HEARTBEAT_MS, MAX_HEARTBEAT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 契约 §1.1 的约束校验:心跳必须严格小于静默超时的一半 */
|
||||||
|
fun isHeartbeatSafe(heartbeatMs: Long, silentTimeoutMs: Long): Boolean =
|
||||||
|
silentTimeoutMs > 0 && heartbeatMs > 0 && heartbeatMs * 2 < silentTimeoutMs
|
||||||
|
|
||||||
|
const val DEFAULT_SILENT_TIMEOUT_MS = 5_000L
|
||||||
|
const val MIN_HEARTBEAT_MS = 1_000L
|
||||||
|
const val MAX_HEARTBEAT_MS = 5_000L
|
||||||
|
|
||||||
|
/** 连续多少个心跳周期没收到 pong 就判定链路已死(触发一次重连而不是干等) */
|
||||||
|
const val MISSED_PONG_LIMIT = 3
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.opendance.studio.pose
|
package com.opendance.studio.pose
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
import com.google.mediapipe.framework.image.BitmapImageBuilder
|
import com.google.mediapipe.framework.image.BitmapImageBuilder
|
||||||
@@ -13,15 +15,27 @@ import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarker
|
|||||||
import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarkerResult
|
import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarkerResult
|
||||||
import com.opendance.studio.model.KeypointFrame
|
import com.opendance.studio.model.KeypointFrame
|
||||||
import com.opendance.studio.model.SkeletonTopology
|
import com.opendance.studio.model.SkeletonTopology
|
||||||
|
import com.opendance.studio.util.ConnectionLog
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T-AND-003:MediaPipe Pose 端侧推理
|
* T-AND-003 / T-AND-012:MediaPipe Pose 端侧推理
|
||||||
*
|
*
|
||||||
* 使用 LIVE_STREAM 模式:`detectAsync` 立即返回,结果通过监听器异步回调。
|
* 使用 LIVE_STREAM 模式:`detectAsync` 立即返回,结果通过监听器异步回调。
|
||||||
* 模型 `pose_landmarker_lite.task` 随 APK 分发(assets/,不压缩)。
|
* 模型 `pose_landmarker_lite.task` 随 APK 分发(assets/,不压缩)。
|
||||||
*
|
*
|
||||||
* 输出 33 个归一化关键点,直接对齐契约 contracts/skeleton-topology.md §1
|
* 输出 33 个归一化关键点,直接对齐契约 contracts/skeleton-topology.md §1
|
||||||
* 的索引顺序(MediaPipe Pose 的原始顺序即是契约顺序)。
|
* 的索引顺序(MediaPipe Pose 的原始顺序即是契约顺序)。
|
||||||
|
*
|
||||||
|
* T-AND-012 的修复要点(`ExceptionInInitializerError: null`):
|
||||||
|
* 1. **先拿到真因**:ExceptionInInitializerError 的信息量全在 cause 链里
|
||||||
|
* (通常是 UnsatisfiedLinkError)。[PoseDiagnostics] 负责展开并给出
|
||||||
|
* ABI 维度的人话结论,而不是把 "null" 丢给用户;
|
||||||
|
* 2. **显式核对 ABI**:读自己 APK 的 lib/ 目录,和 Build.SUPPORTED_ABIS 比对,
|
||||||
|
* 直接指出"release 包装在 x86_64 模拟器"这种必崩组合;
|
||||||
|
* 3. **native 库加载方式**:release 由 build.gradle.kts 的
|
||||||
|
* `jniLibs.useLegacyPackaging = true` 保证走解压加载;
|
||||||
|
* 4. 失败时把诊断写入 [ConnectionLog],随"导出连接日志"一起回传。
|
||||||
*/
|
*/
|
||||||
class PoseDetector(private val context: Context) {
|
class PoseDetector(private val context: Context) {
|
||||||
|
|
||||||
@@ -38,6 +52,9 @@ class PoseDetector(private val context: Context) {
|
|||||||
private var lastTimestampMs = 0L
|
private var lastTimestampMs = 0L
|
||||||
private var busy = false
|
private var busy = false
|
||||||
|
|
||||||
|
/** APK 内实际包含的 ABI 目录(懒加载,用于诊断) */
|
||||||
|
private val apkAbis: List<String> by lazy { readApkAbis() }
|
||||||
|
|
||||||
var onResult: ((PoseResult) -> Unit)? = null
|
var onResult: ((PoseResult) -> Unit)? = null
|
||||||
var onError: ((String) -> Unit)? = null
|
var onError: ((String) -> Unit)? = null
|
||||||
|
|
||||||
@@ -83,12 +100,32 @@ class PoseDetector(private val context: Context) {
|
|||||||
// 静态初始化失败会抛 ExceptionInInitializerError,它们都不是 Exception。
|
// 静态初始化失败会抛 ExceptionInInitializerError,它们都不是 Exception。
|
||||||
// 只 catch Exception 会让这类失败直接冒泡成整个应用崩溃。
|
// 只 catch Exception 会让这类失败直接冒泡成整个应用崩溃。
|
||||||
Log.e(TAG, "PoseLandmarker 初始化失败", t)
|
Log.e(TAG, "PoseLandmarker 初始化失败", t)
|
||||||
onError?.invoke("MediaPipe 初始化失败:${t.javaClass.simpleName}: ${t.message}")
|
val report = PoseDiagnostics.describe(
|
||||||
|
t = t,
|
||||||
|
deviceAbis = Build.SUPPORTED_ABIS.toList(),
|
||||||
|
apkAbis = apkAbis,
|
||||||
|
)
|
||||||
|
Log.e(TAG, "初始化诊断:\n$report")
|
||||||
|
ConnectionLog.record("MediaPipe 初始化失败:\n$report")
|
||||||
|
onError?.invoke("MediaPipe 初始化失败:\n$report")
|
||||||
landmarker = null
|
landmarker = null
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读自己 APK 的 lib/ 目录,得到实际打包进去的 ABI 列表 */
|
||||||
|
private fun readApkAbis(): List<String> = runCatching {
|
||||||
|
val info: ApplicationInfo = context.applicationInfo
|
||||||
|
ZipFile(info.sourceDir).use { zip ->
|
||||||
|
PoseDiagnostics.abisFromEntries(
|
||||||
|
zip.entries().asSequence().map { it.name }.toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrElse {
|
||||||
|
Log.w(TAG, "读取 APK ABI 列表失败:${it.message}", it)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交一帧做异步推理;本方法不阻塞调用线程,且**不负责关闭 imageProxy**。
|
* 提交一帧做异步推理;本方法不阻塞调用线程,且**不负责关闭 imageProxy**。
|
||||||
*
|
*
|
||||||
@@ -105,6 +142,9 @@ class PoseDetector(private val context: Context) {
|
|||||||
|
|
||||||
val mpImage: MPImage = BitmapImageBuilder(rotated).build()
|
val mpImage: MPImage = BitmapImageBuilder(rotated).build()
|
||||||
val safeTimestamp = if (timestampMs <= lastTimestampMs) lastTimestampMs + 1 else timestampMs
|
val safeTimestamp = if (timestampMs <= lastTimestampMs) lastTimestampMs + 1 else timestampMs
|
||||||
|
// 必须把时间戳记下来:handleResult 用它派生 frame_id 与结果时间戳。
|
||||||
|
// 原先漏了这一步,导致 frame_id 恒为 0,接收端无法按 frame_id 检测丢包。
|
||||||
|
lastTimestampMs = safeTimestamp
|
||||||
busy = true
|
busy = true
|
||||||
detector.detectAsync(mpImage, safeTimestamp)
|
detector.detectAsync(mpImage, safeTimestamp)
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.opendance.studio.pose
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-AND-012:MediaPipe 初始化失败诊断
|
||||||
|
*
|
||||||
|
* `ExceptionInInitializerError: null` 这种报错**没有任何信息量**,
|
||||||
|
* 真因几乎总是藏在 `cause` 里的 `UnsatisfiedLinkError`(native 库加载失败)。
|
||||||
|
* 本对象只做纯字符串/列表层面的诊断,因此可以在 JVM 单测里逐条核对,
|
||||||
|
* 不必依赖真机。
|
||||||
|
*/
|
||||||
|
object PoseDiagnostics {
|
||||||
|
|
||||||
|
/** 展开完整因果链:`A: msg <- B: msg <- C: msg` */
|
||||||
|
fun causeChain(t: Throwable): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
var current: Throwable? = t
|
||||||
|
var guard = 0
|
||||||
|
while (current != null && guard < 10) {
|
||||||
|
val message = current.message
|
||||||
|
parts += if (message.isNullOrBlank()) {
|
||||||
|
current.javaClass.name
|
||||||
|
} else {
|
||||||
|
"${current.javaClass.name}: $message"
|
||||||
|
}
|
||||||
|
if (current.cause === current) break
|
||||||
|
current = current.cause
|
||||||
|
guard++
|
||||||
|
}
|
||||||
|
return parts.joinToString(" <- ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最内层真因 */
|
||||||
|
fun rootCause(t: Throwable): Throwable {
|
||||||
|
var current = t
|
||||||
|
var guard = 0
|
||||||
|
while (current.cause != null && current.cause !== current && guard < 10) {
|
||||||
|
current = current.cause!!
|
||||||
|
guard++
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否属于「native 库加载 / 静态初始化」这一类失败 */
|
||||||
|
fun isNativeLoadFailure(t: Throwable): Boolean {
|
||||||
|
var current: Throwable? = t
|
||||||
|
var guard = 0
|
||||||
|
while (current != null && guard < 10) {
|
||||||
|
when (current) {
|
||||||
|
is UnsatisfiedLinkError,
|
||||||
|
is ExceptionInInitializerError,
|
||||||
|
is NoClassDefFoundError,
|
||||||
|
-> return true
|
||||||
|
}
|
||||||
|
current = current.cause
|
||||||
|
guard++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 APK 的 zip 条目里取出 ABI 目录集合。
|
||||||
|
* 输入形如 `["lib/arm64-v8a/libmediapipe_tasks_vision_jni.so", "assets/..."]`。
|
||||||
|
*/
|
||||||
|
fun abisFromEntries(entries: List<String>): List<String> =
|
||||||
|
entries.filter { it.startsWith("lib/") }
|
||||||
|
.map { it.removePrefix("lib/").substringBefore('/') }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
/** 某个 ABI 下是否带 MediaPipe 的 native 库 */
|
||||||
|
fun hasMediaPipeNative(entries: List<String>, abi: String): Boolean =
|
||||||
|
entries.any { it.startsWith("lib/$abi/") && it.contains("mediapipe") }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最典型的坑:在 **x86_64 模拟器**上安装了 release 包。
|
||||||
|
* release 只打包 arm64-v8a / armeabi-v7a(真机 ABI),模拟器必然缺 native 库。
|
||||||
|
*
|
||||||
|
* @param deviceAbis `Build.SUPPORTED_ABIS`
|
||||||
|
* @param apkAbis APK 内实际包含的 ABI 目录
|
||||||
|
* @return 需要提示时返回中文建议,否则返回 null
|
||||||
|
*/
|
||||||
|
fun abiMismatchHint(deviceAbis: List<String>, apkAbis: List<String>): String? {
|
||||||
|
if (deviceAbis.isEmpty() || apkAbis.isEmpty()) return null
|
||||||
|
if (deviceAbis.any { apkAbis.contains(it) }) return null
|
||||||
|
|
||||||
|
val isEmulatorAbi = deviceAbis.any { it == "x86_64" || it == "x86" }
|
||||||
|
val host = if (isEmulatorAbi) "模拟器(${deviceAbis.first()})" else "本机(${deviceAbis.joinToString()})"
|
||||||
|
return buildString {
|
||||||
|
append("APK 只包含 ${apkAbis.joinToString()},而 $host 需要 ")
|
||||||
|
append(deviceAbis.joinToString())
|
||||||
|
append(" 的 native 库 —— 这是 release 包在模拟器上必崩的根因。")
|
||||||
|
append("请在真机(arm64)上安装 release 包,或改用 debug 包在模拟器联调。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 汇总成一条可读的中文诊断(写入界面与崩溃日志)。
|
||||||
|
*/
|
||||||
|
fun describe(
|
||||||
|
t: Throwable,
|
||||||
|
deviceAbis: List<String>,
|
||||||
|
apkAbis: List<String>,
|
||||||
|
): String {
|
||||||
|
val root = rootCause(t)
|
||||||
|
val segments = mutableListOf<String>()
|
||||||
|
segments += "${t.javaClass.simpleName}: ${t.message ?: "无消息"}"
|
||||||
|
if (root !== t) {
|
||||||
|
segments += "真因(cause 链最内层):${root.javaClass.name}: ${root.message ?: "无消息"}"
|
||||||
|
}
|
||||||
|
if (isNativeLoadFailure(t)) {
|
||||||
|
segments += "判定:native 库加载 / 类静态初始化失败(不是模型文件或相机问题)"
|
||||||
|
}
|
||||||
|
segments += "设备 ABI:${deviceAbis.joinToString().ifBlank { "未知" }}"
|
||||||
|
segments += "APK 内 ABI:${apkAbis.joinToString().ifBlank { "未知" }}"
|
||||||
|
abiMismatchHint(deviceAbis, apkAbis)?.let { segments += it }
|
||||||
|
if (root is UnsatisfiedLinkError) {
|
||||||
|
segments += "缺少的具体库:${root.message ?: "未提供库名"}"
|
||||||
|
}
|
||||||
|
return segments.joinToString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,13 @@ import com.opendance.studio.ui.theme.OpenDanceSurface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* T-AND-008:关于页面 + 开源声明
|
* T-AND-008:关于页面 + 开源声明
|
||||||
|
* T-AND-010:关于清单展示当前版本号(文档 §11.1:Android App 在「关于」清单)
|
||||||
*
|
*
|
||||||
* 文档 §8.3 要求 Android App 与 Windows 客户端的「关于」页展示同款声明,可滚动查看。
|
* 文档 §8.3 要求 Android App 与 Windows 客户端的「关于」页展示同款声明,可滚动查看。
|
||||||
|
*
|
||||||
|
* 版本号**唯一来源**是 `app/build.gradle.kts` 的 `versionName`,即
|
||||||
|
* [BuildConfig.VERSION_NAME](= project.yaml 的 version = APK 文件名 = Git tag);
|
||||||
|
* 契约版本来自构建期解析的 `contracts/VERSION`。界面代码里不出现任何版本号字面量。
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -77,12 +82,18 @@ fun AboutScreen(onBack: () -> Unit) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Text("OpenDance 辅助端", style = MaterialTheme.typography.titleLarge)
|
Text("OpenDance 辅助端", style = MaterialTheme.typography.titleLarge)
|
||||||
Text("v${BuildConfig.VERSION_NAME}", color = OpenDanceMuted)
|
Text("v${Credits.appVersion}", color = OpenDanceMuted)
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
|
||||||
InfoRow("契约版本", BuildConfig.CONTRACT_VERSION)
|
InfoRow("客户端版本", "v${Credits.appVersion}")
|
||||||
|
InfoRow("版本号(整数)", BuildConfig.VERSION_CODE.toString())
|
||||||
|
// 六项契约各自独立递增,必须逐项展示(不能只显示一个版本)
|
||||||
|
Credits.contractVersions.forEach { (name, version) ->
|
||||||
|
InfoRow("契约 $name", "v$version")
|
||||||
|
}
|
||||||
InfoRow("WebSocket 子协议", BuildConfig.WS_SUBPROTOCOL)
|
InfoRow("WebSocket 子协议", BuildConfig.WS_SUBPROTOCOL)
|
||||||
InfoRow("关键点拓扑", "MediaPipe Pose 33 点")
|
InfoRow("关键点拓扑", "MediaPipe Pose 33 点")
|
||||||
InfoRow("传输帧长", "239 字节")
|
InfoRow("传输帧长", "239 字节")
|
||||||
|
InfoRow("保活", "应用层 ping,静默超时由服务端声明")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +114,12 @@ fun AboutScreen(onBack: () -> Unit) {
|
|||||||
color = OpenDanceMuted,
|
color = OpenDanceMuted,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
"关键点只发给同一局域网内的 Windows 客户端;连接日志保存在应用私有目录," +
|
||||||
|
"需要时由「运行页 -> 导出连接日志」主动分享,不会自动上传。",
|
||||||
|
color = OpenDanceMuted,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
package com.opendance.studio.ui
|
package com.opendance.studio.ui
|
||||||
|
|
||||||
|
import com.opendance.studio.BuildConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T-AND-008:开源声明数据
|
* T-AND-008 / T-AND-010:开源声明与版本信息
|
||||||
*
|
*
|
||||||
* 与 contracts/open-source-credits.md(v0.1.0)逐条对应,
|
* 与 contracts/open-source-credits.md 逐条对应,并与 opendance-web 的
|
||||||
* 并与 opendance-web 的 THIRD_PARTY.md、opendance-windows 的关于页保持一致。
|
* THIRD_PARTY.md、opendance-windows 的关于页保持一致。
|
||||||
* 该表是**唯一来源**,新增依赖须先回到 opendance-web 更新契约。
|
* 依赖表是**唯一来源**,新增依赖须先回到 opendance-web 更新契约。
|
||||||
|
*
|
||||||
|
* 版本号**不在本文件硬编码**(文档 §11.2 第 1 条):
|
||||||
|
* - 应用版本 -> [BuildConfig.VERSION_NAME](来自 versionName,与 project.yaml 同源)
|
||||||
|
* - 契约版本 -> 构建期从 contracts/VERSION 解析后注入的
|
||||||
|
* [BuildConfig.CONTRACT_VERSION_KEYS] / [BuildConfig.CONTRACT_VERSION_VALUES]
|
||||||
|
* 这样"关于"页展示的一定是「当前正在跑的版本」,而不是某次手写的静态文案。
|
||||||
*/
|
*/
|
||||||
object Credits {
|
object Credits {
|
||||||
|
|
||||||
const val CONTRACT_VERSION = "0.1.0"
|
/**
|
||||||
|
* 兼容旧调用点:websocket-keypoints 契约版本(本端为发送方,最相关的一项)。
|
||||||
|
* 新代码请用 [contractVersions] 展示全部契约。
|
||||||
|
*/
|
||||||
|
val CONTRACT_VERSION: String = BuildConfig.CONTRACT_VERSION
|
||||||
|
|
||||||
|
/** 六项契约的 (名称, 版本) 列表,顺序与 contracts/VERSION 一致 */
|
||||||
|
val contractVersions: List<Pair<String, String>>
|
||||||
|
get() = BuildConfig.CONTRACT_VERSION_KEYS
|
||||||
|
.zip(BuildConfig.CONTRACT_VERSION_VALUES)
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
/** 应用版本(唯一来源:versionName) */
|
||||||
|
val appVersion: String get() = BuildConfig.VERSION_NAME
|
||||||
|
|
||||||
|
/** 「清单」用的一行摘要:`rest-api v0.5.0 | websocket-keypoints v0.2.0 | …` */
|
||||||
|
fun contractSummary(): String =
|
||||||
|
contractVersions.joinToString(" | ") { (name, version) -> "$name v$version" }
|
||||||
|
|
||||||
data class Entry(
|
data class Entry(
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import com.opendance.studio.camera.CameraController
|
|||||||
import com.opendance.studio.data.AppSettings
|
import com.opendance.studio.data.AppSettings
|
||||||
import com.opendance.studio.net.PoseWebSocketClient
|
import com.opendance.studio.net.PoseWebSocketClient
|
||||||
import com.opendance.studio.pose.PoseDetector
|
import com.opendance.studio.pose.PoseDetector
|
||||||
|
import com.opendance.studio.util.ConnectionLog
|
||||||
import com.opendance.studio.ui.theme.OpenDanceGreen
|
import com.opendance.studio.ui.theme.OpenDanceGreen
|
||||||
import com.opendance.studio.ui.theme.OpenDanceMuted
|
import com.opendance.studio.ui.theme.OpenDanceMuted
|
||||||
import com.opendance.studio.ui.theme.OpenDanceRed
|
import com.opendance.studio.ui.theme.OpenDanceRed
|
||||||
@@ -69,6 +70,9 @@ import kotlinx.coroutines.launch
|
|||||||
* 1. 相机权限用平台 Activity Result API,不引入 accompanist(避免 Compose 版本偏斜)
|
* 1. 相机权限用平台 Activity Result API,不引入 accompanist(避免 Compose 版本偏斜)
|
||||||
* 2. MediaPipe 回调发生在相机分析线程 —— 所有 Compose 状态写入都 post 回主线程
|
* 2. MediaPipe 回调发生在相机分析线程 —— 所有 Compose 状态写入都 post 回主线程
|
||||||
* 3. 初始化/连接失败只记录并提示,绝不让异常冒泡成崩溃
|
* 3. 初始化/连接失败只记录并提示,绝不让异常冒泡成崩溃
|
||||||
|
*
|
||||||
|
* T-AND-011:连接状态按契约 §8 展示(含 close code / 断开原因 / 重试倒计时),
|
||||||
|
* 并提供「导出连接日志」(契约 §8 的"可诊断"要求)。
|
||||||
*/
|
*/
|
||||||
private const val TAG = "RunningScreen"
|
private const val TAG = "RunningScreen"
|
||||||
|
|
||||||
@@ -102,9 +106,15 @@ fun RunningScreen(
|
|||||||
var errorMessage by remember { mutableStateOf("") }
|
var errorMessage by remember { mutableStateOf("") }
|
||||||
var cameraError by remember { mutableStateOf("") }
|
var cameraError by remember { mutableStateOf("") }
|
||||||
var fatalError by remember { mutableStateOf("") }
|
var fatalError by remember { mutableStateOf("") }
|
||||||
|
var logHint by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val wsStats = remember { PoseWebSocketClient(scope) }
|
val wsStats = remember { PoseWebSocketClient(scope) }
|
||||||
val stats by wsStats.stats.collectAsState()
|
val stats by wsStats.stats.collectAsState()
|
||||||
|
// 契约 §3.1:稳定的设备标识,供接收端按 deviceId 去重
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
wsStats.deviceId = runCatching { settings.deviceId }.getOrDefault("")
|
||||||
|
ConnectionLog.record("deviceId=${wsStats.deviceId.ifBlank { "(空)" }}")
|
||||||
|
}
|
||||||
|
|
||||||
val cameraController = remember { CameraController(context) }
|
val cameraController = remember { CameraController(context) }
|
||||||
val poseDetector = remember { PoseDetector(context) }
|
val poseDetector = remember { PoseDetector(context) }
|
||||||
@@ -290,15 +300,32 @@ fun RunningScreen(
|
|||||||
) {
|
) {
|
||||||
Text("连接状态", color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
Text("连接状态", color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
||||||
Text(
|
Text(
|
||||||
text = stateLabel(stats.state, stats.reconnectAttempts),
|
text = stateLabel(stats.state, stats.reconnectAttempts, stats.nextRetryMs),
|
||||||
color = stateColor(stats.state),
|
color = stateColor(stats.state),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
InfoLine("状态机", stats.state.wireName)
|
||||||
InfoLine("目标", stats.url)
|
InfoLine("目标", stats.url)
|
||||||
if (stats.serverSessionId.isNotBlank()) {
|
if (stats.serverSessionId.isNotBlank()) {
|
||||||
InfoLine("会话", stats.serverSessionId)
|
InfoLine("会话", stats.serverSessionId)
|
||||||
}
|
}
|
||||||
|
if (stats.negotiatedProtocol.isNotBlank()) {
|
||||||
|
InfoLine("协商子协议", stats.negotiatedProtocol)
|
||||||
|
}
|
||||||
|
if (stats.lastCloseLabel.isNotBlank()) {
|
||||||
|
InfoLine("最近断开", stats.lastCloseLabel, OpenDanceRed)
|
||||||
|
}
|
||||||
|
if (stats.roundTripMs >= 0) {
|
||||||
|
InfoLine("心跳 RTT", "${stats.roundTripMs} ms")
|
||||||
|
}
|
||||||
|
InfoLine(
|
||||||
|
"保活",
|
||||||
|
"心跳 ${stats.heartbeatIntervalMs} ms / 静默超时 ${stats.silentTimeoutMs} ms",
|
||||||
|
)
|
||||||
|
if (stats.firstFrameAtMs > 0) {
|
||||||
|
InfoLine("首帧", "已发出")
|
||||||
|
}
|
||||||
InfoLine("已发送", "${stats.sentFrames} 帧")
|
InfoLine("已发送", "${stats.sentFrames} 帧")
|
||||||
InfoLine(
|
InfoLine(
|
||||||
"已丢弃",
|
"已丢弃",
|
||||||
@@ -332,6 +359,9 @@ fun RunningScreen(
|
|||||||
if (cameraError.isNotBlank()) {
|
if (cameraError.isNotBlank()) {
|
||||||
InfoLine("相机错误", cameraError, OpenDanceRed)
|
InfoLine("相机错误", cameraError, OpenDanceRed)
|
||||||
}
|
}
|
||||||
|
if (logHint.isNotBlank()) {
|
||||||
|
InfoLine("日志", logHint, OpenDanceGreen)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +388,19 @@ fun RunningScreen(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) { Text("切换摄像头") }
|
) { Text("切换摄像头") }
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
// 契约 §8「可诊断」:一键导出连接日志(close code / 子协议 / 首帧时间)
|
||||||
|
val file = ConnectionLog.share(context)
|
||||||
|
logHint = if (file != null) {
|
||||||
|
"连接日志已导出(${ConnectionLog.size()} 条):${file.name}"
|
||||||
|
} else {
|
||||||
|
"连接日志导出失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { Text("导出连接日志") }
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -386,17 +429,32 @@ private fun InfoLine(label: String, value: String, valueColor: Color = Color.Uns
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stateLabel(state: PoseWebSocketClient.State, attempts: Int): String = when (state) {
|
/**
|
||||||
|
* 契约 §8 的状态显示文案。状态名与接收端一致(wireName),
|
||||||
|
* 这里只是把它翻译成用户能读的中文 + 重试倒计时。
|
||||||
|
*/
|
||||||
|
private fun stateLabel(
|
||||||
|
state: PoseWebSocketClient.State,
|
||||||
|
attempts: Int,
|
||||||
|
nextRetryMs: Long,
|
||||||
|
): String = when (state) {
|
||||||
PoseWebSocketClient.State.IDLE -> "未连接"
|
PoseWebSocketClient.State.IDLE -> "未连接"
|
||||||
|
PoseWebSocketClient.State.RESOLVING -> "解析主机地址…"
|
||||||
PoseWebSocketClient.State.CONNECTING -> "连接中…"
|
PoseWebSocketClient.State.CONNECTING -> "连接中…"
|
||||||
PoseWebSocketClient.State.CONNECTED -> "已连接"
|
PoseWebSocketClient.State.HANDSHAKING -> "握手中…"
|
||||||
PoseWebSocketClient.State.RECONNECTING -> "重连中(第 $attempts 次)"
|
PoseWebSocketClient.State.STREAMING -> "推流中"
|
||||||
PoseWebSocketClient.State.CLOSED -> "已断开"
|
PoseWebSocketClient.State.DEGRADED -> "推流中(链路异常,已丢帧)"
|
||||||
|
PoseWebSocketClient.State.RECONNECTING ->
|
||||||
|
"重连中(第 $attempts 次,${nextRetryMs} ms 后重试)"
|
||||||
|
PoseWebSocketClient.State.STOPPED -> "已停止"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stateColor(state: PoseWebSocketClient.State): Color = when (state) {
|
private fun stateColor(state: PoseWebSocketClient.State): Color = when (state) {
|
||||||
PoseWebSocketClient.State.CONNECTED -> OpenDanceGreen
|
PoseWebSocketClient.State.STREAMING -> OpenDanceGreen
|
||||||
|
PoseWebSocketClient.State.DEGRADED -> OpenDanceYellow
|
||||||
|
PoseWebSocketClient.State.RESOLVING,
|
||||||
PoseWebSocketClient.State.CONNECTING,
|
PoseWebSocketClient.State.CONNECTING,
|
||||||
|
PoseWebSocketClient.State.HANDSHAKING,
|
||||||
PoseWebSocketClient.State.RECONNECTING,
|
PoseWebSocketClient.State.RECONNECTING,
|
||||||
-> OpenDanceYellow
|
-> OpenDanceYellow
|
||||||
else -> OpenDanceRed
|
else -> OpenDanceRed
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.opendance.studio.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentLinkedDeque
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-AND-011:连接日志(契约 §8「可诊断」)
|
||||||
|
*
|
||||||
|
* 记录连接状态迁移、close code / reason、子协议协商结果、首帧时间等关键事件。
|
||||||
|
* 只保留最近 [MAX_ENTRIES] 条,避免长时间运行占用内存;可一键导出为文本文件
|
||||||
|
* 并通过系统分享面板回传(真机问题基本只能靠这份日志定位)。
|
||||||
|
*
|
||||||
|
* 这是**进程内单例**:连接客户端与界面都通过它记录/读取,保证时序一致。
|
||||||
|
* 不使用 AndroidX Startup 之类的初始化框架,显式 [install] 即可。
|
||||||
|
*/
|
||||||
|
object ConnectionLog {
|
||||||
|
|
||||||
|
private const val MAX_ENTRIES = 500
|
||||||
|
private const val TAG = "ConnectionLog"
|
||||||
|
private const val FILE_NAME = "connection-log.txt"
|
||||||
|
|
||||||
|
private val entries = ConcurrentLinkedDeque<String>()
|
||||||
|
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var startedAt: Long = 0L
|
||||||
|
|
||||||
|
/** 记录一条事件(线程安全;可由 OkHttp 回调线程直接调用) */
|
||||||
|
fun record(event: String) {
|
||||||
|
if (startedAt == 0L) startedAt = System.currentTimeMillis()
|
||||||
|
val stamp = timeFormat.format(Date())
|
||||||
|
entries.addLast("[$stamp] $event")
|
||||||
|
while (entries.size > MAX_ENTRIES) {
|
||||||
|
entries.pollFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun entries(): List<String> = entries.toList()
|
||||||
|
|
||||||
|
fun size(): Int = entries.size
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
entries.clear()
|
||||||
|
startedAt = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出的完整文本:带设备 / 应用 / ABI 头部,便于直接贴进 issue */
|
||||||
|
fun toText(context: Context): String = buildString {
|
||||||
|
appendLine("OpenDance Studio 辅助端 —— 连接日志")
|
||||||
|
appendLine("应用版本:${context.packageName} ${appVersion(context)}")
|
||||||
|
appendLine("设备:${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||||
|
appendLine("Android:${android.os.Build.VERSION.RELEASE}(API ${android.os.Build.VERSION.SDK_INT})")
|
||||||
|
appendLine("ABI:${android.os.Build.SUPPORTED_ABIS.joinToString()}")
|
||||||
|
appendLine("导出时间:${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())}")
|
||||||
|
appendLine("记录条数:${entries.size}")
|
||||||
|
appendLine("----------------------------------------")
|
||||||
|
entries.forEach { appendLine(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appVersion(context: Context): String = runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?"
|
||||||
|
}.getOrDefault("?")
|
||||||
|
|
||||||
|
/** 写入 filesDir/connection-log.txt 并返回文件 */
|
||||||
|
fun exportToFile(context: Context): File {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
file.writeText(toText(context))
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出并弹出系统分享面板;没有可用分享目标时返回 null(调用方提示路径即可)。
|
||||||
|
*/
|
||||||
|
fun share(context: Context): File? {
|
||||||
|
val file = exportToFile(context)
|
||||||
|
val uri = runCatching {
|
||||||
|
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}.getOrElse {
|
||||||
|
android.util.Log.w(TAG, "无法生成 content:// URI(缺少 FileProvider 配置?)", it)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "OpenDance 连接日志")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
val chooser = Intent.createChooser(intent, "导出连接日志")
|
||||||
|
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
return runCatching {
|
||||||
|
context.startActivity(chooser)
|
||||||
|
file
|
||||||
|
}.getOrElse {
|
||||||
|
android.util.Log.w(TAG, "没有可用的分享目标,日志已保存到 ${file.absolutePath}", it)
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
T-AND-011:FileProvider 可分享的路径。
|
||||||
|
只暴露 filesDir 根目录(连接日志写在 filesDir/connection-log.txt),
|
||||||
|
不暴露缓存目录与外部存储。
|
||||||
|
-->
|
||||||
|
<paths>
|
||||||
|
<files-path name="opendance_files" path="." />
|
||||||
|
</paths>
|
||||||
@@ -71,6 +71,78 @@ class CreditsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun contractVersionIsPinned() {
|
fun contractVersionIsPinned() {
|
||||||
assertEquals("契约版本必须与 opendance-web v0.1.0 对齐", "0.1.0", Credits.CONTRACT_VERSION)
|
// 本端是 websocket-keypoints 的发送方,这项必须与契约快照一致
|
||||||
|
assertEquals(
|
||||||
|
"websocket-keypoints 契约版本必须与 opendance-web 冻结版本一致",
|
||||||
|
"0.2.0",
|
||||||
|
Credits.CONTRACT_VERSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyContractVersionMatchesTheSnapshot() {
|
||||||
|
// 契约版本是"单一来源":contracts/VERSION 决定一切,界面不得手写
|
||||||
|
val snapshot = readContractVersionFile()
|
||||||
|
assertTrue("能读到 contracts/VERSION", snapshot.isNotEmpty())
|
||||||
|
assertEquals(
|
||||||
|
"契约项数与 contracts/VERSION 一致(新增契约时忘记同步会导致这里失败)",
|
||||||
|
snapshot.size,
|
||||||
|
Credits.contractVersions.size,
|
||||||
|
)
|
||||||
|
for ((name, version) in Credits.contractVersions) {
|
||||||
|
assertEquals(
|
||||||
|
"契约 $name 的版本与快照一致",
|
||||||
|
snapshot[name] ?: "",
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appVersionMatchesProjectYaml() {
|
||||||
|
// 文档 §11.2:界面展示的版本必须与 project.yaml / 产物文件名同源
|
||||||
|
val yaml = readProjectYamlVersion()
|
||||||
|
assertTrue("能读到 project.yaml 的 version", yaml.isNotEmpty())
|
||||||
|
assertEquals("BuildConfig.VERSION_NAME 必须等于 project.yaml 的 version", yaml, Credits.appVersion)
|
||||||
|
assertEquals("Credits.appVersion 与 BuildConfig 同源(不得手写)", BuildConfig.VERSION_NAME, Credits.appVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contractSummaryListsAllContracts() {
|
||||||
|
val summary = Credits.contractSummary()
|
||||||
|
for ((name, version) in Credits.contractVersions) {
|
||||||
|
assertTrue("摘要含 $name", summary.contains(name))
|
||||||
|
assertTrue("摘要含 $name 的版本 v$version", summary.contains("v$version"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 文件读取
|
||||||
|
|
||||||
|
/** 从模块目录逐级向上找仓库根(含 contracts/VERSION 的那一级) */
|
||||||
|
private fun repoFile(relative: String): java.io.File? {
|
||||||
|
var dir: java.io.File = java.io.File(System.getProperty("user.dir")).absoluteFile
|
||||||
|
repeat(6) {
|
||||||
|
val candidate = java.io.File(dir, relative)
|
||||||
|
if (candidate.exists()) return candidate
|
||||||
|
dir = dir.parentFile ?: return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readContractVersionFile(): Map<String, String> {
|
||||||
|
val file = repoFile("contracts/VERSION") ?: return emptyMap()
|
||||||
|
return file.readLines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains(":") }
|
||||||
|
.associate { it.substringBefore(":").trim() to it.substringAfter(":").trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readProjectYamlVersion(): String {
|
||||||
|
val file = repoFile("project.yaml") ?: return ""
|
||||||
|
return file.readLines()
|
||||||
|
.firstOrNull { it.trimStart().startsWith("version:") }
|
||||||
|
?.substringAfter(":")
|
||||||
|
?.trim()
|
||||||
|
?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.opendance.studio
|
||||||
|
|
||||||
|
import com.opendance.studio.pose.PoseDiagnostics
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-AND-012:MediaPipe 初始化失败诊断
|
||||||
|
*
|
||||||
|
* `ExceptionInInitializerError: null` 本身没有信息量,真因在 cause 链里。
|
||||||
|
* 这里逐条核对"能不能把真因翻出来、能不能给出 ABI 维度的人话结论"。
|
||||||
|
*/
|
||||||
|
class PoseDiagnosticsTest {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ cause 链
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun causeChainUnwrapsInitializerError() {
|
||||||
|
val link = UnsatisfiedLinkError("dlopen failed: library \"libmediapipe_tasks_vision_jni.so\" not found")
|
||||||
|
val outer = ExceptionInInitializerError(link)
|
||||||
|
|
||||||
|
val chain = PoseDiagnostics.causeChain(outer)
|
||||||
|
assertTrue("链里含外层异常类名", chain.contains("ExceptionInInitializerError"))
|
||||||
|
assertTrue("链里含真因类名", chain.contains("UnsatisfiedLinkError"))
|
||||||
|
assertTrue("链里含缺失的库名", chain.contains("libmediapipe_tasks_vision_jni.so"))
|
||||||
|
|
||||||
|
assertSame("rootCause 应返回最内层真因", link, PoseDiagnostics.rootCause(outer))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun causeChainTerminatesOnCyclicCauses() {
|
||||||
|
// 手工 setCause 过的第三方库可能形成环(A -> B -> A);处理不当会死循环。
|
||||||
|
// 注意 JVM 禁止自引用(t.initCause(t) 直接抛 IllegalArgumentException),
|
||||||
|
// 因此这里构造的是两节点环。
|
||||||
|
val a = RuntimeException("A")
|
||||||
|
val b = RuntimeException("B")
|
||||||
|
a.initCause(b)
|
||||||
|
b.initCause(a)
|
||||||
|
val chain = PoseDiagnostics.causeChain(a)
|
||||||
|
assertTrue("环上的异常都出现在链里", chain.contains("A") && chain.contains("B"))
|
||||||
|
assertTrue("环不会导致死循环(有长度上限)", chain.length < 1000)
|
||||||
|
val root = PoseDiagnostics.rootCause(a)
|
||||||
|
assertTrue("rootCause 在环上收敛(不抛错)", root === a || root === b)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun causeChainHandlesNullMessage() {
|
||||||
|
// message 为 null 时仍要给出类名,否则用户只看到 "null"
|
||||||
|
val chain = PoseDiagnostics.causeChain(ExceptionInInitializerError(RuntimeException()))
|
||||||
|
assertTrue("消息为 null 时仍输出外层类名", chain.contains("ExceptionInInitializerError"))
|
||||||
|
assertTrue("消息为 null 时仍输出真因类名", chain.contains("RuntimeException"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nativeLoadFailureIsDetectedThroughTheChain() {
|
||||||
|
assertTrue(
|
||||||
|
"ExceptionInInitializerError(cause=UnsatisfiedLinkError) 属于 native 加载失败",
|
||||||
|
PoseDiagnostics.isNativeLoadFailure(
|
||||||
|
ExceptionInInitializerError(UnsatisfiedLinkError("dlopen failed")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue("直接的 UnsatisfiedLinkError 属于 native 加载失败", PoseDiagnostics.isNativeLoadFailure(UnsatisfiedLinkError("x")))
|
||||||
|
assertTrue("NoClassDefFoundError 也归入该类", PoseDiagnostics.isNativeLoadFailure(NoClassDefFoundError("x")))
|
||||||
|
assertFalse("普通业务异常不算 native 加载失败", PoseDiagnostics.isNativeLoadFailure(IllegalStateException("模型文件缺失")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ ABI
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun abisAreExtractedFromApkEntries() {
|
||||||
|
val entries = listOf(
|
||||||
|
"lib/arm64-v8a/libmediapipe_tasks_vision_jni.so",
|
||||||
|
"lib/arm64-v8a/libopencv_java4.so",
|
||||||
|
"lib/armeabi-v7a/libmediapipe_tasks_vision_jni.so",
|
||||||
|
"assets/pose_landmarker_lite.task",
|
||||||
|
"classes.dex",
|
||||||
|
"META-INF/MANIFEST.MF",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"只取 lib/<abi>/ 下的目录名并去重排序",
|
||||||
|
listOf("arm64-v8a", "armeabi-v7a"),
|
||||||
|
PoseDiagnostics.abisFromEntries(entries),
|
||||||
|
)
|
||||||
|
assertTrue("arm64-v8a 含 mediapipe native 库", PoseDiagnostics.hasMediaPipeNative(entries, "arm64-v8a"))
|
||||||
|
assertFalse("x86_64 没有 mediapipe native 库", PoseDiagnostics.hasMediaPipeNative(entries, "x86_64"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun abisAreEmptyWhenApkHasNoNativeLibs() {
|
||||||
|
assertEquals(
|
||||||
|
"没有 lib/ 条目时返回空列表",
|
||||||
|
emptyList<String>(),
|
||||||
|
PoseDiagnostics.abisFromEntries(listOf("classes.dex", "assets/x.task")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun releaseApkOnX86EmulatorIsDiagnosed() {
|
||||||
|
// 这是"debug 好用、release 崩"的根因:release 只打包真机 ABI
|
||||||
|
val hint = PoseDiagnostics.abiMismatchHint(
|
||||||
|
deviceAbis = listOf("x86_64", "x86"),
|
||||||
|
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||||
|
)
|
||||||
|
assertNotNull("x86_64 模拟器 + 仅 ARM 的 APK 必须给出提示", hint)
|
||||||
|
assertTrue("提示里要说清 APK 实际包含的 ABI", hint!!.contains("arm64-v8a"))
|
||||||
|
assertTrue("提示里要说清设备需要的 ABI", hint.contains("x86_64"))
|
||||||
|
assertTrue("提示里要给出可执行建议", hint.contains("真机") || hint.contains("debug"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun arm64DeviceWithArmApkIsNotFlagged() {
|
||||||
|
assertNull(
|
||||||
|
"真机 arm64 + ARM APK 正常,不应提示",
|
||||||
|
PoseDiagnostics.abiMismatchHint(
|
||||||
|
deviceAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||||
|
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun arm64DeviceCanStillUseApkThatAlsoShipsArm32() {
|
||||||
|
assertNull(
|
||||||
|
"设备支持多个 ABI 时只要有一个命中即可",
|
||||||
|
PoseDiagnostics.abiMismatchHint(
|
||||||
|
deviceAbis = listOf("arm64-v8a"),
|
||||||
|
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownAbisAreNotFlagged() {
|
||||||
|
assertNull(
|
||||||
|
"信息缺失时不应误报",
|
||||||
|
PoseDiagnostics.abiMismatchHint(emptyList(), listOf("arm64-v8a")),
|
||||||
|
)
|
||||||
|
assertNull(
|
||||||
|
"APK 无 native 库时不误报(由别的检查负责)",
|
||||||
|
PoseDiagnostics.abiMismatchHint(listOf("arm64-v8a"), emptyList()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 汇总报告
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun describeProducesActionableReport() {
|
||||||
|
val error = ExceptionInInitializerError(UnsatisfiedLinkError("dlopen failed: library \"libx.so\" not found"))
|
||||||
|
val report = PoseDiagnostics.describe(
|
||||||
|
t = error,
|
||||||
|
deviceAbis = listOf("x86_64"),
|
||||||
|
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||||
|
)
|
||||||
|
assertTrue("报告含原始异常", report.contains("ExceptionInInitializerError"))
|
||||||
|
assertTrue("报告含真因", report.contains("UnsatisfiedLinkError"))
|
||||||
|
assertTrue("报告含判定结论", report.contains("native 库加载"))
|
||||||
|
assertTrue("报告含设备 ABI", report.contains("x86_64"))
|
||||||
|
assertTrue("报告含 APK ABI", report.contains("arm64-v8a"))
|
||||||
|
assertTrue("报告给出可执行建议", report.contains("真机") || report.contains("debug"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun describeOnOrdinaryFailureDoesNotClaimNativeProblem() {
|
||||||
|
val report = PoseDiagnostics.describe(
|
||||||
|
t = IllegalStateException("模型文件 pose_landmarker_lite.task 缺失"),
|
||||||
|
deviceAbis = listOf("arm64-v8a"),
|
||||||
|
apkAbis = listOf("arm64-v8a"),
|
||||||
|
)
|
||||||
|
assertFalse("普通异常不应被判定为 native 失败", report.contains("native 库加载"))
|
||||||
|
assertTrue("仍然带上设备与 APK 的 ABI 供排查", report.contains("arm64-v8a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.opendance.studio
|
||||||
|
|
||||||
|
import com.opendance.studio.net.ReconnectPolicy
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-AND-011:重连策略(契约 websocket-keypoints.md v0.2.0 §7 / §7.1)
|
||||||
|
*
|
||||||
|
* 这是"频繁重连"缺陷的核心逻辑,必须逐值核对:
|
||||||
|
* 1. 退避序列 0.5s → 1 → 2 → 4 → 8 → 15s(封顶)+ ±20% 抖动;
|
||||||
|
* 2. 只有真正断开才重连(1000 / 1001 / 1002 与主动停止都不重连);
|
||||||
|
* 3. 心跳间隔必须严格小于静默超时的一半(契约 §1.1)。
|
||||||
|
*/
|
||||||
|
class ReconnectPolicyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backoffSequenceMatchesContract() {
|
||||||
|
assertEquals("第 1 次重连等待 500ms", 500L, ReconnectPolicy.baseDelayMs(1))
|
||||||
|
assertEquals("第 2 次重连等待 1s", 1_000L, ReconnectPolicy.baseDelayMs(2))
|
||||||
|
assertEquals("第 3 次重连等待 2s", 2_000L, ReconnectPolicy.baseDelayMs(3))
|
||||||
|
assertEquals("第 4 次重连等待 4s", 4_000L, ReconnectPolicy.baseDelayMs(4))
|
||||||
|
assertEquals("第 5 次重连等待 8s", 8_000L, ReconnectPolicy.baseDelayMs(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backoffIsCappedAt15Seconds() {
|
||||||
|
assertEquals("第 6 次起封顶 15s(500*2^5=16s 被截断)", 15_000L, ReconnectPolicy.baseDelayMs(6))
|
||||||
|
assertEquals("第 7 次仍封顶", 15_000L, ReconnectPolicy.baseDelayMs(7))
|
||||||
|
assertEquals("很大的次数也不溢出", 15_000L, ReconnectPolicy.baseDelayMs(64))
|
||||||
|
assertEquals("非法次数返回 0", 0L, ReconnectPolicy.baseDelayMs(0))
|
||||||
|
assertEquals("负数次数返回 0", 0L, ReconnectPolicy.baseDelayMs(-3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun jitterStaysWithinTwentyPercent() {
|
||||||
|
// randomFraction 0.0 -> 下界 -20%;1.0 -> 上界 +20%
|
||||||
|
assertEquals("抖动下界为 -20%", 400L, ReconnectPolicy.delayWithJitterMs(1, 0.0))
|
||||||
|
assertEquals("抖动中点为基数", 500L, ReconnectPolicy.delayWithJitterMs(1, 0.5))
|
||||||
|
assertEquals("抖动上界为 +20%", 600L, ReconnectPolicy.delayWithJitterMs(1, 1.0))
|
||||||
|
// 越界的随机数必须被夹紧,不能放大到任意倍
|
||||||
|
assertEquals("越界随机数夹紧到下界", 400L, ReconnectPolicy.delayWithJitterMs(1, -5.0))
|
||||||
|
assertEquals("越界随机数夹紧到上界", 600L, ReconnectPolicy.delayWithJitterMs(1, 9.0))
|
||||||
|
assertEquals("无重连计划时不抖动", 0L, ReconnectPolicy.delayWithJitterMs(0, 0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun jitterIsAlwaysPositiveAcrossTheWholeRange() {
|
||||||
|
for (attempt in 1..8) {
|
||||||
|
for (step in 0..100) {
|
||||||
|
val delay = ReconnectPolicy.delayWithJitterMs(attempt, step / 100.0)
|
||||||
|
assertTrue("等待时间必须为正(attempt=$attempt,step=$step)", delay > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun intentionalClosesDoNotReconnect() {
|
||||||
|
// 契约 §7:主动结束 / 1000 / 1001 / 1002 一律不重连
|
||||||
|
assertFalse("1000 正常关闭不重连", ReconnectPolicy.shouldReconnect(1000, manualClose = false))
|
||||||
|
assertFalse("1001 对端离开不重连", ReconnectPolicy.shouldReconnect(1001, manualClose = false))
|
||||||
|
assertFalse("1002 协议错误不重连", ReconnectPolicy.shouldReconnect(1002, manualClose = false))
|
||||||
|
assertFalse("主动停止不重连", ReconnectPolicy.shouldReconnect(1006, manualClose = true))
|
||||||
|
assertFalse("主动停止(1000)不重连", ReconnectPolicy.shouldReconnect(1000, manualClose = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun abnormalClosesDoReconnect() {
|
||||||
|
// 网络抖动 / 未收到关闭帧 / 对端崩溃 -> 重连
|
||||||
|
assertTrue("1006 异常断开要重连", ReconnectPolicy.shouldReconnect(1006, manualClose = false))
|
||||||
|
assertTrue("未收到关闭帧(0)要重连", ReconnectPolicy.shouldReconnect(0, manualClose = false))
|
||||||
|
assertTrue("1011 对端内部错误要重连", ReconnectPolicy.shouldReconnect(1011, manualClose = false))
|
||||||
|
assertTrue("1012 对端重启要重连", ReconnectPolicy.shouldReconnect(1012, manualClose = false))
|
||||||
|
assertTrue("1013 对端过载要重连", ReconnectPolicy.shouldReconnect(1013, manualClose = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun closeCodeLabelsAreHumanReadable() {
|
||||||
|
assertTrue(ReconnectPolicy.closeCodeLabel(1001).contains("对端离开"))
|
||||||
|
assertTrue(ReconnectPolicy.closeCodeLabel(1002).contains("协议错误"))
|
||||||
|
assertTrue(ReconnectPolicy.closeCodeLabel(0).contains("未收到关闭帧"))
|
||||||
|
assertTrue(ReconnectPolicy.closeCodeLabel(4321).contains("4321"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun heartbeatIsAlwaysFasterThanHalfTheSilentTimeout() {
|
||||||
|
// 契约 §1.1:heartbeatIntervalMs 必须小于 silentTimeoutMs 的一半
|
||||||
|
val declared = listOf(5_000L, 10_000L, 15_000L, 30_000L)
|
||||||
|
for (timeout in declared) {
|
||||||
|
val heartbeat = ReconnectPolicy.heartbeatIntervalMs(timeout)
|
||||||
|
assertTrue(
|
||||||
|
"超时 ${timeout}ms 时心跳 ${heartbeat}ms 必须安全",
|
||||||
|
ReconnectPolicy.isHeartbeatSafe(heartbeat, timeout),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun heartbeatFallsBackToConservativeDefaults() {
|
||||||
|
// 旧版服务端(契约 v0.1.0)不在 welcome 里声明超时 -> 用保守默认值
|
||||||
|
val heartbeat = ReconnectPolicy.heartbeatIntervalMs(0)
|
||||||
|
assertEquals(
|
||||||
|
"未声明超时时按默认 5s 的三分之一计算",
|
||||||
|
ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS / 3,
|
||||||
|
heartbeat,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"默认心跳对默认静默超时(5s)必须安全",
|
||||||
|
ReconnectPolicy.isHeartbeatSafe(heartbeat, ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun heartbeatIsClampedIntoReasonableRange() {
|
||||||
|
assertEquals("极小超时也不低于下限", ReconnectPolicy.MIN_HEARTBEAT_MS, ReconnectPolicy.heartbeatIntervalMs(10))
|
||||||
|
assertEquals("极大超时不超过上限", ReconnectPolicy.MAX_HEARTBEAT_MS, ReconnectPolicy.heartbeatIntervalMs(600_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun heartbeatSafetyRejectsUnsafeCombinations() {
|
||||||
|
assertFalse("未声明超时视为不安全", ReconnectPolicy.isHeartbeatSafe(1_000, 0))
|
||||||
|
assertFalse("心跳等于超时的一半不安全(必须严格小于)", ReconnectPolicy.isHeartbeatSafe(5_000, 10_000))
|
||||||
|
assertFalse("心跳大于超时的一半不安全", ReconnectPolicy.isHeartbeatSafe(6_000, 10_000))
|
||||||
|
assertTrue("心跳小于超时的一半才安全", ReconnectPolicy.isHeartbeatSafe(4_000, 10_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-11
@@ -2,12 +2,19 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# OpenDance Studio —— Android 辅助 App 发布打包
|
# OpenDance Studio —— Android 辅助 App 发布打包
|
||||||
#
|
#
|
||||||
# 产物(文档 §6.3):
|
# 产物(文档 §6.3 / §11.2):
|
||||||
# opendance-android-v0.1.0.zip
|
# opendance-android-vX.Y.Z.zip ← 单个完整包
|
||||||
# ├── app-release.apk
|
# SHA256SUMS ← 裸文件名,下载目录内 sha256sum -c 直接可用
|
||||||
# ├── THIRD_PARTY.md
|
#
|
||||||
# ├── README.txt
|
# 包内结构:
|
||||||
# └── contracts/ 契约快照
|
# opendance-android-vX.Y.Z/
|
||||||
|
# app-release.apk
|
||||||
|
# THIRD_PARTY.md
|
||||||
|
# README.txt README.md
|
||||||
|
# contracts/
|
||||||
|
#
|
||||||
|
# 版本号唯一来源是 app/build.gradle.kts 的 versionName;不传参数时自动读取,
|
||||||
|
# 并与 project.yaml 交叉校验(文档 §11.2:产物名 / tag / README / 界面必须一致)。
|
||||||
#
|
#
|
||||||
# 用法:bash build/package-release.sh [版本号]
|
# 用法:bash build/package-release.sh [版本号]
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -16,7 +23,14 @@ set -euo pipefail
|
|||||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
VERSION="${1:-0.1.0}"
|
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}"
|
||||||
|
VERSION="${VERSION:-0.2.0}"
|
||||||
|
[ -n "$YAML_VERSION" ] && [ "$VERSION" != "$YAML_VERSION" ] \
|
||||||
|
&& die "版本号不一致:参数/versionName=$VERSION,project.yaml=$YAML_VERSION"
|
||||||
|
[ -n "$GRADLE_VERSION" ] && [ "$VERSION" != "$GRADLE_VERSION" ] \
|
||||||
|
&& die "版本号不一致:参数=$VERSION,app/build.gradle.kts 的 versionName=$GRADLE_VERSION"
|
||||||
NAME="opendance-android-v${VERSION}"
|
NAME="opendance-android-v${VERSION}"
|
||||||
OUT_DIR="$PROJECT_DIR/dist-release"
|
OUT_DIR="$PROJECT_DIR/dist-release"
|
||||||
STAGE="$OUT_DIR/$NAME"
|
STAGE="$OUT_DIR/$NAME"
|
||||||
@@ -37,14 +51,42 @@ mkdir -p "$STAGE"
|
|||||||
|
|
||||||
cp "$APK" "$STAGE/app-release.apk"
|
cp "$APK" "$STAGE/app-release.apk"
|
||||||
cp THIRD_PARTY.md README.txt "$STAGE/"
|
cp THIRD_PARTY.md README.txt "$STAGE/"
|
||||||
|
if [ -f README.md ]; then cp README.md "$STAGE/"; fi
|
||||||
cp -r contracts "$STAGE/contracts"
|
cp -r contracts "$STAGE/contracts"
|
||||||
|
|
||||||
|
# ---- 完整性自检:APK 必须是合法 zip、含 MediaPipe 模型、ABI 只含真机 ABI ----
|
||||||
|
[ "$(head -c 2 "$APK" | od -An -tx1 | tr -d ' \n')" = "504b" ] || die "$APK 不是合法 APK"
|
||||||
|
unzip -l "$APK" | grep -q "assets/pose_landmarker_lite.task" || die "APK 缺少 MediaPipe 模型资源"
|
||||||
|
ABIS=$(unzip -l "$APK" | grep -oE "lib/[^/]+/" | sed 's#lib/##;s#/##' | sort -u | tr '\n' ' ')
|
||||||
|
log "APK 内 ABI:$ABIS"
|
||||||
|
for abi in arm64-v8a armeabi-v7a; do
|
||||||
|
echo "$ABIS" | grep -q "$abi" || die "APK 缺少 $abi 的 native 库"
|
||||||
|
done
|
||||||
|
if echo "$ABIS" | grep -qE "x86"; then
|
||||||
|
die "release APK 含模拟器 ABI($ABIS);请检查 abiFilters(T-AND-012)"
|
||||||
|
fi
|
||||||
|
unzip -l "$APK" | grep -q "lib/arm64-v8a/.*mediapipe" || die "arm64-v8a 下缺少 MediaPipe native 库"
|
||||||
|
|
||||||
|
ZIP="$OUT_DIR/$NAME.zip"
|
||||||
|
rm -f "$ZIP" "$OUT_DIR/SHA256SUMS"*
|
||||||
log "生成压缩包…"
|
log "生成压缩包…"
|
||||||
( cd "$OUT_DIR" && zip -qr "$NAME.zip" "$NAME" )
|
( cd "$OUT_DIR" && zip -qr "$NAME.zip" "$NAME" )
|
||||||
|
|
||||||
log "完成:$OUT_DIR/$NAME.zip($(du -h "$OUT_DIR/$NAME.zip" | cut -f1))"
|
# ---- SHA256SUMS(裸文件名,下载目录内可直接校验) ----
|
||||||
|
( cd "$OUT_DIR" && sha256sum "$NAME.zip" > SHA256SUMS )
|
||||||
|
log "完成:$ZIP($(du -h "$ZIP" | cut -f1))"
|
||||||
echo ""
|
echo ""
|
||||||
echo "产物内容:"
|
echo "产物:"
|
||||||
unzip -l "$OUT_DIR/$NAME.zip" | sed -n '4,20p'
|
echo " $ZIP"
|
||||||
|
echo " $OUT_DIR/SHA256SUMS"
|
||||||
|
if ( cd "$OUT_DIR" && sha256sum -c SHA256SUMS --status ); then
|
||||||
|
echo " sha256 校验:OK"
|
||||||
|
else
|
||||||
|
die "sha256 自校验失败"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "分发:把 APK 交给用户(Android 10+),或解压后 adb install -r app-release.apk"
|
unzip -l "$ZIP" | sed -n '4,20p'
|
||||||
|
echo ""
|
||||||
|
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
||||||
|
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip"
|
||||||
|
echo " 再 adb install -r $NAME/app-release.apk(Android 10+)"
|
||||||
|
|||||||
+46
-2
@@ -84,8 +84,52 @@ else
|
|||||||
FAILED=1
|
FAILED=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ABIS=$(unzip -l "$RELEASE_APK" | grep -oE "lib/[^/]+/" | sort -u | tr '\n' ' ')
|
# ---- T-AND-012:ABI 必须恰为真机 ABI,且每个 ABI 下都有 MediaPipe native 库 ----
|
||||||
echo "✓ 包含 ABI:$ABIS"
|
ABIS=$(unzip -l "$RELEASE_APK" | grep -oE "lib/[^/]+/" | sed 's#lib/##;s#/##' | sort -u)
|
||||||
|
ABIS_ONE_LINE=$(echo "$ABIS" | tr '\n' ' ')
|
||||||
|
echo " 包含 ABI:$ABIS_ONE_LINE"
|
||||||
|
EXPECTED_ABIS=$(printf 'arm64-v8a\narmeabi-v7a')
|
||||||
|
if [ "$ABIS" = "$EXPECTED_ABIS" ]; then
|
||||||
|
echo "✓ release ABI 恰为 arm64-v8a + armeabi-v7a(真机 ABI)"
|
||||||
|
else
|
||||||
|
echo "✗ release ABI 与预期不符(预期 arm64-v8a / armeabi-v7a)"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
if echo "$ABIS" | grep -qE "x86"; then
|
||||||
|
echo "✗ release APK 含模拟器 ABI,会导致包体翻倍且与 abiFilters 不符"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
for abi in arm64-v8a armeabi-v7a; do
|
||||||
|
if unzip -l "$RELEASE_APK" | grep -q "lib/$abi/.*mediapipe"; then
|
||||||
|
echo "✓ lib/$abi 下有 MediaPipe native 库"
|
||||||
|
else
|
||||||
|
echo "✗ lib/$abi 下缺少 MediaPipe native 库(真机安装后必然初始化失败)"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- T-AND-012:R8 不得裁掉 MediaPipe(seeds 由 -printseeds 输出) ----
|
||||||
|
SEEDS="app/build/outputs/mapping/release/seeds.txt"
|
||||||
|
if [ -f "$SEEDS" ]; then
|
||||||
|
MP_SEEDS=$(grep -c "^com.google.mediapipe" "$SEEDS" || true)
|
||||||
|
if [ "$MP_SEEDS" -gt 0 ]; then
|
||||||
|
echo "✓ R8 保留 MediaPipe 类 $MP_SEEDS 条(seeds.txt)"
|
||||||
|
else
|
||||||
|
echo "✗ R8 把 MediaPipe 整体裁掉了,release 包必然崩溃"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✗ 未生成 $SEEDS(proguard-rules.pro 的 -printseeds 是否被移除?)"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- T-AND-012:关键类必须真的在 dex 里 ----
|
||||||
|
if unzip -p "$RELEASE_APK" classes.dex 2>/dev/null | grep -qa "poselandmarker/PoseLandmarker"; then
|
||||||
|
echo "✓ dex 中存在 PoseLandmarker 类"
|
||||||
|
else
|
||||||
|
echo "✗ dex 中找不到 PoseLandmarker(R8/打包异常)"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# OpenDance Studio 契约版本
|
# OpenDance Studio 契约版本
|
||||||
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
|
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
|
||||||
rest-api: 0.5.0
|
rest-api: 0.5.0
|
||||||
websocket-keypoints: 0.1.0
|
websocket-keypoints: 0.2.0
|
||||||
data-models: 0.7.0
|
data-models: 0.7.0
|
||||||
skeleton-topology: 0.2.0
|
skeleton-topology: 0.2.0
|
||||||
model-formats: 0.5.0
|
model-formats: 0.5.0
|
||||||
|
|||||||
@@ -940,4 +940,96 @@ components:
|
|||||||
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
|
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
|
||||||
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
|
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
|
||||||
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
|
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
|
||||||
|
scenes:
|
||||||
|
type: array
|
||||||
|
description: 已发布场景(rest-api 0.5.0 起随目录下发;结构见 data-models.md 7)
|
||||||
|
items: { $ref: '#/components/schemas/Scene' }
|
||||||
defaultModelId: { type: integer, format: int64, nullable: true }
|
defaultModelId: { type: integer, format: int64, nullable: true }
|
||||||
|
|
||||||
|
Scene:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SourceFields'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer, format: int64 }
|
||||||
|
name: { type: string }
|
||||||
|
glbUrl: { type: string, nullable: true, description: 场景 GLB 下载地址 }
|
||||||
|
thumbnailUrl: { type: string, nullable: true }
|
||||||
|
fileSize: { type: integer, nullable: true }
|
||||||
|
fileSha256: { type: string, nullable: true }
|
||||||
|
conversionNote:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: 由 .zip/.pmx/.fbx 转换而来时的说明;直接上传 GLB 为 null
|
||||||
|
config:
|
||||||
|
allOf: [{ $ref: '#/components/schemas/SceneConfig' }]
|
||||||
|
nullable: true
|
||||||
|
description: 物理 / 环境 / 出生点 / 相机;服务端只存参数,仿真在客户端
|
||||||
|
stats:
|
||||||
|
type: object
|
||||||
|
nullable: true
|
||||||
|
description: 场景 GLB 分析(面数 / 网格 / 材质 / warnings)
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
updatedAt: { type: string, format: date-time }
|
||||||
|
|
||||||
|
SceneConfig:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
data-models.md 7.1 冻结结构。服务端入库时对每项做边界收敛
|
||||||
|
(越界夹紧、非法枚举回退默认值),客户端拿到的参数一定可解释。
|
||||||
|
properties:
|
||||||
|
physics:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
gravity:
|
||||||
|
type: object
|
||||||
|
description: 米/秒^2,各分量 ±200
|
||||||
|
properties:
|
||||||
|
x: { type: number }
|
||||||
|
y: { type: number }
|
||||||
|
z: { type: number }
|
||||||
|
wind:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
base: { $ref: '#/components/schemas/Vec3' }
|
||||||
|
gustAmplitude: { type: number, description: 0~100 }
|
||||||
|
gustFrequency: { type: number, description: Hz,0.01~5 }
|
||||||
|
gustDirection: { $ref: '#/components/schemas/Vec3' }
|
||||||
|
space: { type: string, enum: [world, local] }
|
||||||
|
ground:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
friction: { type: number, description: 0~1 }
|
||||||
|
restitution: { type: number, description: 0~1 }
|
||||||
|
slopeLimit: { type: number, description: 0~89 度 }
|
||||||
|
colliders: { type: string, enum: [auto, mesh, none] }
|
||||||
|
environment:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ambientColor: { type: string, description: '#RRGGBB' }
|
||||||
|
ambientIntensity: { type: number, description: 0~4 }
|
||||||
|
fogDensity: { type: number, description: 0~1,0=关闭 }
|
||||||
|
spawnPoints:
|
||||||
|
type: array
|
||||||
|
maxItems: 16
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
x: { type: number }
|
||||||
|
y: { type: number }
|
||||||
|
z: { type: number }
|
||||||
|
yaw: { type: number, description: 朝向(度,绕 Y 轴) }
|
||||||
|
camera:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
mode: { type: string, enum: [follow, fixed, orbit] }
|
||||||
|
distance: { type: number, description: 0.5~50 }
|
||||||
|
height: { type: number, description: 0~20 }
|
||||||
|
|
||||||
|
Vec3:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
x: { type: number }
|
||||||
|
y: { type: number }
|
||||||
|
z: { type: number }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# contracts/websocket-keypoints.md - 关键点传输契约 v0.1.0
|
# contracts/websocket-keypoints.md - 关键点传输契约 v0.2.0
|
||||||
|
|
||||||
Android 辅助 App(发送端)-> Windows 客户端(接收端)。
|
Android 辅助 App(发送端)-> Windows 客户端(接收端)。
|
||||||
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
|
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
|
||||||
@@ -14,6 +14,22 @@ ws://<windows-host>:8765/pose
|
|||||||
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
|
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
|
||||||
- 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。
|
- 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。
|
||||||
|
|
||||||
|
### 1.1 保活(v0.2.0 新增,向后兼容)
|
||||||
|
|
||||||
|
接收端有**静默超时**:超过 `silentTimeoutMs` 没有收到任何数据帧或文本帧即断开
|
||||||
|
(`close(1001, "timeout")`)。为此:
|
||||||
|
|
||||||
|
- 接收端必须在 `welcome`(以及需要变更时的 `config`)中声明
|
||||||
|
`silentTimeoutMs` 与期望的 `heartbeatIntervalMs`;
|
||||||
|
- 发送端按 `heartbeatIntervalMs` 周期发送 `ping`(见 §3.6),
|
||||||
|
且 **`heartbeatIntervalMs` 必须小于 `silentTimeoutMs` 的一半**;
|
||||||
|
- 发送端每帧关键点与每秒 `stats` 同样算作活动,`ping` 只是兜底,
|
||||||
|
保证「画面里没有人 / 相机暂停」时连接不会被误判掉线。
|
||||||
|
|
||||||
|
> 子协议名与二进制帧 `version` 均**不变**:本版本只**新增可选**控制字段与
|
||||||
|
> 一种新的控制类型,遵循 §5 的兼容规则;仅实现 v0.1.0 的一端仍可正常互通
|
||||||
|
> (它不发 `ping`、不读 `silentTimeoutMs`,但每帧与每秒 stats 已足够保活)。
|
||||||
|
|
||||||
## 2. 二进制帧:239 字节(冻结)
|
## 2. 二进制帧:239 字节(冻结)
|
||||||
|
|
||||||
**小端序(little-endian)**。
|
**小端序(little-endian)**。
|
||||||
@@ -76,26 +92,54 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
|||||||
{
|
{
|
||||||
"type": "hello",
|
"type": "hello",
|
||||||
"protocol": "opendance.pose.v1",
|
"protocol": "opendance.pose.v1",
|
||||||
|
"deviceId": "a1b2c3d4e5f60718",
|
||||||
"device": "Pixel 8",
|
"device": "Pixel 8",
|
||||||
"appVersion": "0.1.0",
|
"appVersion": "0.2.0",
|
||||||
"cameraFacing": "front",
|
"cameraFacing": "front",
|
||||||
"targetFps": 30,
|
"targetFps": 30,
|
||||||
"resolution": "640x480"
|
"resolution": "640x480"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `deviceId`(v0.2.0 新增,**可选但强烈建议**):该安装实例的稳定标识
|
||||||
|
(建议 16 位十六进制,取自 `ANDROID_ID` 的哈希,不使用任何硬件序列号)。
|
||||||
|
接收端**必须按 `deviceId` 去重**:同一 `deviceId` 的新连接替换旧连接,
|
||||||
|
设备数按去重后计数(否则一次「反复重连」会被显示成多台设备)。
|
||||||
|
未提供 `deviceId` 时接收端退回按连接计数。
|
||||||
|
|
||||||
### 3.2 `welcome` - Windows -> Android
|
### 3.2 `welcome` - Windows -> Android
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 }
|
{
|
||||||
|
"type": "welcome",
|
||||||
|
"protocol": "opendance.pose.v1",
|
||||||
|
"sessionId": "uuid",
|
||||||
|
"expectedFps": 30,
|
||||||
|
"silentTimeoutMs": 15000,
|
||||||
|
"heartbeatIntervalMs": 5000
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `silentTimeoutMs` / `heartbeatIntervalMs`(v0.2.0 新增):见 §1.1。
|
||||||
|
发送端若未读到这两个字段,应使用保守默认值(超时 5000、心跳 1000)。
|
||||||
|
|
||||||
### 3.3 `config` - Windows -> Android,运行中调整
|
### 3.3 `config` - Windows -> Android,运行中调整
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" }
|
{
|
||||||
|
"type": "config",
|
||||||
|
"mirror": true,
|
||||||
|
"targetFps": 30,
|
||||||
|
"upperBodyOnly": false,
|
||||||
|
"resolution": "640x480",
|
||||||
|
"silentTimeoutMs": 15000,
|
||||||
|
"heartbeatIntervalMs": 5000
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `silentTimeoutMs` / `heartbeatIntervalMs` 为 v0.2.0 新增的可选字段,
|
||||||
|
接收端在运行中调整保活参数时一并下发。
|
||||||
|
|
||||||
### 3.4 `stats` - Android -> Windows,每秒一次
|
### 3.4 `stats` - Android -> Windows,每秒一次
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -108,6 +152,31 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
|||||||
{ "type": "bye", "reason": "user_stop" }
|
{ "type": "bye", "reason": "user_stop" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`bye` 表示**主动**结束:发出方在发送后立即关闭连接,接收方据此关闭且
|
||||||
|
**不得**自动重连(见 §6)。
|
||||||
|
|
||||||
|
### 3.6 `ping` / `pong` - 保活(v0.2.0 新增)
|
||||||
|
|
||||||
|
Android -> Windows:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ping", "sentAtMs": 1730000000000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows -> Android(可选回复,用于 RTT):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "pong", "sentAtMs": 1730000000000, "serverAtMs": 1730000000042 }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `sentAtMs` 为发送方的本地毫秒时间戳,原样回传;发送端用
|
||||||
|
`now - sentAtMs` 估算 RTT。
|
||||||
|
- 接收端收到任何一帧(二进制或文本)都算活动,`ping` 无需额外应答即可保活;
|
||||||
|
`pong` 只用于诊断。
|
||||||
|
- **不要**依赖 WebSocket 协议层的 ping 帧(`Sec-WebSocket-Ping`):它由底层
|
||||||
|
库自动处理,**不会**暴露给应用层,因此不能被接收端的静默超时逻辑看到。
|
||||||
|
这也是必须定义应用层 `ping` 的原因。
|
||||||
|
|
||||||
## 4. JSON 调试帧(可选,仅开发期)
|
## 4. JSON 调试帧(可选,仅开发期)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -123,9 +192,68 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
|||||||
|
|
||||||
生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。
|
生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。
|
||||||
|
|
||||||
## 5. 兼容性规则
|
## 5. 设备去重(v0.2.0 新增)
|
||||||
|
|
||||||
|
接收端按 §3.1 的 `deviceId` 去重:
|
||||||
|
|
||||||
|
| 情况 | 行为 |
|
||||||
|
|--|--|
|
||||||
|
| 新连接的 `deviceId` 已存在 | 关闭旧连接(`close(1000, "replaced by new connection")`),保留新连接;设备数不变 |
|
||||||
|
| `deviceId` 缺失或为空 | 不做去重,按连接计数 |
|
||||||
|
| 同一 `deviceId` 的旧连接已断开 | 正常替换,不影响设备数 |
|
||||||
|
|
||||||
|
这样「手机闪断后立刻重连」不会在接收端显示成两台设备,也不会让
|
||||||
|
「已连接 N 台」抖动。
|
||||||
|
|
||||||
|
## 6. 兼容性规则
|
||||||
|
|
||||||
- `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。
|
- `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。
|
||||||
- `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。
|
- `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。
|
||||||
- 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。
|
- 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。
|
||||||
- 服务端 `motions.keypoint_format` 字段取值 `mediapipe33@0.1.0` 对应本契约。
|
- 服务端 `motions.keypoint_format` 字段取值 `mediapipe33@0.1.0` 对应本契约。
|
||||||
|
|
||||||
|
## 7. 关闭码与重连策略(v0.2.0 新增,两端必须一致)
|
||||||
|
|
||||||
|
| 关闭码 / 情形 | 含义 | 发送端是否自动重连 |
|
||||||
|
|--|--|--|
|
||||||
|
| `1000` 正常关闭 | 任一方主动结束(含收到 `bye`) | **否** |
|
||||||
|
| `1001` going away | 接收端主动关闭(服务重启、`stop()`、静默超时踢人) | **否**(见下方说明) |
|
||||||
|
| `1002` 协议错误 | 子协议 / 协议版本不匹配 | **否**(重连也不会成功) |
|
||||||
|
| 连接失败 / 超时 / `1006` 异常断开 | 网络抖动、对端进程退出、Wi-Fi 切换 | **是** |
|
||||||
|
| 客户端主动 `disconnect()` | 用户点「结束推流」 | **否** |
|
||||||
|
|
||||||
|
- 静默超时由接收端发 `close(1001, "timeout")`;正常情况下发送端按 §1.1 保活,
|
||||||
|
**不应**触发它。若仍频繁触发,属于保活参数或实现缺陷,需按 §7 排查,
|
||||||
|
而不是靠重连掩盖。
|
||||||
|
- 接收端因「同一 `deviceId` 被新连接替换」关闭旧连接时用 `1000`,
|
||||||
|
旧连接不重连。
|
||||||
|
|
||||||
|
### 7.1 退避参数(冻结)
|
||||||
|
|
||||||
|
```
|
||||||
|
第 n 次重连等待 = min(500ms * 2^(n-1), 15000ms) * (1 ± 20% 抖动)
|
||||||
|
即 0.5s → 1s → 2s → 4s → 8s → 15s(封顶),每次叠加 ±20% 抖动
|
||||||
|
连接成功后计数清零
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 连接状态机(v0.2.0 新增,两端同名,便于对照日志)
|
||||||
|
|
||||||
|
```
|
||||||
|
idle → resolving → connecting → handshaking → streaming → degraded
|
||||||
|
↑ │
|
||||||
|
└──────── reconnecting ←───────────┘
|
||||||
|
stopped ← 主动停止 / 1000 / 1001 / 1002(终态,不再重连)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 状态 | 含义 | 进入条件(发送端) |
|
||||||
|
|--|--|--|
|
||||||
|
| `idle` | 尚未开始连接 | 初始状态 |
|
||||||
|
| `resolving` | 解析主机地址(DNS / 局域网发现) | `connect()` 且主机名非 IP 字面量 |
|
||||||
|
| `connecting` | TCP + WebSocket 升级中 | 已发起请求 |
|
||||||
|
| `handshaking` | 已连接,等待 `welcome` | `onOpen`,已发送 `hello` |
|
||||||
|
| `streaming` | 正常推流 | 收到 `welcome` 或已成功发送首帧 |
|
||||||
|
| `degraded` | 仍连接但异常(丢弃帧增长 / 发送失败) | `send` 失败或持续丢帧 |
|
||||||
|
| `reconnecting` | 退避等待中 | 非主动断开 |
|
||||||
|
| `stopped` | 已停止(终态) | 主动停止 / 1000 / 1001 / 1002 |
|
||||||
|
|
||||||
|
接收端(Windows)在日志与状态面板中使用同名状态描述每个客户端连接。
|
||||||
|
|||||||
+24
-3
@@ -1,9 +1,15 @@
|
|||||||
project: opendance-android-app
|
project: opendance-android-app
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
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)
|
||||||
contracts_version: 0.1.0
|
contracts_version:
|
||||||
|
rest-api: 0.5.0
|
||||||
|
websocket-keypoints: 0.2.0
|
||||||
|
data-models: 0.7.0
|
||||||
|
skeleton-topology: 0.2.0
|
||||||
|
model-formats: 0.5.0
|
||||||
|
database: 0.4.0
|
||||||
|
|
||||||
# 契约变更流程(文档 7.2):
|
# 契约变更流程(文档 7.2):
|
||||||
# 任何跨端契约变更必须先在 opendance-web 修改 contracts/ 并提升版本,
|
# 任何跨端契约变更必须先在 opendance-web 修改 contracts/ 并提升版本,
|
||||||
@@ -56,12 +62,27 @@ tasks:
|
|||||||
file: tasks/009-build-apk.md
|
file: tasks/009-build-apk.md
|
||||||
scope: Android Studio 打包
|
scope: Android Studio 打包
|
||||||
status: done
|
status: done
|
||||||
|
- id: T-AND-010
|
||||||
|
file: tasks/010-about-version.md
|
||||||
|
scope: 「关于」清单展示当前版本号 + 契约版本单一来源
|
||||||
|
status: done
|
||||||
|
- id: T-AND-011
|
||||||
|
file: tasks/011-reconnect.md
|
||||||
|
scope: 连接状态机 / 保活 / 去重与 Windows 端对齐
|
||||||
|
status: done
|
||||||
|
- id: T-AND-012
|
||||||
|
file: tasks/012-mediapipe-crash.md
|
||||||
|
scope: MediaPipe 初始化崩溃修复 + 诊断
|
||||||
|
status: done
|
||||||
|
|
||||||
deliverable:
|
deliverable:
|
||||||
artifact: opendance-android-v0.1.0.apk
|
artifact: opendance-android-v0.2.0.apk
|
||||||
packaging: Gradle 命令行 / Android Studio
|
packaging: Gradle 命令行 / Android Studio
|
||||||
includes:
|
includes:
|
||||||
- app-release.apk
|
- app-release.apk
|
||||||
- THIRD_PARTY.md
|
- THIRD_PARTY.md
|
||||||
- README.txt
|
- README.txt
|
||||||
- contracts/ # 契约快照
|
- contracts/ # 契约快照
|
||||||
|
release_rules:
|
||||||
|
- release 包只含 arm64-v8a / armeabi-v7a(真机 ABI)
|
||||||
|
- 版本号单一来源:versionName(= project.yaml 的 version)与 BuildConfig 契约版本
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# T-AND-010 「关于」清单展示当前版本号
|
||||||
|
|
||||||
|
- **状态**: done
|
||||||
|
- **依赖**: T-AND-008
|
||||||
|
- **文档**: v1.6 §11(版本号展示)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
「关于」清单展示当前运行版本,且版本号只有一处权威定义。
|
||||||
|
|
||||||
|
## 方案(单一来源,禁止手写)
|
||||||
|
|
||||||
|
| 项 | 来源 |
|
||||||
|
|--|--|
|
||||||
|
| 应用版本 | `app/build.gradle.kts` 的 `versionName`(= `project.yaml` 的 `version`)→ `BuildConfig.VERSION_NAME` |
|
||||||
|
| 契约版本 | 构建期解析 `contracts/VERSION`,逐项注入 `BuildConfig.CONTRACT_<KEY>` 与 `CONTRACT_VERSION_KEYS/VALUES` |
|
||||||
|
|
||||||
|
由此带来两点硬约束:
|
||||||
|
|
||||||
|
1. `versionCode` 按文档 §11.1 的规则递增:`10000*主 + 100*次 + 修订`
|
||||||
|
(本次 `0.2.0` → `200`);
|
||||||
|
2. `Credits` 原先的单个 `CONTRACT_VERSION = "0.1.0"` 常量被删除 ——
|
||||||
|
六项契约已各自独立递增(`rest-api 0.5.0` / `websocket-keypoints 0.2.0` /
|
||||||
|
`data-models 0.7.0` …),单个常量必然失真。
|
||||||
|
`Credits.contractVersions` 直接读 `BuildConfig`,界面不出现任何版本号字面量。
|
||||||
|
|
||||||
|
## 构建期强制校验
|
||||||
|
|
||||||
|
`app/build.gradle.kts` 新增 `checkReleaseConsistency` 任务,并挂到每个变体的
|
||||||
|
`preBuild` 上(任何构建都会先跑):
|
||||||
|
|
||||||
|
- `versionName` 必须等于 `project.yaml` 的 `version`;
|
||||||
|
- `contracts/VERSION` 的 `websocket-keypoints` 必须等于 `0.2.0`(本端是发送方);
|
||||||
|
- `versionCode` 必须与 `versionName` 按上述公式自洽。
|
||||||
|
|
||||||
|
任何一项不一致直接 **BUILD FAILED**,不让版本漂移进入产物。
|
||||||
|
|
||||||
|
## 验收(文档 §11.3)
|
||||||
|
|
||||||
|
| 验收项 | 结果 |
|
||||||
|
|--|--|
|
||||||
|
| 关于清单可见 `vX.Y.Z` | ✅ 清单首行「客户端版本:v0.2.0」,另有「版本号(整数):200」 |
|
||||||
|
| 与 `versionName` / APK 文件名一致 | ✅ `opendance-android-v0.2.0.apk`;`checkReleaseConsistency` 强制 |
|
||||||
|
| 六项契约版本逐项展示 | ✅ 清单逐行列「契约 rest-api: v0.5.0」等 |
|
||||||
|
| 界面代码无版本号字面量 | ✅ `CreditsTest.appVersionMatchesProjectYaml` / `everyContractVersionMatchesTheSnapshot` 核对 |
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# T-AND-011 连接状态与重连修复
|
||||||
|
|
||||||
|
- **状态**: done
|
||||||
|
- **依赖**: T-AND-005、T-AND-006
|
||||||
|
- **文档**: v1.6 §6.5;契约 `websocket-keypoints` **0.2.0**(本次提升)
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
「连接状态异常、频繁重连」。逐条核对原实现后,找到 **3 个真实根因**,
|
||||||
|
都不是"退避参数没调好"这种表象问题:
|
||||||
|
|
||||||
|
### 根因 1:保活用错了层(最要命)
|
||||||
|
|
||||||
|
- 服务端(Windows `pose_server.gd`)有 `CLIENT_TIMEOUT_MS = 5000` 的**静默超时**:
|
||||||
|
5 秒内没有收到**应用层**的数据帧或文本帧就 `close(1001, "timeout")`;
|
||||||
|
- App 端只有 OkHttp 的 `pingInterval = 15s`。**WebSocket 协议层的 ping 由底层库
|
||||||
|
自动应答,不会暴露给接收端的应用层** —— 也就是说它根本不能续命;
|
||||||
|
- 结果:只要画面里没有人(或相机暂停)而 `stats` 又没发出去,5 秒后必然被踢,
|
||||||
|
然后重连,循环往复 —— 这就是"频繁重连"。
|
||||||
|
|
||||||
|
**修复(两端一起改)**:
|
||||||
|
|
||||||
|
- 契约新增 §1.1:接收端必须在 `welcome`/`config` 中声明 `silentTimeoutMs` 与
|
||||||
|
`heartbeatIntervalMs`,且 **`heartbeatIntervalMs < silentTimeoutMs / 2`**;
|
||||||
|
- 新增 §3.6 应用层 `ping` / `pong`(协议层 ping 不可见,必须自己定义);
|
||||||
|
- Windows 端超时从 5s 提到 **15s**、期望心跳 **5s**,并在 `welcome`/`config` 中下发;
|
||||||
|
- App 端按声明值周期发 `ping`,连续 3 个周期没收到 `pong` 才判定链路已死
|
||||||
|
并主动重连(而不是干等)。
|
||||||
|
|
||||||
|
### 根因 2:不该重连的时候也重连
|
||||||
|
|
||||||
|
原 `scheduleReconnect()` 只看 `manualClose`:服务端因重启发的 `1001`、
|
||||||
|
协议不匹配的 `1002`、乃至收到 `bye` 之后的正常 `1000`,都会触发重连。
|
||||||
|
|
||||||
|
**修复**:契约新增 §7 关闭码策略,App 用 `ReconnectPolicy.shouldReconnect()` 判定 ——
|
||||||
|
`1000` / `1001` / `1002` 与主动停止一律**不**重连。
|
||||||
|
|
||||||
|
### 根因 3:frame_id 恒为 0
|
||||||
|
|
||||||
|
`PoseDetector.detect()` 计算出 `safeTimestamp` 后**忘了写回 `lastTimestampMs`**,
|
||||||
|
于是 `handleResult()` 里 `frameId = lastTimestampMs and 0x7FFFFFFF` 永远是 0 ——
|
||||||
|
接收端据此做的丢包检测完全失效,`frames_dropped` 恒为 0。顺带修掉。
|
||||||
|
|
||||||
|
## 其他对齐项
|
||||||
|
|
||||||
|
| 项 | 做法 |
|
||||||
|
|--|--|
|
||||||
|
| 状态机同名 | 契约新增 §8:`idle → resolving → connecting → handshaking → streaming → degraded → reconnecting → stopped`;App 的 `State` 枚举带 `wireName`,Windows 端 `PoseServer.ClientState` 同名,日志可直接对照 |
|
||||||
|
| resolving | 主机名不是 IP 字面量时先解析(`InetAddress`),局域网直连 IP 直接进 connecting |
|
||||||
|
| 退避与抖动 | 契约新增 §7.1 冻结:0.5s→1→2→4→8→15s 封顶 + **±20% 抖动**(避免同网段多机同时重连打尖峰) |
|
||||||
|
| 设备去重 | 契约新增 §5:hello 携带稳定 `deviceId`(`ANDROID_ID` 的 SHA-256 前 16 位,**不含硬件序列号**);Windows 端按它去重,旧连接以 `1000` 关闭(旧连接不重连),设备数按去重后计数 |
|
||||||
|
| 可诊断 | 新增 `ConnectionLog`(进程内环形缓冲,最近 500 条):状态迁移、close code/reason、子协议协商结果、首帧时间、保活参数;运行页「导出连接日志」经 FileProvider 分享 |
|
||||||
|
| 界面 | 运行页显示状态机名、协商子协议、最近断开(close code + 中文原因)、保活参数、心跳 RTT、重试倒计时 |
|
||||||
|
|
||||||
|
## 契约变更(`websocket-keypoints` 0.1.0 → 0.2.0)
|
||||||
|
|
||||||
|
按文档 §7.2,先在 `opendance-web/contracts/websocket-keypoints.md` 修改并提升版本,
|
||||||
|
再同步本仓库快照(三端逐字节一致)。
|
||||||
|
|
||||||
|
**向后兼容**:子协议名与二进制帧 `version` 均不变,只**新增可选**控制字段与
|
||||||
|
一种新控制类型;仅实现 v0.1.0 的一端仍可互通(它不发 `ping`、不读
|
||||||
|
`silentTimeoutMs`,但每帧关键点 + 每秒 `stats` 已足够保活)。
|
||||||
|
|
||||||
|
## 验收(文档 §6.5)
|
||||||
|
|
||||||
|
| 验收项 | 结果 |
|
||||||
|
|--|--|
|
||||||
|
| 连续推流 30 分钟不出现非预期重连 | ⚠️ 需真机 30 分钟实测(本环境无设备);根因 1 已消除,且保活节奏有单测锁定 |
|
||||||
|
| 首次连接与断网恢复各有明确日志与状态 | ✅ 状态机 + `ConnectionLog`(可导出);`resolving/connecting/handshaking/streaming` 各有记录 |
|
||||||
|
| 服务端主动关闭 / 客户端主动停止**不**触发重连 | ✅ 契约 §7 策略 + `ReconnectPolicyTest` 逐码断言 |
|
||||||
|
| Windows 端设备数与手机端一致(重复连接去重) | ✅ 两端都按 `deviceId` 去重;Windows 端 `test_duplicate_device_id_is_deduplicated` 断言旧连接被 `1000` 关闭且设备数仍为 1 |
|
||||||
|
| 两端协议(子协议名、帧格式、心跳)与契约一致 | ✅ 契约 0.2.0 已冻结;两端快照 md5 相同 |
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- `ReconnectPolicyTest`(11 项):退避序列逐值、抖动上下界、契约 §7 关闭码策略、
|
||||||
|
心跳安全性(严格小于静默超时的一半)、越界与非法输入。
|
||||||
|
- Windows 端 `tests/test_pose_server.gd` 新增 7 项:welcome 声明保活参数、
|
||||||
|
ping→pong 回显、同 deviceId 去重、状态名与契约一致、
|
||||||
|
状态随握手/首帧推进到 streaming、丢帧进入 degraded 后恢复、
|
||||||
|
snapshot 暴露设备数与保活参数。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# T-AND-012 MediaPipe 初始化崩溃修复
|
||||||
|
|
||||||
|
- **状态**: done
|
||||||
|
- **依赖**: T-AND-003
|
||||||
|
- **文档**: v1.6 §6.6
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
真机上报 `mediapipe 初始化失败:ExceptionInInitializerError: null`。
|
||||||
|
|
||||||
|
`ExceptionInInitializerError` 的 message 为 `null` 时**没有任何信息量** ——
|
||||||
|
它的真因一定在 `cause` 链里,通常是 `UnsatisfiedLinkError`(native 库加载失败)。
|
||||||
|
|
||||||
|
## 诊断结论
|
||||||
|
|
||||||
|
按文档 §6.6 的五步逐条核对,本项目实际命中 **三处**:
|
||||||
|
|
||||||
|
| # | 项 | 结论 |
|
||||||
|
|--|--|--|
|
||||||
|
| 1 | 拿到真因 | ❌ 原实现只把 `t.message` 拼进提示,`null` 就此丢失。**已修**:`PoseDiagnostics.causeChain()` 展开完整因果链,`rootCause()` 取最内层,并把"缺少的具体库"一并显示 |
|
||||||
|
| 2 | ABI 核对 | ⚠️ release 只打包 `arm64-v8a` / `armeabi-v7a`(正确),但**没有任何机制阻止把 release 装到 x86_64 模拟器**上 —— 那必然缺 native 库。**已加**:`PoseDiagnostics.abiMismatchHint()` 会比较 `Build.SUPPORTED_ABIS` 与 APK 内 `lib/<abi>/` 实际目录,直接给出"APK 只包含 X,而模拟器(x86_64)需要 Y"的结论;构建期另有 `verifyReleaseAbi` 任务核对 |
|
||||||
|
| 3 | native 解压方式 | ❌ 未设置。AGP 8 的 release 默认 `extractNativeLibs=false`,MediaPipe 的 JNI 加载对它有历史兼容问题。**已修**:`packaging { jniLibs { useLegacyPackaging = true } }` |
|
||||||
|
| 4 | R8 细化 | ❌ 只有 `-keep class com.google.mediapipe.**`,缺少 **native 方法**的保留规则(R8 仍可能裁剪方法名导致 JNI 注册失败)。**已修**:补 `-keepclasseswithmembernames class com.google.mediapipe.** { native <methods>; }`,并用 `-printseeds` 输出 seeds 供发布前核对(实测保留 **20169** 条 MediaPipe 条目) |
|
||||||
|
| 5 | 版本与 minSdk | ✅ `minSdk = 29 ≥ 24`,`tasks-vision 0.10.35` 兼容 |
|
||||||
|
|
||||||
|
**未换库**:诊断指向的是集成配置(native 解压 + R8 + ABI),不是 MediaPipe 本身不稳定,
|
||||||
|
因此保留 MediaPipe(33 点契约零成本),备选方案(ML Kit Pose Detection 等)
|
||||||
|
仅在后续实测仍不稳定时启用。文档 §6.6 的备选 A/B/C/D 已在任务说明中记录取舍。
|
||||||
|
|
||||||
|
## 改动清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|--|--|
|
||||||
|
| `app/build.gradle.kts` | `jniLibs.useLegacyPackaging = true`;新增 `verifyReleaseAbi` 任务(ABI 集合必须恰为 `[arm64-v8a, armeabi-v7a]`,每个 ABI 下必须有 MediaPipe native 库,模型资源必须存在) |
|
||||||
|
| `app/proguard-rules.pro` | 补 native 方法保留规则;输出 `seeds.txt` / `usage.txt` 供发布前核对 |
|
||||||
|
| `pose/PoseDiagnostics.kt` | **新增**:纯函数诊断(cause 链 / 真因 / native 判定 / ABI 比对 / 汇总报告),可在 JVM 单测逐条核对 |
|
||||||
|
| `pose/PoseDetector.kt` | 读自身 APK 的 `lib/` 目录得到实际 ABI;失败时输出完整诊断并写入 `ConnectionLog`;顺手修复 `lastTimestampMs` 未回写导致 `frame_id` 恒为 0 的缺陷 |
|
||||||
|
| `util/ConnectionLog.kt` | **新增**:连接与初始化诊断日志(可导出) |
|
||||||
|
| `AndroidManifest.xml` + `res/xml/file_paths.xml` | FileProvider(导出日志用) |
|
||||||
|
|
||||||
|
## 验收(文档 §6.6)
|
||||||
|
|
||||||
|
| 验收项 | 结果 |
|
||||||
|
|--|--|
|
||||||
|
| release 包在真机(arm64)上初始化成功率 100%,连续 30 分钟无崩溃 | ⚠️ 需真机实测(本环境无设备)。构建期已强制 ABI 与 native 库齐备,R8 保留已用 seeds 核对 |
|
||||||
|
| 崩溃时 `CrashLogger` 能给出 `Caused by` 真因(含 ABI / 库名) | ✅ `PoseDiagnostics` 输出「真因(cause 链最内层)」「缺少的具体库」「设备 ABI / APK 内 ABI」与判定结论;`PoseDiagnosticsTest` 12 项逐条断言 |
|
||||||
|
| 若换库:仍输出 MediaPipe 33 点顺序 | ✅ 未换库,33 点契约不变(`KeypointFrameTest` 金标准向量仍通过) |
|
||||||
|
| 相机权限被拒 / 模型缺失 / 无 Play 服务时给出可读提示,不白屏不崩溃 | ✅ 权限分支已有独立界面与 Robolectric 用例;初始化失败 `catch Throwable` 并显示诊断;`ScreenCompositionTest` 覆盖组合稳定性 |
|
||||||
|
|
||||||
|
## 发布前必做(三条命令)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew verifyReleaseAbi
|
||||||
|
# -> [abi] release APK 包含 ABI:[arm64-v8a, armeabi-v7a]
|
||||||
|
# -> [abi] arm64-v8a / armeabi-v7a 下 MediaPipe native 库存在
|
||||||
|
|
||||||
|
grep -c "^com.google.mediapipe" app/build/outputs/mapping/release/seeds.txt
|
||||||
|
# -> 必须 > 0(实测 20169);为 0 说明 MediaPipe 被整体裁掉,release 必崩
|
||||||
|
|
||||||
|
unzip -p app/build/outputs/apk/release/app-release.apk classes.dex | grep -c poselandmarker/PoseLandmarker
|
||||||
|
# -> 必须 > 0,确认关键类仍在 dex 中
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user