diff --git a/DSH.md b/DSH.md index d5132be..cc49c5d 100644 --- a/DSH.md +++ b/DSH.md @@ -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 规则 diff --git a/README.md b/README.md index 22a2432..195472b 100644 --- a/README.md +++ b/README.md @@ -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://:<端口>/pose`;服务端返回 welcome 后出现「会话」ID | +| App 运行页 | 「连接状态」为**推流中**(`streaming`;短暂经过 解析地址 → 连接中 → 握手中) | +| App 运行页 | 「目标」显示 `ws://:<端口>/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 点索引与坐标语义 diff --git a/README.txt b/README.txt index b745bb7..44cc86f 100644 --- a/README.txt +++ b/README.txt @@ -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/ 打包与验证脚本 diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 34a247c..359c2e6 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -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` 更新该表)。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff771f9..992dce7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() + 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 = ZipFile(apk).use { zip -> + zip.entries().asSequence().map { entry -> entry.name }.toList() + } + val abis: Set = 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 模型资源存在") + } +} + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 04a8139..9b615a1 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 ; +} +-keepclasseswithmembernames class * { + native ; +} -dontwarn com.google.mediapipe.** # MediaPipe 使用 AutoValue 生成不可变类,依赖注解与泛型签名 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 146fd73..066fe3a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,20 @@ + + + + + diff --git a/app/src/main/java/com/opendance/studio/data/AppSettings.kt b/app/src/main/java/com/opendance/studio/data/AppSettings.kt index f1b1091..69e038d 100644 --- a/app/src/main/java/com/opendance/studio/data/AppSettings.kt +++ b/app/src/main/java/com/opendance/studio/data/AppSettings.kt @@ -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://: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" } } diff --git a/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt b/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt index 6987064..6f25133 100644 --- a/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt +++ b/app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt @@ -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://: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) - openSocket(initial = true) + 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, + ), + ) + } + 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") } - "config" -> onRemoteConfig?.invoke(json) - "bye" -> Log.i(TAG, "服务端要求断开:${json.optString("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 里的字段由设置填充,运行时由调用方覆盖 */ diff --git a/app/src/main/java/com/opendance/studio/net/ReconnectPolicy.kt b/app/src/main/java/com/opendance/studio/net/ReconnectPolicy.kt new file mode 100644 index 0000000..eed63e8 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/net/ReconnectPolicy.kt @@ -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 +} diff --git a/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt b/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt index 6144b72..838192a 100644 --- a/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt +++ b/app/src/main/java/com/opendance/studio/pose/PoseDetector.kt @@ -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 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 = 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 diff --git a/app/src/main/java/com/opendance/studio/pose/PoseDiagnostics.kt b/app/src/main/java/com/opendance/studio/pose/PoseDiagnostics.kt new file mode 100644 index 0000000..301ec3e --- /dev/null +++ b/app/src/main/java/com/opendance/studio/pose/PoseDiagnostics.kt @@ -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() + 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): List = + entries.filter { it.startsWith("lib/") } + .map { it.removePrefix("lib/").substringBefore('/') } + .filter { it.isNotEmpty() } + .distinct() + .sorted() + + /** 某个 ABI 下是否带 MediaPipe 的 native 库 */ + fun hasMediaPipeNative(entries: List, 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, apkAbis: List): 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, + apkAbis: List, + ): String { + val root = rootCause(t) + val segments = mutableListOf() + 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") + } +} diff --git a/app/src/main/java/com/opendance/studio/ui/AboutScreen.kt b/app/src/main/java/com/opendance/studio/ui/AboutScreen.kt index a72d491..cb4f2ff 100644 --- a/app/src/main/java/com/opendance/studio/ui/AboutScreen.kt +++ b/app/src/main/java/com/opendance/studio/ui/AboutScreen.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/opendance/studio/ui/Credits.kt b/app/src/main/java/com/opendance/studio/ui/Credits.kt index 9777389..cde5542 100644 --- a/app/src/main/java/com/opendance/studio/ui/Credits.kt +++ b/app/src/main/java/com/opendance/studio/ui/Credits.kt @@ -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> + 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, diff --git a/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt b/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt index 3955f09..4afed16 100644 --- a/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt +++ b/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt @@ -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 diff --git a/app/src/main/java/com/opendance/studio/util/ConnectionLog.kt b/app/src/main/java/com/opendance/studio/util/ConnectionLog.kt new file mode 100644 index 0000000..0e6c9c5 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/util/ConnectionLog.kt @@ -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() + 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 = 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 + } + } +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..fb38863 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/test/java/com/opendance/studio/CreditsTest.kt b/app/src/test/java/com/opendance/studio/CreditsTest.kt index 1d6874c..122a63a 100644 --- a/app/src/test/java/com/opendance/studio/CreditsTest.kt +++ b/app/src/test/java/com/opendance/studio/CreditsTest.kt @@ -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 { + 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() + ?: "" } } diff --git a/app/src/test/java/com/opendance/studio/PoseDiagnosticsTest.kt b/app/src/test/java/com/opendance/studio/PoseDiagnosticsTest.kt new file mode 100644 index 0000000..05dad9a --- /dev/null +++ b/app/src/test/java/com/opendance/studio/PoseDiagnosticsTest.kt @@ -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// 下的目录名并去重排序", + 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(), + 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")) + } +} diff --git a/app/src/test/java/com/opendance/studio/ReconnectPolicyTest.kt b/app/src/test/java/com/opendance/studio/ReconnectPolicyTest.kt new file mode 100644 index 0000000..452a78b --- /dev/null +++ b/app/src/test/java/com/opendance/studio/ReconnectPolicyTest.kt @@ -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)) + } +} diff --git a/build/package-release.sh b/build/package-release.sh index a5a8b45..c5dbb91 100755 --- a/build/package-release.sh +++ b/build/package-release.sh @@ -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+)" diff --git a/build/verify.sh b/build/verify.sh index c016662..357c5f1 100755 --- a/build/verify.sh +++ b/build/verify.sh @@ -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 "" diff --git a/contracts/VERSION b/contracts/VERSION index 16044a8..3b1ce3a 100644 --- a/contracts/VERSION +++ b/contracts/VERSION @@ -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 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index 6964940..2170b51 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -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 } diff --git a/contracts/websocket-keypoints.md b/contracts/websocket-keypoints.md index f6ed088..0af36c3 100644 --- a/contracts/websocket-keypoints.md +++ b/contracts/websocket-keypoints.md @@ -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://: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)在日志与状态面板中使用同名状态描述每个客户端连接。 diff --git a/project.yaml b/project.yaml index febffb0..045136b 100644 --- a/project.yaml +++ b/project.yaml @@ -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 契约版本 diff --git a/tasks/010-about-version.md b/tasks/010-about-version.md new file mode 100644 index 0000000..611efdc --- /dev/null +++ b/tasks/010-about-version.md @@ -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_` 与 `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` 核对 | diff --git a/tasks/011-reconnect.md b/tasks/011-reconnect.md new file mode 100644 index 0000000..a14d3f5 --- /dev/null +++ b/tasks/011-reconnect.md @@ -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 暴露设备数与保活参数。 diff --git a/tasks/012-mediapipe-crash.md b/tasks/012-mediapipe-crash.md new file mode 100644 index 0000000..9c70743 --- /dev/null +++ b/tasks/012-mediapipe-crash.md @@ -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//` 实际目录,直接给出"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 ; }`,并用 `-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 中 +```