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
|
||||
├── project.yaml / DSH.md
|
||||
├── tasks/ # 001~009 单任务说明书
|
||||
├── contracts/ # 只读契约快照(来源 opendance-web v0.1.0)
|
||||
├── contracts/ # 只读契约快照(来源 opendance-web;六项各自独立版本)
|
||||
├── app/
|
||||
│ ├── build.gradle.kts # 依赖、签名、R8、ABI
|
||||
│ ├── 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 实现**逐位一致** |
|
||||
| `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) |
|
||||
| 签名校验 | 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 未压缩) |
|
||||
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
|
||||
|
||||
@@ -172,9 +172,11 @@ IP 地址:192.0.2.10 # RFC 5737 文档示例网段,实际填你电脑
|
||||
|
||||
| 位置 | 可观察现象 |
|
||||
|--|--|
|
||||
| App 运行页 | 「连接状态」为**已连接**(或短暂「连接中」后转「已连接」) |
|
||||
| App 运行页 | 「目标」显示 `ws://<IP>:<端口>/pose`;服务端返回 welcome 后出现「会话」ID |
|
||||
| App 运行页 | 「连接状态」为**推流中**(`streaming`;短暂经过 解析地址 → 连接中 → 握手中) |
|
||||
| App 运行页 | 「目标」显示 `ws://<IP>:<端口>/pose`;服务端返回 welcome 后出现「会话」ID 与「协商子协议」 |
|
||||
| App 运行页 | 「已发送」帧数持续增长;「发送帧率」「识别帧率」大于 0;「推理耗时」为毫秒数 |
|
||||
| App 运行页 | 「保活」显示心跳与静默超时(由服务端在 welcome 中声明),心跳必须小于静默超时的一半 |
|
||||
| App 运行页 | 断开时「最近断开」显示 close code 与原因(如 `1001 对端离开`),而不是只有"未连接" |
|
||||
| App 运行页 | 「人体识别」为**已识别**,叠加层出现骨架(黄点 = 置信度好,红点 = 置信度低) |
|
||||
| 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)` |
|
||||
| 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 未压缩) |
|
||||
| 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` 必须通过:
|
||||
它断言本端 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 隔离 |
|
||||
| 「扫描局域网」扫不到主机 | 手机未连 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/ 连接页 / 运行页 / 关于页
|
||||
```
|
||||
|
||||
## 依赖的契约(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/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 个骨骼关键点通过
|
||||
@@ -288,9 +288,16 @@ Android Studio 路径:
|
||||
|
||||
./gradlew testDebugUnitTest
|
||||
|
||||
30 项单元测试(JVM,无需设备),核心是金标准字节向量测试:
|
||||
用 opendance-web 已通过验收的 TypeScript 实现产出的 239 字节向量,
|
||||
断言本端 Kotlin 实现逐位相同 -- 这是三端契约一致性的硬证据。
|
||||
56 项单元测试(JVM,无需设备)。除金标准字节向量测试外,另有两组针对
|
||||
本次缺陷的用例:
|
||||
- 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
|
||||
|
||||
@@ -302,16 +309,17 @@ Android Studio 路径:
|
||||
gradle/wrapper/ Gradle 8.9 wrapper
|
||||
project.yaml 任务清单与状态
|
||||
DSH.md 本项目 DSH 执行约束
|
||||
tasks/ 单任务说明书(T-AND-001 ~ 009)
|
||||
contracts/ 只读契约快照(来源 opendance-web v0.1.0)
|
||||
tasks/ 单任务说明书(T-AND-001 ~ 012)
|
||||
contracts/ 只读契约快照(来源 opendance-web;六项各自独立版本)
|
||||
app/src/main/java/com/opendance/studio/
|
||||
MainActivity.kt 三页导航
|
||||
model/ 239 字节帧编解码 + 33 点拓扑
|
||||
camera/ CameraX 采集
|
||||
pose/ MediaPipe 端侧推理
|
||||
net/ WebSocket 推流 + 主机发现
|
||||
data/ 设置
|
||||
net/ WebSocket 推流 + 主机发现 + 重连策略
|
||||
data/ 设置(含稳定 deviceId)
|
||||
ui/ 连接页 / 运行页 / 关于页
|
||||
util/ 崩溃日志 + 连接日志(可导出)
|
||||
app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型
|
||||
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`
|
||||
(三端共用,任一端新增依赖须回到 `opendance-web` 更新该表)。
|
||||
|
||||
+141
-6
@@ -1,9 +1,16 @@
|
||||
import java.util.Properties
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
// OpenDance Studio —— Android 辅助 App(应用模块)
|
||||
//
|
||||
// 契约:contracts/websocket-keypoints.md v0.1.0(本端为**发送方**)
|
||||
// contracts/skeleton-topology.md v0.1.0(MediaPipe 33 点)
|
||||
// 契约:contracts/websocket-keypoints.md v0.2.0(本端为**发送方**)
|
||||
// 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 {
|
||||
id("com.android.application")
|
||||
@@ -19,13 +26,49 @@ android {
|
||||
applicationId = "com.opendance.studio"
|
||||
minSdk = 29 // Android 10(文档 T-AND-009 验收要求 Android 10+)
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
|
||||
// versionName 用 X.Y.Z,两者同一次发布一起更新。
|
||||
versionCode = 200
|
||||
versionName = "0.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// 契约版本,供关于页与日志显示
|
||||
buildConfigField("String", "CONTRACT_VERSION", "\"0.1.0\"")
|
||||
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 {
|
||||
noCompress += "task"
|
||||
}
|
||||
|
||||
// T-AND-012:release 默认 extractNativeLibs=false(native 库直接从 APK mmap),
|
||||
// MediaPipe 的 JNI 加载对这一点有历史兼容问题,会表现为
|
||||
// ExceptionInInitializerError / UnsatisfiedLinkError。
|
||||
// 显式使用传统打包(等价 android:extractNativeLibs="true")。
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -190,3 +241,87 @@ dependencies {
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
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 包不会暴露该问题)。
|
||||
|
||||
# ---------------------------------------------------------------- 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 interface com.google.mediapipe.** { *; }
|
||||
-keepclasseswithmembernames class com.google.mediapipe.** {
|
||||
native <methods>;
|
||||
}
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
-dontwarn com.google.mediapipe.**
|
||||
|
||||
# MediaPipe 使用 AutoValue 生成不可变类,依赖注解与泛型签名
|
||||
|
||||
@@ -34,6 +34,20 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.opendance.studio.data
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.edit
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 应用设置(SharedPreferences)。
|
||||
@@ -11,6 +13,7 @@ import androidx.core.content.edit
|
||||
class AppSettings(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val context: Context = context.applicationContext
|
||||
|
||||
/** Windows 客户端主机地址 */
|
||||
var host: String
|
||||
@@ -51,8 +54,32 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getBoolean(KEY_AUTO_RECONNECT, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_AUTO_RECONNECT, value) }
|
||||
|
||||
/** 契约 §1:ws://<windows-host>:8765/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 {
|
||||
const val PREFS_NAME = "opendance_settings"
|
||||
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_DEVICE_LABEL = "device_label"
|
||||
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 com.opendance.studio.BuildConfig
|
||||
import com.opendance.studio.model.KeypointFrame
|
||||
import com.opendance.studio.util.ConnectionLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -12,36 +13,56 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONObject
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.util.concurrent.TimeUnit
|
||||
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
|
||||
* - 二进制帧 = 239 字节关键点(见 [KeypointFrame])
|
||||
* - 文本控制帧 hello / config / stats / bye
|
||||
* - 文本控制帧 hello / welcome / config / stats / ping / pong / bye
|
||||
*
|
||||
* 关键设计:
|
||||
* - 二进制帧走 `send(ByteString)`,控制帧走 `send(text)`
|
||||
* - 自动重连采用指数退避(1s → 2s → 4s … 上限 15s),连接成功即重置
|
||||
* - `sendFrame` 在非连接态直接丢弃并计数,绝不阻塞相机线程
|
||||
* - 统计信息(发送帧数、丢弃帧数、FPS、RTT)由本类维护,供运行页展示
|
||||
* T-AND-011 的修复要点("连接状态异常、频繁重连"):
|
||||
* 1. **状态机与接收端同名**(契约 §8):idle → resolving → connecting →
|
||||
* handshaking → streaming → degraded → reconnecting → stopped,
|
||||
* 界面显示状态 + 最近一次断开原因(close code / reason)。
|
||||
* 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(
|
||||
private val scope: CoroutineScope,
|
||||
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(
|
||||
val state: State = State.IDLE,
|
||||
@@ -52,15 +73,38 @@ class PoseWebSocketClient(
|
||||
val sendFps: Float = 0f,
|
||||
val roundTripMs: Long = -1,
|
||||
val reconnectAttempts: Int = 0,
|
||||
val nextRetryMs: Long = 0,
|
||||
val lastError: 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()
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 长连接不设读超时
|
||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
||||
// 协议层 ping 仍然保留(对中间设备有用),但**不能**替代应用层 ping:
|
||||
// 接收端的静默超时只看应用层收到的数据帧/文本帧。
|
||||
.pingInterval(PROTOCOL_PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
@@ -69,21 +113,31 @@ class PoseWebSocketClient(
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private var heartbeatJob: Job? = null
|
||||
private var url: String = ""
|
||||
private var manualClose = false
|
||||
private var autoReconnect = true
|
||||
|
||||
private val sentCounter = AtomicInteger(0)
|
||||
private val droppedCounter = AtomicInteger(0)
|
||||
private val reconnectCounter = AtomicInteger(0)
|
||||
private val pongCounter = AtomicInteger(0)
|
||||
private val frameIdCounter = AtomicInteger(0)
|
||||
|
||||
/** 首帧时间戳与计数,用于计算发送 FPS */
|
||||
private var fpsWindowStart = 0L
|
||||
private var fpsWindowFrames = 0
|
||||
|
||||
/** 上次收到 pong 的时刻,用于判定链路是否已死 */
|
||||
@Volatile
|
||||
private var lastPongAtMs = 0L
|
||||
|
||||
private val deviceLabel: String =
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
|
||||
|
||||
/** 稳定的设备标识(契约 §3.1),用于接收端去重;不含任何硬件序列号 */
|
||||
var deviceId: String = ""
|
||||
|
||||
// ------------------------------------------------------------------ 连接
|
||||
|
||||
fun connect(targetUrl: String, autoReconnect: Boolean = true) {
|
||||
@@ -91,16 +145,33 @@ class PoseWebSocketClient(
|
||||
manualClose = false
|
||||
this.autoReconnect = autoReconnect
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
closeSocketQuietly()
|
||||
stopHeartbeat()
|
||||
|
||||
val state = if (initial) State.CONNECTING else State.RECONNECTING
|
||||
update(state = state, error = "")
|
||||
update(state = State.CONNECTING, error = "")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
@@ -108,18 +179,64 @@ class PoseWebSocketClient(
|
||||
.build()
|
||||
|
||||
Log.i(TAG, "连接 $url(子协议 $subprotocol)")
|
||||
ConnectionLog.record(
|
||||
if (initial) "connecting $url" else "reconnecting $url(第 ${reconnectCounter.get()} 次)",
|
||||
)
|
||||
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() {
|
||||
|
||||
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)
|
||||
fpsWindowStart = 0L
|
||||
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()
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
@@ -128,35 +245,66 @@ class PoseWebSocketClient(
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
val reason = t.message ?: t.javaClass.simpleName
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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()
|
||||
val attempt = reconnectCounter.incrementAndGet()
|
||||
val delayMs = min(BASE_RECONNECT_MS shl (attempt - 1), MAX_RECONNECT_MS.toInt()).toLong()
|
||||
update(state = State.RECONNECTING, attempts = attempt)
|
||||
// 抖动:±20%,避免同网段多台设备同时重连造成尖峰
|
||||
val delayMs = ReconnectPolicy.delayWithJitterMs(attempt, Random.nextDouble())
|
||||
update(state = State.RECONNECTING, attempts = attempt, nextRetryMs = delayMs)
|
||||
ConnectionLog.record("安排第 $attempt 次重连,等待 ${delayMs}ms")
|
||||
|
||||
reconnectJob = scope.launch {
|
||||
delay(delayMs)
|
||||
if (!manualClose && autoReconnect) {
|
||||
Log.i(TAG, "第 $attempt 次重连(等待 ${delayMs}ms)")
|
||||
openSocket(initial = false)
|
||||
}
|
||||
}
|
||||
@@ -166,18 +314,67 @@ class PoseWebSocketClient(
|
||||
manualClose = true
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
stopHeartbeat()
|
||||
ConnectionLog.record("disconnect():用户主动停止")
|
||||
sendBye("user_stop")
|
||||
closeSocketQuietly()
|
||||
update(state = State.CLOSED)
|
||||
update(state = State.STOPPED)
|
||||
}
|
||||
|
||||
private fun closeSocketQuietly() {
|
||||
webSocket?.let {
|
||||
it.cancel()
|
||||
runCatching { it.cancel() }
|
||||
}
|
||||
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 {
|
||||
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 bytes = KeypointFrame.encode(
|
||||
@@ -199,9 +399,22 @@ class PoseWebSocketClient(
|
||||
)
|
||||
|
||||
val ok = socket.send(okio.ByteString.of(*bytes))
|
||||
if (!ok) return drop()
|
||||
if (!ok) {
|
||||
// 发送失败通常意味着底层连接已断;标记 degraded 并让 OkHttp 触发 onFailure
|
||||
markDegraded("发送失败(缓冲区满或连接已断)")
|
||||
return drop()
|
||||
}
|
||||
|
||||
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++
|
||||
val now = System.currentTimeMillis()
|
||||
if (fpsWindowStart == 0L) fpsWindowStart = now
|
||||
@@ -230,11 +443,20 @@ class PoseWebSocketClient(
|
||||
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() {
|
||||
sendJson(
|
||||
JSONObject().apply {
|
||||
put("type", "hello")
|
||||
put("protocol", subprotocol)
|
||||
put("deviceId", deviceId)
|
||||
put("device", deviceLabel)
|
||||
put("appVersion", BuildConfig.VERSION_NAME)
|
||||
put("cameraFacing", "front")
|
||||
@@ -242,6 +464,7 @@ class PoseWebSocketClient(
|
||||
put("resolution", RESOLUTION_FIELD)
|
||||
},
|
||||
)
|
||||
ConnectionLog.record("hello 已发送(deviceId=${deviceId.ifBlank { "(空)" }})")
|
||||
}
|
||||
|
||||
fun sendStats(fps: Float, dropped: Int, latencyMs: Long, confidenceAvg: Float) {
|
||||
@@ -267,8 +490,9 @@ class PoseWebSocketClient(
|
||||
|
||||
private fun sendJson(json: JSONObject) {
|
||||
val socket = webSocket ?: return
|
||||
if (_stats.value.state != State.CONNECTED) return
|
||||
socket.send(json.toString())
|
||||
val state = _stats.value.state
|
||||
if (state == State.IDLE || state == State.STOPPED || state == State.RECONNECTING) return
|
||||
runCatching { socket.send(json.toString()) }
|
||||
}
|
||||
|
||||
private fun handleControlMessage(text: String) {
|
||||
@@ -276,19 +500,81 @@ class PoseWebSocketClient(
|
||||
val json = JSONObject(text)
|
||||
when (json.optString("type")) {
|
||||
"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(
|
||||
state = State.STREAMING,
|
||||
serverSessionId = json.optString("sessionId", ""),
|
||||
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)
|
||||
"bye" -> Log.i(TAG, "服务端要求断开:${json.optString("reason")}")
|
||||
startHeartbeat()
|
||||
}
|
||||
"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) {
|
||||
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) */
|
||||
var onRemoteConfig: ((JSONObject) -> Unit)? = null
|
||||
|
||||
@@ -296,12 +582,14 @@ class PoseWebSocketClient(
|
||||
state: State,
|
||||
error: String = _stats.value.lastError,
|
||||
attempts: Int = _stats.value.reconnectAttempts,
|
||||
nextRetryMs: Long = _stats.value.nextRetryMs,
|
||||
) {
|
||||
_stats.value = _stats.value.copy(
|
||||
state = state,
|
||||
url = url,
|
||||
lastError = error,
|
||||
reconnectAttempts = attempts,
|
||||
nextRetryMs = if (state == State.RECONNECTING) nextRetryMs else 0L,
|
||||
sentFrames = sentCounter.get(),
|
||||
droppedFrames = droppedCounter.get(),
|
||||
)
|
||||
@@ -310,15 +598,20 @@ class PoseWebSocketClient(
|
||||
fun release() {
|
||||
disconnect()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PoseWebSocket"
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 8L
|
||||
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
|
||||
|
||||
/** 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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.camera.core.ImageProxy
|
||||
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.opendance.studio.model.KeypointFrame
|
||||
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` 立即返回,结果通过监听器异步回调。
|
||||
* 模型 `pose_landmarker_lite.task` 随 APK 分发(assets/,不压缩)。
|
||||
*
|
||||
* 输出 33 个归一化关键点,直接对齐契约 contracts/skeleton-topology.md §1
|
||||
* 的索引顺序(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) {
|
||||
|
||||
@@ -38,6 +52,9 @@ class PoseDetector(private val context: Context) {
|
||||
private var lastTimestampMs = 0L
|
||||
private var busy = false
|
||||
|
||||
/** APK 内实际包含的 ABI 目录(懒加载,用于诊断) */
|
||||
private val apkAbis: List<String> by lazy { readApkAbis() }
|
||||
|
||||
var onResult: ((PoseResult) -> Unit)? = null
|
||||
var onError: ((String) -> Unit)? = null
|
||||
|
||||
@@ -83,12 +100,32 @@ class PoseDetector(private val context: Context) {
|
||||
// 静态初始化失败会抛 ExceptionInInitializerError,它们都不是 Exception。
|
||||
// 只 catch Exception 会让这类失败直接冒泡成整个应用崩溃。
|
||||
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
|
||||
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**。
|
||||
*
|
||||
@@ -105,6 +142,9 @@ class PoseDetector(private val context: Context) {
|
||||
|
||||
val mpImage: MPImage = BitmapImageBuilder(rotated).build()
|
||||
val safeTimestamp = if (timestampMs <= lastTimestampMs) lastTimestampMs + 1 else timestampMs
|
||||
// 必须把时间戳记下来:handleResult 用它派生 frame_id 与结果时间戳。
|
||||
// 原先漏了这一步,导致 frame_id 恒为 0,接收端无法按 frame_id 检测丢包。
|
||||
lastTimestampMs = safeTimestamp
|
||||
busy = true
|
||||
detector.detectAsync(mpImage, safeTimestamp)
|
||||
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-010:关于清单展示当前版本号(文档 §11.1:Android App 在「关于」清单)
|
||||
*
|
||||
* 文档 §8.3 要求 Android App 与 Windows 客户端的「关于」页展示同款声明,可滚动查看。
|
||||
*
|
||||
* 版本号**唯一来源**是 `app/build.gradle.kts` 的 `versionName`,即
|
||||
* [BuildConfig.VERSION_NAME](= project.yaml 的 version = APK 文件名 = Git tag);
|
||||
* 契约版本来自构建期解析的 `contracts/VERSION`。界面代码里不出现任何版本号字面量。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -77,12 +82,18 @@ fun AboutScreen(onBack: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
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))
|
||||
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("关键点拓扑", "MediaPipe Pose 33 点")
|
||||
InfoRow("传输帧长", "239 字节")
|
||||
InfoRow("保活", "应用层 ping,静默超时由服务端声明")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +114,12 @@ fun AboutScreen(onBack: () -> Unit) {
|
||||
color = OpenDanceMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
"关键点只发给同一局域网内的 Windows 客户端;连接日志保存在应用私有目录," +
|
||||
"需要时由「运行页 -> 导出连接日志」主动分享,不会自动上传。",
|
||||
color = OpenDanceMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
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)逐条对应,
|
||||
* 并与 opendance-web 的 THIRD_PARTY.md、opendance-windows 的关于页保持一致。
|
||||
* 该表是**唯一来源**,新增依赖须先回到 opendance-web 更新契约。
|
||||
* 与 contracts/open-source-credits.md 逐条对应,并与 opendance-web 的
|
||||
* THIRD_PARTY.md、opendance-windows 的关于页保持一致。
|
||||
* 依赖表是**唯一来源**,新增依赖须先回到 opendance-web 更新契约。
|
||||
*
|
||||
* 版本号**不在本文件硬编码**(文档 §11.2 第 1 条):
|
||||
* - 应用版本 -> [BuildConfig.VERSION_NAME](来自 versionName,与 project.yaml 同源)
|
||||
* - 契约版本 -> 构建期从 contracts/VERSION 解析后注入的
|
||||
* [BuildConfig.CONTRACT_VERSION_KEYS] / [BuildConfig.CONTRACT_VERSION_VALUES]
|
||||
* 这样"关于"页展示的一定是「当前正在跑的版本」,而不是某次手写的静态文案。
|
||||
*/
|
||||
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(
|
||||
val name: String,
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.opendance.studio.camera.CameraController
|
||||
import com.opendance.studio.data.AppSettings
|
||||
import com.opendance.studio.net.PoseWebSocketClient
|
||||
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.OpenDanceMuted
|
||||
import com.opendance.studio.ui.theme.OpenDanceRed
|
||||
@@ -69,6 +70,9 @@ import kotlinx.coroutines.launch
|
||||
* 1. 相机权限用平台 Activity Result API,不引入 accompanist(避免 Compose 版本偏斜)
|
||||
* 2. MediaPipe 回调发生在相机分析线程 —— 所有 Compose 状态写入都 post 回主线程
|
||||
* 3. 初始化/连接失败只记录并提示,绝不让异常冒泡成崩溃
|
||||
*
|
||||
* T-AND-011:连接状态按契约 §8 展示(含 close code / 断开原因 / 重试倒计时),
|
||||
* 并提供「导出连接日志」(契约 §8 的"可诊断"要求)。
|
||||
*/
|
||||
private const val TAG = "RunningScreen"
|
||||
|
||||
@@ -102,9 +106,15 @@ fun RunningScreen(
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
var cameraError by remember { mutableStateOf("") }
|
||||
var fatalError by remember { mutableStateOf("") }
|
||||
var logHint by remember { mutableStateOf("") }
|
||||
|
||||
val wsStats = remember { PoseWebSocketClient(scope) }
|
||||
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 poseDetector = remember { PoseDetector(context) }
|
||||
@@ -290,15 +300,32 @@ fun RunningScreen(
|
||||
) {
|
||||
Text("连接状态", color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
text = stateLabel(stats.state, stats.reconnectAttempts),
|
||||
text = stateLabel(stats.state, stats.reconnectAttempts, stats.nextRetryMs),
|
||||
color = stateColor(stats.state),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
InfoLine("状态机", stats.state.wireName)
|
||||
InfoLine("目标", stats.url)
|
||||
if (stats.serverSessionId.isNotBlank()) {
|
||||
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(
|
||||
"已丢弃",
|
||||
@@ -332,6 +359,9 @@ fun RunningScreen(
|
||||
if (cameraError.isNotBlank()) {
|
||||
InfoLine("相机错误", cameraError, OpenDanceRed)
|
||||
}
|
||||
if (logHint.isNotBlank()) {
|
||||
InfoLine("日志", logHint, OpenDanceGreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +388,19 @@ fun RunningScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
) { 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(
|
||||
onClick = {
|
||||
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.RESOLVING -> "解析主机地址…"
|
||||
PoseWebSocketClient.State.CONNECTING -> "连接中…"
|
||||
PoseWebSocketClient.State.CONNECTED -> "已连接"
|
||||
PoseWebSocketClient.State.RECONNECTING -> "重连中(第 $attempts 次)"
|
||||
PoseWebSocketClient.State.CLOSED -> "已断开"
|
||||
PoseWebSocketClient.State.HANDSHAKING -> "握手中…"
|
||||
PoseWebSocketClient.State.STREAMING -> "推流中"
|
||||
PoseWebSocketClient.State.DEGRADED -> "推流中(链路异常,已丢帧)"
|
||||
PoseWebSocketClient.State.RECONNECTING ->
|
||||
"重连中(第 $attempts 次,${nextRetryMs} ms 后重试)"
|
||||
PoseWebSocketClient.State.STOPPED -> "已停止"
|
||||
}
|
||||
|
||||
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.HANDSHAKING,
|
||||
PoseWebSocketClient.State.RECONNECTING,
|
||||
-> OpenDanceYellow
|
||||
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
|
||||
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 发布打包
|
||||
#
|
||||
# 产物(文档 §6.3):
|
||||
# opendance-android-v0.1.0.zip
|
||||
# ├── app-release.apk
|
||||
# ├── THIRD_PARTY.md
|
||||
# ├── README.txt
|
||||
# └── contracts/ 契约快照
|
||||
# 产物(文档 §6.3 / §11.2):
|
||||
# opendance-android-vX.Y.Z.zip ← 单个完整包
|
||||
# SHA256SUMS ← 裸文件名,下载目录内 sha256sum -c 直接可用
|
||||
#
|
||||
# 包内结构:
|
||||
# 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 [版本号]
|
||||
# ============================================================
|
||||
@@ -16,7 +23,14 @@ set -euo pipefail
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
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}"
|
||||
OUT_DIR="$PROJECT_DIR/dist-release"
|
||||
STAGE="$OUT_DIR/$NAME"
|
||||
@@ -37,14 +51,42 @@ mkdir -p "$STAGE"
|
||||
|
||||
cp "$APK" "$STAGE/app-release.apk"
|
||||
cp THIRD_PARTY.md README.txt "$STAGE/"
|
||||
if [ -f README.md ]; then cp README.md "$STAGE/"; fi
|
||||
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 "生成压缩包…"
|
||||
( 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 "产物内容:"
|
||||
unzip -l "$OUT_DIR/$NAME.zip" | sed -n '4,20p'
|
||||
echo "产物:"
|
||||
echo " $ZIP"
|
||||
echo " $OUT_DIR/SHA256SUMS"
|
||||
if ( cd "$OUT_DIR" && sha256sum -c SHA256SUMS --status ); then
|
||||
echo " sha256 校验:OK"
|
||||
else
|
||||
die "sha256 自校验失败"
|
||||
fi
|
||||
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
|
||||
fi
|
||||
|
||||
ABIS=$(unzip -l "$RELEASE_APK" | grep -oE "lib/[^/]+/" | sort -u | tr '\n' ' ')
|
||||
echo "✓ 包含 ABI:$ABIS"
|
||||
# ---- T-AND-012:ABI 必须恰为真机 ABI,且每个 ABI 下都有 MediaPipe native 库 ----
|
||||
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
|
||||
|
||||
echo ""
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# OpenDance Studio 契约版本
|
||||
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
|
||||
rest-api: 0.5.0
|
||||
websocket-keypoints: 0.1.0
|
||||
websocket-keypoints: 0.2.0
|
||||
data-models: 0.7.0
|
||||
skeleton-topology: 0.2.0
|
||||
model-formats: 0.5.0
|
||||
|
||||
@@ -940,4 +940,96 @@ components:
|
||||
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
|
||||
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
|
||||
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 }
|
||||
|
||||
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 客户端(接收端)。
|
||||
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
|
||||
@@ -14,6 +14,22 @@ ws://<windows-host>:8765/pose
|
||||
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
|
||||
- 默认 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 字节(冻结)
|
||||
|
||||
**小端序(little-endian)**。
|
||||
@@ -76,26 +92,54 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
{
|
||||
"type": "hello",
|
||||
"protocol": "opendance.pose.v1",
|
||||
"deviceId": "a1b2c3d4e5f60718",
|
||||
"device": "Pixel 8",
|
||||
"appVersion": "0.1.0",
|
||||
"appVersion": "0.2.0",
|
||||
"cameraFacing": "front",
|
||||
"targetFps": 30,
|
||||
"resolution": "640x480"
|
||||
}
|
||||
```
|
||||
|
||||
- `deviceId`(v0.2.0 新增,**可选但强烈建议**):该安装实例的稳定标识
|
||||
(建议 16 位十六进制,取自 `ANDROID_ID` 的哈希,不使用任何硬件序列号)。
|
||||
接收端**必须按 `deviceId` 去重**:同一 `deviceId` 的新连接替换旧连接,
|
||||
设备数按去重后计数(否则一次「反复重连」会被显示成多台设备)。
|
||||
未提供 `deviceId` 时接收端退回按连接计数。
|
||||
|
||||
### 3.2 `welcome` - Windows -> Android
|
||||
|
||||
```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,运行中调整
|
||||
|
||||
```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,每秒一次
|
||||
|
||||
```json
|
||||
@@ -108,6 +152,31 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
{ "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 调试帧(可选,仅开发期)
|
||||
|
||||
```json
|
||||
@@ -123,9 +192,68 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
|
||||
生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。
|
||||
|
||||
## 5. 兼容性规则
|
||||
## 5. 设备去重(v0.2.0 新增)
|
||||
|
||||
接收端按 §3.1 的 `deviceId` 去重:
|
||||
|
||||
| 情况 | 行为 |
|
||||
|--|--|
|
||||
| 新连接的 `deviceId` 已存在 | 关闭旧连接(`close(1000, "replaced by new connection")`),保留新连接;设备数不变 |
|
||||
| `deviceId` 缺失或为空 | 不做去重,按连接计数 |
|
||||
| 同一 `deviceId` 的旧连接已断开 | 正常替换,不影响设备数 |
|
||||
|
||||
这样「手机闪断后立刻重连」不会在接收端显示成两台设备,也不会让
|
||||
「已连接 N 台」抖动。
|
||||
|
||||
## 6. 兼容性规则
|
||||
|
||||
- `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。
|
||||
- `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。
|
||||
- 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。
|
||||
- 服务端 `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
|
||||
version: 0.1.0
|
||||
version: 0.2.0
|
||||
repository: opendance-android
|
||||
default_branch: main
|
||||
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):
|
||||
# 任何跨端契约变更必须先在 opendance-web 修改 contracts/ 并提升版本,
|
||||
@@ -56,12 +62,27 @@ tasks:
|
||||
file: tasks/009-build-apk.md
|
||||
scope: Android Studio 打包
|
||||
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:
|
||||
artifact: opendance-android-v0.1.0.apk
|
||||
artifact: opendance-android-v0.2.0.apk
|
||||
packaging: Gradle 命令行 / Android Studio
|
||||
includes:
|
||||
- app-release.apk
|
||||
- THIRD_PARTY.md
|
||||
- README.txt
|
||||
- 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