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:
2026-09-17 01:10:06 +08:00
parent 392f4544f4
commit 7ebbaef631
29 changed files with 1979 additions and 98 deletions
+1 -1
View File
@@ -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 规则
+28 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 模型资源存在")
}
}
+13
View File
@@ -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 生成不可变类,依赖注解与泛型签名
+14
View File
@@ -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
}
}
}
+9
View 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+92
View File
@@ -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 }
+133 -5
View File
@@ -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
View File
@@ -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 契约版本
+45
View File
@@ -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` 核对 |
+81
View File
@@ -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 暴露设备数与保活参数。
+62
View File
@@ -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 中
```