feat(net,pose): 连接状态机与保活对齐 + MediaPipe 崩溃修复(T-AND-010/011/012)

契约 websocket-keypoints 0.1.0 -> 0.2.0(向后兼容,子协议名与帧 version 不变;
先在 opendance-web 修改并提升版本,再同步三端快照)。

T-AND-011:连接状态异常、频繁重连(查实 3 个根因)
1. 保活用错了层:Windows 端有 5s 静默超时(只看应用层收到的帧),而本端只有
   OkHttp 的协议层 ping —— 它由底层库自动应答、**不暴露给应用层**,根本不能续命。
   画面里没人时 5s 必被踢、再重连,循环往复。
   - 契约新增 §1.1(welcome/config 声明 silentTimeoutMs 与 heartbeatIntervalMs,
     且心跳必须 < 静默超时的一半)与 §3.6(应用层 ping/pong);
   - Windows 端超时 5s -> 15s、期望心跳 5s,并在 welcome/config 中下发;
   - 本端按声明发 ping,连续 3 个周期无 pong 才判定链路已死并主动重连。
2. 不该重连时也重连:契约新增 §7 关闭码策略,1000/1001/1002 与主动停止一律不重连。
3. frame_id 恒为 0:PoseDetector.detect() 算了 safeTimestamp 却忘了写回
   lastTimestampMs,接收端的丢包检测因此完全失效。已修。

其他对齐:
- 契约新增 §8 连接状态机 idle -> resolving -> connecting -> handshaking ->
  streaming -> degraded -> reconnecting -> stopped,两端同名便于对照日志;
  resolving 只对主机名生效,局域网直连 IP 直接进 connecting;
- 退避与抖动按 §7.1 冻结:0.5s -> 1 -> 2 -> 4 -> 8 -> 15s 封顶 + ±20% 抖动;
- 契约新增 §5 设备去重:hello 携带稳定 deviceId(ANDROID_ID 的 SHA-256 前 16 位,
  不含硬件序列号),Windows 端据此去重、旧连接以 1000 关闭;
- 新增 util/ConnectionLog(环形缓冲,最近 500 条:状态迁移 / close code /
  子协议协商 / 首帧时间 / 保活参数),运行页可一键导出(FileProvider)。

T-AND-012:MediaPipe 初始化崩溃(ExceptionInInitializerError: null)
- 诊断:新增 pose/PoseDiagnostics 展开 cause 链取真因(通常 UnsatisfiedLinkError)、
  区分 native 加载失败、比对设备 ABI 与 APK 内 ABI 并给出可执行结论;
- native 解压:packaging { jniLibs { useLegacyPackaging = true } }(AGP 8 release
  默认 extractNativeLibs=false,MediaPipe JNI 对它不兼容);
- R8:补 -keepclasseswithmembernames 的 native 方法保留规则,并 -printseeds 输出
  seeds 供发布前核对(实测保留 20169 条 MediaPipe 条目);
- 构建期新增 verifyReleaseAbi:release ABI 必须恰为 arm64-v8a/armeabi-v7a、
  每个 ABI 下必须有 MediaPipe native 库、模型资源必须存在;
- 未换库:诊断指向集成配置而非 MediaPipe 本身,保留 33 点契约零成本。

T-AND-010:版本号展示与单一来源
- 版本唯一来源 versionName(= project.yaml 的 version);versionCode 按
  10000*主+100*次+修订 递增(0.2.0 -> 200);
- 契约版本改为构建期解析 contracts/VERSION 后注入 BuildConfig(六项各自独立
  递增,原先的单个 CONTRACT_VERSION 常量已失真);界面不再手写任何版本号;
- 新增 checkReleaseConsistency 并挂到 preBuild,三者不一致直接 BUILD FAILED。

README/DSH/THIRD_PARTY/tasks 同步更新(含「导出连接日志」「保活参数」
「最近断开原因」等新增可观察项与排查表)。

测试:56 项全部通过(新增 ReconnectPolicyTest 11 项、PoseDiagnosticsTest 12 项);
build/verify.sh 全通过(单元测试 -> 构建 -> 签名/ABI/native/seeds/dex 校验)
This commit is contained in:
2026-09-17 01:10:06 +08:00
parent 392f4544f4
commit 7ebbaef631
29 changed files with 1979 additions and 98 deletions
+141 -6
View File
@@ -1,9 +1,16 @@
import java.util.Properties
import java.util.zip.ZipFile
// OpenDance Studio —— Android 辅助 App(应用模块)
//
// 契约:contracts/websocket-keypoints.md v0.1.0(本端为**发送方**)
// contracts/skeleton-topology.md v0.1.0(MediaPipe 33 点)
// 契约:contracts/websocket-keypoints.md v0.2.0(本端为**发送方**)
// contracts/skeleton-topology.md v0.2.0(MediaPipe 33 点)
//
// 版本号与契约版本的**单一来源**(文档 §11.2 第 1 条):
// - 应用版本 -> app/build.gradle.kts 的 versionName(= 本次要发布的版本)
// - 契约版本 -> contracts/VERSION(构建期解析后注入 BuildConfig)
// 两者都不允许在 Kotlin 界面代码里再手写一份;文件末尾的
// `checkReleaseConsistency` 任务会在打包前校验与 project.yaml 一致。
plugins {
id("com.android.application")
@@ -19,13 +26,49 @@ android {
applicationId = "com.opendance.studio"
minSdk = 29 // Android 10(文档 T-AND-009 验收要求 Android 10+)
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
// versionName 用 X.Y.Z,两者同一次发布一起更新。
versionCode = 200
versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 契约版本,供关于页与日志显示
buildConfigField("String", "CONTRACT_VERSION", "\"0.1.0\"")
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
// ---- 契约版本:从 contracts/VERSION 解析,作为唯一来源注入 BuildConfig ----
// 六项契约各自独立递增(rest-api 0.5.0 时 skeleton-topology 可能还是 0.2.0),
// 因此注入每一项 + 一个用于展示的汇总串,界面不再手写任何版本号。
val contractVersions = linkedMapOf<String, String>()
val versionFile = rootProject.file("contracts/VERSION")
versionFile.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains(":") }
.forEach { line ->
val key = line.substringBefore(":").trim()
val value = line.substringAfter(":").trim()
if (key.isNotEmpty() && value.isNotEmpty()) contractVersions[key] = value
}
check(contractVersions.isNotEmpty()) {
"无法从 ${versionFile.path} 解析契约版本:请确认该文件存在且格式为 `key: value`"
}
contractVersions.forEach { (key, value) ->
val field = "CONTRACT_" + key.uppercase().replace('-', '_')
buildConfigField("String", field, "\"$value\"")
}
buildConfigField(
"String",
"CONTRACT_VERSION",
"\"" + contractVersions["websocket-keypoints"] + "\"",
)
buildConfigField(
"String[]",
"CONTRACT_VERSION_KEYS",
contractVersions.keys.joinToString(", ") { "\"$it\"" }.let { "new String[]{$it}" },
)
buildConfigField(
"String[]",
"CONTRACT_VERSION_VALUES",
contractVersions.values.joinToString(", ") { "\"$it\"" }.let { "new String[]{$it}" },
)
}
/**
@@ -119,6 +162,14 @@ android {
androidResources {
noCompress += "task"
}
// T-AND-012:release 默认 extractNativeLibs=false(native 库直接从 APK mmap),
// MediaPipe 的 JNI 加载对这一点有历史兼容问题,会表现为
// ExceptionInInitializerError / UnsatisfiedLinkError。
// 显式使用传统打包(等价 android:extractNativeLibs="true")。
jniLibs {
useLegacyPackaging = true
}
}
testOptions {
@@ -190,3 +241,87 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
// ============================================================================
// T-AND-010 / T-AND-012:发布前自检
//
// 1) 版本号与 project.yaml 一致(文档 §11.2:产物名 / tag / README / 界面必须同源);
// 2) release APK 的 ABI 必须与 abiFilters 一致,且每个 ABI 目录下都要有
// MediaPipe 的 native 库 —— 这正是 "debug 好用、release 崩" 的典型根因
// (在 x86_64 模拟器上装 release 包必然缺库)。
// ============================================================================
/** 从 project.yaml 取 `version:` */
fun projectYamlVersion(): String {
val file = rootProject.file("project.yaml")
if (!file.exists()) return ""
return file.readLines()
.firstOrNull { it.trimStart().startsWith("version:") }
?.substringAfter(":")
?.trim()
?: ""
}
// 每次构建都先做版本/契约一致性校验,避免版本漂移进入产物
tasks.matching { it.name == "preBuild" }.configureEach { dependsOn("checkReleaseConsistency") }
tasks.register("checkReleaseConsistency") {
group = "verification"
description = "校验 versionName 与 project.yaml / 契约快照一致(文档 §11.2)"
doLast {
val yamlVersion = projectYamlVersion()
val name = android.defaultConfig.versionName
if (yamlVersion.isNotEmpty() && yamlVersion != name) {
throw GradleException("versionName=$name 与 project.yaml 的 version=$yamlVersion 不一致")
}
val wsVersion = android.defaultConfig.buildConfigFields["CONTRACT_WEBSOCKET_KEYPOINTS"]?.value?.trim('"')
if (wsVersion != "0.2.0") {
throw GradleException("contracts/VERSION 的 websocket-keypoints=$wsVersion,期望 0.2.0")
}
val code = android.defaultConfig.versionCode
val expected = name!!.split(".").let {
it[0].toInt() * 10000 + it[1].toInt() * 100 + it[2].toInt()
}
logger.lifecycle("[consistency] versionName=$name versionCode=$code 契约(websocket-keypoints)=$wsVersion")
if (code != expected) {
throw GradleException("versionCode=$code 与 versionName=$name 不匹配(应为 $expected)")
}
}
}
/**
* 校验 release APK 里的 native 库:
* - ABI 目录集合必须等于 abiFilters(多一个或少一个都算失败);
* - 每个 ABI 目录下必须存在 MediaPipe 的 native 库;
* - assets 里的 MediaPipe 模型存在。
*/
tasks.register("verifyReleaseAbi") {
group = "verification"
description = "校验 release APK 的 ABI 与 MediaPipe native 库(T-AND-012)"
dependsOn("assembleRelease")
doLast {
val apk = file("build/outputs/apk/release/app-release.apk")
if (!apk.exists()) throw GradleException("未生成 ${apk.path}")
val expected = setOf("arm64-v8a", "armeabi-v7a")
// 注意:脚本里 `java` 被 JavaPluginExtension 遮蔽,必须用顶部 import 的 ZipFile
val entries: List<String> = ZipFile(apk).use { zip ->
zip.entries().asSequence().map { entry -> entry.name }.toList()
}
val abis: Set<String> = entries.filter { name -> name.startsWith("lib/") }
.map { name -> name.removePrefix("lib/").substringBefore("/") }
.toSet()
logger.lifecycle("[abi] release APK 包含 ABI:$abis")
if (abis != expected) {
throw GradleException("release APK 的 ABI=$abis,期望 $expected")
}
abis.forEach { abi: String ->
val hasJni = entries.any { name -> name.startsWith("lib/$abi/") && name.contains("mediapipe") }
if (!hasJni) throw GradleException("lib/$abi/ 下缺少 MediaPipe native 库")
logger.lifecycle("[abi] $abi 下 MediaPipe native 库存在")
}
val model = entries.count { name -> name == "assets/pose_landmarker_lite.task" }
if (model != 1) throw GradleException("assets/pose_landmarker_lite.task 数量为 $model(期望 1)")
logger.lifecycle("[abi] MediaPipe 模型资源存在")
}
}
+13
View File
@@ -7,8 +7,21 @@
# 删掉这些规则会导致 release 包在真机上运行时崩溃(debug 包不会暴露该问题)。
# ---------------------------------------------------------------- MediaPipe
#
# T-AND-012:release 崩溃(ExceptionInInitializerError / UnsatisfiedLinkError)
# 的一大来源就是 R8 把 MediaPipe 的类裁掉或改名。以下规则逐条对应:
# 1. 整个包保持原样(JNI 按类名/方法名查找);
# 2. **native 方法所在的类与其声明类名必须保留**(仅 keep class 不够,
# R8 仍可能裁剪方法名,导致 JNI 注册失败);
# 3. 生成 seeds 文件,发布前可用 -printseeds 核对关键类没被裁掉。
-keep class com.google.mediapipe.** { *; }
-keep interface com.google.mediapipe.** { *; }
-keepclasseswithmembernames class com.google.mediapipe.** {
native <methods>;
}
-keepclasseswithmembernames class * {
native <methods>;
}
-dontwarn com.google.mediapipe.**
# MediaPipe 使用 AutoValue 生成不可变类,依赖注解与泛型签名
+14
View File
@@ -34,6 +34,20 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
T-AND-011:导出连接日志时通过 FileProvider 分享 filesDir 下的文本文件。
Android 7+ 不允许直接以 file:// 分享,必须走 content://。
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -1,7 +1,9 @@
package com.opendance.studio.data
import android.content.Context
import android.provider.Settings
import androidx.core.content.edit
import java.security.MessageDigest
/**
* 应用设置(SharedPreferences)。
@@ -11,6 +13,7 @@ import androidx.core.content.edit
class AppSettings(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val context: Context = context.applicationContext
/** Windows 客户端主机地址 */
var host: String
@@ -51,8 +54,32 @@ class AppSettings(context: Context) {
get() = prefs.getBoolean(KEY_AUTO_RECONNECT, true)
set(value) = prefs.edit { putBoolean(KEY_AUTO_RECONNECT, value) }
/** 契约 §1:ws://<windows-host>:8765/pose */
fun webSocketUrl(): String = "ws://$host:$port/pose"
/**
* 契约 §3.1 的 hello.deviceId:该安装实例的**稳定标识**,用于接收端去重
* (否则一次"闪断重连"会被 Windows 端显示成两台设备)。
*
* 取 `ANDROID_ID` 的 SHA-256 前 16 位十六进制:ANDROID_ID 在 Android 8+
* 按「应用签名 + 用户 + 设备」隔离,不需要任何权限,也不是硬件序列号。
* 首次访问生成一次并落盘,之后不再变化(清除应用数据才会变)。
*/
val deviceId: String
get() {
val cached = prefs.getString(KEY_DEVICE_ID, null)
if (!cached.isNullOrBlank()) return cached
val raw = runCatching {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
}.getOrNull().orEmpty().ifBlank { "unknown" }
val digest = MessageDigest.getInstance("SHA-256")
.digest(("opendance:" + raw).toByteArray())
.joinToString("") { "%02x".format(it) }
.take(16)
prefs.edit { putString(KEY_DEVICE_ID, digest) }
return digest
}
companion object {
const val PREFS_NAME = "opendance_settings"
const val DEFAULT_HOST = "192.0.2.100"
@@ -67,6 +94,7 @@ class AppSettings(context: Context) {
private const val KEY_RESOLUTION = "resolution"
private const val KEY_DEVICE_LABEL = "device_label"
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
private const val KEY_DEVICE_ID = "device_id"
}
}
@@ -4,6 +4,7 @@ import android.os.Build
import android.util.Log
import com.opendance.studio.BuildConfig
import com.opendance.studio.model.KeypointFrame
import com.opendance.studio.util.ConnectionLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,36 +13,56 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.net.InetAddress
import java.net.URI
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.min
import kotlin.random.Random
/**
* T-AND-005:WebSocket 推流客户端(本端为**发送方**)
* T-AND-005 / T-AND-011:WebSocket 推流客户端(本端为**发送方**)
*
* 契约 contracts/websocket-keypoints.md:
* 契约 contracts/websocket-keypoints.md v0.2.0:
* - 端点 ws://<windows-host>:8765/pose,子协议 opendance.pose.v1
* - 二进制帧 = 239 字节关键点(见 [KeypointFrame])
* - 文本控制帧 hello / config / stats / bye
* - 文本控制帧 hello / welcome / config / stats / ping / pong / bye
*
* 关键设计:
* - 二进制帧走 `send(ByteString)`,控制帧走 `send(text)`
* - 自动重连采用指数退避(1s → 2s → 4s … 上限 15s),连接成功即重置
* - `sendFrame` 在非连接态直接丢弃并计数,绝不阻塞相机线程
* - 统计信息(发送帧数、丢弃帧数、FPS、RTT)由本类维护,供运行页展示
* T-AND-011 的修复要点("连接状态异常、频繁重连"):
* 1. **状态机与接收端同名**(契约 §8):idle → resolving → connecting →
* handshaking → streaming → degraded → reconnecting → stopped,
* 界面显示状态 + 最近一次断开原因(close code / reason)。
* 2. **保活**:接收端在 welcome/config 里声明 silentTimeoutMs 与
* heartbeatIntervalMs,本端按后者周期发应用层 `ping`。
* 协议层 ping(OkHttp 的 `pingInterval`)对接收端的应用层**不可见**,
* 不能用来续命 —— 这是原先"画面里没人就掉线"的根因之一。
* 3. **只有真正断开才重连**(契约 §7):主动停止 / 1000 / 1001 / 1002 一律不重连。
* 4. **退避与抖动**:0.5s→1→2→4→8→15s 封顶 + ±20% 抖动(契约 §7.1)。
* 5. **可诊断**:close code / 子协议协商结果 / 首帧时间写入 [ConnectionLog],
* 界面可一键导出。
*/
class PoseWebSocketClient(
private val scope: CoroutineScope,
private val subprotocol: String = BuildConfig.WS_SUBPROTOCOL,
) {
enum class State { IDLE, CONNECTING, CONNECTED, RECONNECTING, CLOSED }
/** 契约 §8 的连接状态(wireName 与接收端 GDScript 侧同名,便于对照日志) */
enum class State(val wireName: String) {
IDLE("idle"),
RESOLVING("resolving"),
CONNECTING("connecting"),
HANDSHAKING("handshaking"),
STREAMING("streaming"),
DEGRADED("degraded"),
RECONNECTING("reconnecting"),
STOPPED("stopped"),
}
data class Stats(
val state: State = State.IDLE,
@@ -52,15 +73,38 @@ class PoseWebSocketClient(
val sendFps: Float = 0f,
val roundTripMs: Long = -1,
val reconnectAttempts: Int = 0,
val nextRetryMs: Long = 0,
val lastError: String = "",
val serverSessionId: String = "",
)
val negotiatedProtocol: String = "",
/** 最近一次断开:close code 与原因(契约 §8 要求界面展示) */
val lastCloseCode: Int = 0,
val lastCloseReason: String = "",
val lastDisconnectAtMs: Long = 0,
/** 服务端声明的静默超时与期望心跳(契约 §1.1) */
val silentTimeoutMs: Long = ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS,
val heartbeatIntervalMs: Long =
ReconnectPolicy.heartbeatIntervalMs(ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS),
/** 本轮连接的首帧时间;0 表示还没发出过关键点帧 */
val firstFrameAtMs: Long = 0,
val pongsReceived: Int = 0,
) {
val lastCloseLabel: String
get() = if (lastCloseCode == 0 && lastCloseReason.isBlank()) {
""
} else {
ReconnectPolicy.closeCodeLabel(lastCloseCode) +
if (lastCloseReason.isNotBlank()) "($lastCloseReason)" else ""
}
}
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 长连接不设读超时
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
// 协议层 ping 仍然保留(对中间设备有用),但**不能**替代应用层 ping:
// 接收端的静默超时只看应用层收到的数据帧/文本帧。
.pingInterval(PROTOCOL_PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
@@ -69,21 +113,31 @@ class PoseWebSocketClient(
private var webSocket: WebSocket? = null
private var reconnectJob: Job? = null
private var heartbeatJob: Job? = null
private var url: String = ""
private var manualClose = false
private var autoReconnect = true
private val sentCounter = AtomicInteger(0)
private val droppedCounter = AtomicInteger(0)
private val reconnectCounter = AtomicInteger(0)
private val pongCounter = AtomicInteger(0)
private val frameIdCounter = AtomicInteger(0)
/** 首帧时间戳与计数,用于计算发送 FPS */
private var fpsWindowStart = 0L
private var fpsWindowFrames = 0
/** 上次收到 pong 的时刻,用于判定链路是否已死 */
@Volatile
private var lastPongAtMs = 0L
private val deviceLabel: String =
"${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
/** 稳定的设备标识(契约 §3.1),用于接收端去重;不含任何硬件序列号 */
var deviceId: String = ""
// ------------------------------------------------------------------ 连接
fun connect(targetUrl: String, autoReconnect: Boolean = true) {
@@ -91,16 +145,33 @@ class PoseWebSocketClient(
manualClose = false
this.autoReconnect = autoReconnect
reconnectCounter.set(0)
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 里的字段由设置填充,运行时由调用方覆盖 */
@@ -0,0 +1,103 @@
package com.opendance.studio.net
import kotlin.math.min
/**
* T-AND-011:重连策略(契约 websocket-keypoints.md v0.2.0 §7 / §7.1)
*
* 抽成纯函数对象的原因:这是"频繁重连"缺陷的核心逻辑,
* 必须能在 JVM 上(无需设备、无需网络)逐值核对,而不是靠真机观察。
*/
object ReconnectPolicy {
/** 契约 §7.1:首次退避 500ms */
const val BASE_DELAY_MS = 500L
/** 契约 §7.1:退避封顶 15s */
const val MAX_DELAY_MS = 15_000L
/** 契约 §7.1:±20% 抖动 */
const val JITTER_RATIO = 0.2
/**
* 第 [attempt] 次重连的基础等待(attempt 从 1 开始):
* `min(500ms * 2^(attempt-1), 15000ms)`
* 即 0.5s → 1s → 2s → 4s → 8s → 15s(封顶)。
* attempt <= 0 时返回 0(没有重连计划)。
*/
fun baseDelayMs(attempt: Int): Long {
if (attempt <= 0) return 0L
val shift = attempt - 1
// 500 << 5 = 16000 已超过封顶,再大的 attempt 一律封顶
if (shift >= 5) return MAX_DELAY_MS
return min(BASE_DELAY_MS shl shift, MAX_DELAY_MS)
}
/**
* 叠加抖动后的实际等待。
*
* @param randomFraction [0,1) 的随机数;由调用方注入,测试可传固定值确定化。
* factor = 1 - 0.2 + 0.4 * fraction ∈ [0.8, 1.2]
*/
fun delayWithJitterMs(attempt: Int, randomFraction: Double): Long {
val base = baseDelayMs(attempt)
if (base <= 0L) return 0L
val clamped = randomFraction.coerceIn(0.0, 1.0)
val factor = 1.0 - JITTER_RATIO + 2 * JITTER_RATIO * clamped
return (base * factor).toLong().coerceAtLeast(1L)
}
/**
* 契约 §7:**只有真正断开才重连**。
*
* | 关闭码 | 含义 | 是否重连 |
* |--|--|--|
* | 1000 | 任一方主动结束(含收到 bye) | 否 |
* | 1001 | 接收端主动关闭(服务重启 / 静默超时踢人) | 否 |
* | 1002 | 协议错误(重连也不会成功) | 否 |
* | 其他 / 0(未收到关闭帧)| 网络抖动、对端进程退出 | 是 |
*
* @param manualClose 用户主动「结束推流」;为 true 时任何情况都不重连
*/
fun shouldReconnect(closeCode: Int, manualClose: Boolean): Boolean {
if (manualClose) return false
return when (closeCode) {
1000, 1001, 1002 -> false
else -> true
}
}
/** 关闭码的中文说明(界面「最近一次断开原因」与日志共用) */
fun closeCodeLabel(code: Int): String = when (code) {
0 -> "未收到关闭帧(异常断开)"
1000 -> "正常关闭(主动结束)"
1001 -> "对端离开(服务重启 / 静默超时)"
1002 -> "协议错误(子协议或版本不匹配)"
1003 -> "对端不支持收到的数据类型"
1006 -> "连接异常断开(网络抖动)"
1011 -> "对端内部错误"
1012 -> "对端服务重启"
1013 -> "对端过载,请稍后重试"
else -> "关闭码 $code"
}
/**
* 保活间隔:契约 §1.1 要求小于静默超时的一半。
* 服务端未声明时用保守默认值(超时 5s、心跳 1s)。
*/
fun heartbeatIntervalMs(silentTimeoutMs: Long): Long {
val timeout = if (silentTimeoutMs > 0) silentTimeoutMs else DEFAULT_SILENT_TIMEOUT_MS
return (timeout / 3).coerceIn(MIN_HEARTBEAT_MS, MAX_HEARTBEAT_MS)
}
/** 契约 §1.1 的约束校验:心跳必须严格小于静默超时的一半 */
fun isHeartbeatSafe(heartbeatMs: Long, silentTimeoutMs: Long): Boolean =
silentTimeoutMs > 0 && heartbeatMs > 0 && heartbeatMs * 2 < silentTimeoutMs
const val DEFAULT_SILENT_TIMEOUT_MS = 5_000L
const val MIN_HEARTBEAT_MS = 1_000L
const val MAX_HEARTBEAT_MS = 5_000L
/** 连续多少个心跳周期没收到 pong 就判定链路已死(触发一次重连而不是干等) */
const val MISSED_PONG_LIMIT = 3
}
@@ -1,7 +1,9 @@
package com.opendance.studio.pose
import android.content.Context
import android.content.pm.ApplicationInfo
import android.graphics.Bitmap
import android.os.Build
import android.util.Log
import androidx.camera.core.ImageProxy
import com.google.mediapipe.framework.image.BitmapImageBuilder
@@ -13,15 +15,27 @@ import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarker
import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarkerResult
import com.opendance.studio.model.KeypointFrame
import com.opendance.studio.model.SkeletonTopology
import com.opendance.studio.util.ConnectionLog
import java.util.zip.ZipFile
/**
* T-AND-003:MediaPipe Pose 端侧推理
* T-AND-003 / T-AND-012:MediaPipe Pose 端侧推理
*
* 使用 LIVE_STREAM 模式:`detectAsync` 立即返回,结果通过监听器异步回调。
* 模型 `pose_landmarker_lite.task` 随 APK 分发(assets/,不压缩)。
*
* 输出 33 个归一化关键点,直接对齐契约 contracts/skeleton-topology.md §1
* 的索引顺序(MediaPipe Pose 的原始顺序即是契约顺序)。
*
* T-AND-012 的修复要点(`ExceptionInInitializerError: null`):
* 1. **先拿到真因**:ExceptionInInitializerError 的信息量全在 cause 链里
* (通常是 UnsatisfiedLinkError)。[PoseDiagnostics] 负责展开并给出
* ABI 维度的人话结论,而不是把 "null" 丢给用户;
* 2. **显式核对 ABI**:读自己 APK 的 lib/ 目录,和 Build.SUPPORTED_ABIS 比对,
* 直接指出"release 包装在 x86_64 模拟器"这种必崩组合;
* 3. **native 库加载方式**:release 由 build.gradle.kts 的
* `jniLibs.useLegacyPackaging = true` 保证走解压加载;
* 4. 失败时把诊断写入 [ConnectionLog],随"导出连接日志"一起回传。
*/
class PoseDetector(private val context: Context) {
@@ -38,6 +52,9 @@ class PoseDetector(private val context: Context) {
private var lastTimestampMs = 0L
private var busy = false
/** APK 内实际包含的 ABI 目录(懒加载,用于诊断) */
private val apkAbis: List<String> by lazy { readApkAbis() }
var onResult: ((PoseResult) -> Unit)? = null
var onError: ((String) -> Unit)? = null
@@ -83,12 +100,32 @@ class PoseDetector(private val context: Context) {
// 静态初始化失败会抛 ExceptionInInitializerError,它们都不是 Exception。
// 只 catch Exception 会让这类失败直接冒泡成整个应用崩溃。
Log.e(TAG, "PoseLandmarker 初始化失败", t)
onError?.invoke("MediaPipe 初始化失败:${t.javaClass.simpleName}: ${t.message}")
val report = PoseDiagnostics.describe(
t = t,
deviceAbis = Build.SUPPORTED_ABIS.toList(),
apkAbis = apkAbis,
)
Log.e(TAG, "初始化诊断:\n$report")
ConnectionLog.record("MediaPipe 初始化失败:\n$report")
onError?.invoke("MediaPipe 初始化失败:\n$report")
landmarker = null
false
}
}
/** 读自己 APK 的 lib/ 目录,得到实际打包进去的 ABI 列表 */
private fun readApkAbis(): List<String> = runCatching {
val info: ApplicationInfo = context.applicationInfo
ZipFile(info.sourceDir).use { zip ->
PoseDiagnostics.abisFromEntries(
zip.entries().asSequence().map { it.name }.toList(),
)
}
}.getOrElse {
Log.w(TAG, "读取 APK ABI 列表失败:${it.message}", it)
emptyList()
}
/**
* 提交一帧做异步推理;本方法不阻塞调用线程,且**不负责关闭 imageProxy**。
*
@@ -105,6 +142,9 @@ class PoseDetector(private val context: Context) {
val mpImage: MPImage = BitmapImageBuilder(rotated).build()
val safeTimestamp = if (timestampMs <= lastTimestampMs) lastTimestampMs + 1 else timestampMs
// 必须把时间戳记下来:handleResult 用它派生 frame_id 与结果时间戳。
// 原先漏了这一步,导致 frame_id 恒为 0,接收端无法按 frame_id 检测丢包。
lastTimestampMs = safeTimestamp
busy = true
detector.detectAsync(mpImage, safeTimestamp)
true
@@ -0,0 +1,122 @@
package com.opendance.studio.pose
/**
* T-AND-012:MediaPipe 初始化失败诊断
*
* `ExceptionInInitializerError: null` 这种报错**没有任何信息量**,
* 真因几乎总是藏在 `cause` 里的 `UnsatisfiedLinkError`(native 库加载失败)。
* 本对象只做纯字符串/列表层面的诊断,因此可以在 JVM 单测里逐条核对,
* 不必依赖真机。
*/
object PoseDiagnostics {
/** 展开完整因果链:`A: msg <- B: msg <- C: msg` */
fun causeChain(t: Throwable): String {
val parts = mutableListOf<String>()
var current: Throwable? = t
var guard = 0
while (current != null && guard < 10) {
val message = current.message
parts += if (message.isNullOrBlank()) {
current.javaClass.name
} else {
"${current.javaClass.name}: $message"
}
if (current.cause === current) break
current = current.cause
guard++
}
return parts.joinToString(" <- ")
}
/** 最内层真因 */
fun rootCause(t: Throwable): Throwable {
var current = t
var guard = 0
while (current.cause != null && current.cause !== current && guard < 10) {
current = current.cause!!
guard++
}
return current
}
/** 是否属于「native 库加载 / 静态初始化」这一类失败 */
fun isNativeLoadFailure(t: Throwable): Boolean {
var current: Throwable? = t
var guard = 0
while (current != null && guard < 10) {
when (current) {
is UnsatisfiedLinkError,
is ExceptionInInitializerError,
is NoClassDefFoundError,
-> return true
}
current = current.cause
guard++
}
return false
}
/**
* 从 APK 的 zip 条目里取出 ABI 目录集合。
* 输入形如 `["lib/arm64-v8a/libmediapipe_tasks_vision_jni.so", "assets/..."]`。
*/
fun abisFromEntries(entries: List<String>): List<String> =
entries.filter { it.startsWith("lib/") }
.map { it.removePrefix("lib/").substringBefore('/') }
.filter { it.isNotEmpty() }
.distinct()
.sorted()
/** 某个 ABI 下是否带 MediaPipe 的 native 库 */
fun hasMediaPipeNative(entries: List<String>, abi: String): Boolean =
entries.any { it.startsWith("lib/$abi/") && it.contains("mediapipe") }
/**
* 最典型的坑:在 **x86_64 模拟器**上安装了 release 包。
* release 只打包 arm64-v8a / armeabi-v7a(真机 ABI),模拟器必然缺 native 库。
*
* @param deviceAbis `Build.SUPPORTED_ABIS`
* @param apkAbis APK 内实际包含的 ABI 目录
* @return 需要提示时返回中文建议,否则返回 null
*/
fun abiMismatchHint(deviceAbis: List<String>, apkAbis: List<String>): String? {
if (deviceAbis.isEmpty() || apkAbis.isEmpty()) return null
if (deviceAbis.any { apkAbis.contains(it) }) return null
val isEmulatorAbi = deviceAbis.any { it == "x86_64" || it == "x86" }
val host = if (isEmulatorAbi) "模拟器(${deviceAbis.first()})" else "本机(${deviceAbis.joinToString()})"
return buildString {
append("APK 只包含 ${apkAbis.joinToString()},而 $host 需要 ")
append(deviceAbis.joinToString())
append(" 的 native 库 —— 这是 release 包在模拟器上必崩的根因。")
append("请在真机(arm64)上安装 release 包,或改用 debug 包在模拟器联调。")
}
}
/**
* 汇总成一条可读的中文诊断(写入界面与崩溃日志)。
*/
fun describe(
t: Throwable,
deviceAbis: List<String>,
apkAbis: List<String>,
): String {
val root = rootCause(t)
val segments = mutableListOf<String>()
segments += "${t.javaClass.simpleName}: ${t.message ?: "无消息"}"
if (root !== t) {
segments += "真因(cause 链最内层):${root.javaClass.name}: ${root.message ?: "无消息"}"
}
if (isNativeLoadFailure(t)) {
segments += "判定:native 库加载 / 类静态初始化失败(不是模型文件或相机问题)"
}
segments += "设备 ABI:${deviceAbis.joinToString().ifBlank { "未知" }}"
segments += "APK 内 ABI:${apkAbis.joinToString().ifBlank { "未知" }}"
abiMismatchHint(deviceAbis, apkAbis)?.let { segments += it }
if (root is UnsatisfiedLinkError) {
segments += "缺少的具体库:${root.message ?: "未提供库名"}"
}
return segments.joinToString("\n")
}
}
@@ -38,8 +38,13 @@ import com.opendance.studio.ui.theme.OpenDanceSurface
/**
* T-AND-008:关于页面 + 开源声明
* T-AND-010:关于清单展示当前版本号(文档 §11.1:Android App 在「关于」清单)
*
* 文档 §8.3 要求 Android App 与 Windows 客户端的「关于」页展示同款声明,可滚动查看。
*
* 版本号**唯一来源**是 `app/build.gradle.kts` 的 `versionName`,即
* [BuildConfig.VERSION_NAME](= project.yaml 的 version = APK 文件名 = Git tag);
* 契约版本来自构建期解析的 `contracts/VERSION`。界面代码里不出现任何版本号字面量。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -77,12 +82,18 @@ fun AboutScreen(onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("OpenDance 辅助端", style = MaterialTheme.typography.titleLarge)
Text("v${BuildConfig.VERSION_NAME}", color = OpenDanceMuted)
Text("v${Credits.appVersion}", color = OpenDanceMuted)
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
InfoRow("契约版本", BuildConfig.CONTRACT_VERSION)
InfoRow("客户端版本", "v${Credits.appVersion}")
InfoRow("版本号(整数)", BuildConfig.VERSION_CODE.toString())
// 六项契约各自独立递增,必须逐项展示(不能只显示一个版本)
Credits.contractVersions.forEach { (name, version) ->
InfoRow("契约 $name", "v$version")
}
InfoRow("WebSocket 子协议", BuildConfig.WS_SUBPROTOCOL)
InfoRow("关键点拓扑", "MediaPipe Pose 33 点")
InfoRow("传输帧长", "239 字节")
InfoRow("保活", "应用层 ping,静默超时由服务端声明")
}
}
@@ -103,6 +114,12 @@ fun AboutScreen(onBack: () -> Unit) {
color = OpenDanceMuted,
style = MaterialTheme.typography.bodySmall,
)
Text(
"关键点只发给同一局域网内的 Windows 客户端;连接日志保存在应用私有目录," +
"需要时由「运行页 -> 导出连接日志」主动分享,不会自动上传。",
color = OpenDanceMuted,
style = MaterialTheme.typography.bodySmall,
)
}
}
@@ -1,15 +1,40 @@
package com.opendance.studio.ui
import com.opendance.studio.BuildConfig
/**
* T-AND-008:开源声明数据
* T-AND-008 / T-AND-010:开源声明与版本信息
*
* 与 contracts/open-source-credits.md(v0.1.0)逐条对应,
* 并与 opendance-web 的 THIRD_PARTY.md、opendance-windows 的关于页保持一致。
* 该表是**唯一来源**,新增依赖须先回到 opendance-web 更新契约。
* 与 contracts/open-source-credits.md 逐条对应,并与 opendance-web 的
* THIRD_PARTY.md、opendance-windows 的关于页保持一致。
* 依赖表是**唯一来源**,新增依赖须先回到 opendance-web 更新契约。
*
* 版本号**不在本文件硬编码**(文档 §11.2 第 1 条):
* - 应用版本 -> [BuildConfig.VERSION_NAME](来自 versionName,与 project.yaml 同源)
* - 契约版本 -> 构建期从 contracts/VERSION 解析后注入的
* [BuildConfig.CONTRACT_VERSION_KEYS] / [BuildConfig.CONTRACT_VERSION_VALUES]
* 这样"关于"页展示的一定是「当前正在跑的版本」,而不是某次手写的静态文案。
*/
object Credits {
const val CONTRACT_VERSION = "0.1.0"
/**
* 兼容旧调用点:websocket-keypoints 契约版本(本端为发送方,最相关的一项)。
* 新代码请用 [contractVersions] 展示全部契约。
*/
val CONTRACT_VERSION: String = BuildConfig.CONTRACT_VERSION
/** 六项契约的 (名称, 版本) 列表,顺序与 contracts/VERSION 一致 */
val contractVersions: List<Pair<String, String>>
get() = BuildConfig.CONTRACT_VERSION_KEYS
.zip(BuildConfig.CONTRACT_VERSION_VALUES)
.toList()
/** 应用版本(唯一来源:versionName) */
val appVersion: String get() = BuildConfig.VERSION_NAME
/** 「清单」用的一行摘要:`rest-api v0.5.0 | websocket-keypoints v0.2.0 | …` */
fun contractSummary(): String =
contractVersions.joinToString(" | ") { (name, version) -> "$name v$version" }
data class Entry(
val name: String,
@@ -44,6 +44,7 @@ import com.opendance.studio.camera.CameraController
import com.opendance.studio.data.AppSettings
import com.opendance.studio.net.PoseWebSocketClient
import com.opendance.studio.pose.PoseDetector
import com.opendance.studio.util.ConnectionLog
import com.opendance.studio.ui.theme.OpenDanceGreen
import com.opendance.studio.ui.theme.OpenDanceMuted
import com.opendance.studio.ui.theme.OpenDanceRed
@@ -69,6 +70,9 @@ import kotlinx.coroutines.launch
* 1. 相机权限用平台 Activity Result API,不引入 accompanist(避免 Compose 版本偏斜)
* 2. MediaPipe 回调发生在相机分析线程 —— 所有 Compose 状态写入都 post 回主线程
* 3. 初始化/连接失败只记录并提示,绝不让异常冒泡成崩溃
*
* T-AND-011:连接状态按契约 §8 展示(含 close code / 断开原因 / 重试倒计时),
* 并提供「导出连接日志」(契约 §8 的"可诊断"要求)。
*/
private const val TAG = "RunningScreen"
@@ -102,9 +106,15 @@ fun RunningScreen(
var errorMessage by remember { mutableStateOf("") }
var cameraError by remember { mutableStateOf("") }
var fatalError by remember { mutableStateOf("") }
var logHint by remember { mutableStateOf("") }
val wsStats = remember { PoseWebSocketClient(scope) }
val stats by wsStats.stats.collectAsState()
// 契约 §3.1:稳定的设备标识,供接收端按 deviceId 去重
LaunchedEffect(Unit) {
wsStats.deviceId = runCatching { settings.deviceId }.getOrDefault("")
ConnectionLog.record("deviceId=${wsStats.deviceId.ifBlank { "(空)" }}")
}
val cameraController = remember { CameraController(context) }
val poseDetector = remember { PoseDetector(context) }
@@ -290,15 +300,32 @@ fun RunningScreen(
) {
Text("连接状态", color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
Text(
text = stateLabel(stats.state, stats.reconnectAttempts),
text = stateLabel(stats.state, stats.reconnectAttempts, stats.nextRetryMs),
color = stateColor(stats.state),
style = MaterialTheme.typography.bodySmall,
)
}
InfoLine("状态机", stats.state.wireName)
InfoLine("目标", stats.url)
if (stats.serverSessionId.isNotBlank()) {
InfoLine("会话", stats.serverSessionId)
}
if (stats.negotiatedProtocol.isNotBlank()) {
InfoLine("协商子协议", stats.negotiatedProtocol)
}
if (stats.lastCloseLabel.isNotBlank()) {
InfoLine("最近断开", stats.lastCloseLabel, OpenDanceRed)
}
if (stats.roundTripMs >= 0) {
InfoLine("心跳 RTT", "${stats.roundTripMs} ms")
}
InfoLine(
"保活",
"心跳 ${stats.heartbeatIntervalMs} ms / 静默超时 ${stats.silentTimeoutMs} ms",
)
if (stats.firstFrameAtMs > 0) {
InfoLine("首帧", "已发出")
}
InfoLine("已发送", "${stats.sentFrames} 帧")
InfoLine(
"已丢弃",
@@ -332,6 +359,9 @@ fun RunningScreen(
if (cameraError.isNotBlank()) {
InfoLine("相机错误", cameraError, OpenDanceRed)
}
if (logHint.isNotBlank()) {
InfoLine("日志", logHint, OpenDanceGreen)
}
}
}
}
@@ -358,6 +388,19 @@ fun RunningScreen(
modifier = Modifier.weight(1f),
) { Text("切换摄像头") }
OutlinedButton(
onClick = {
// 契约 §8「可诊断」:一键导出连接日志(close code / 子协议 / 首帧时间)
val file = ConnectionLog.share(context)
logHint = if (file != null) {
"连接日志已导出(${ConnectionLog.size()} 条):${file.name}"
} else {
"连接日志导出失败"
}
},
modifier = Modifier.weight(1f),
) { Text("导出连接日志") }
Button(
onClick = {
scope.launch {
@@ -386,17 +429,32 @@ private fun InfoLine(label: String, value: String, valueColor: Color = Color.Uns
}
}
private fun stateLabel(state: PoseWebSocketClient.State, attempts: Int): String = when (state) {
/**
* 契约 §8 的状态显示文案。状态名与接收端一致(wireName),
* 这里只是把它翻译成用户能读的中文 + 重试倒计时。
*/
private fun stateLabel(
state: PoseWebSocketClient.State,
attempts: Int,
nextRetryMs: Long,
): String = when (state) {
PoseWebSocketClient.State.IDLE -> "未连接"
PoseWebSocketClient.State.RESOLVING -> "解析主机地址…"
PoseWebSocketClient.State.CONNECTING -> "连接中…"
PoseWebSocketClient.State.CONNECTED -> "已连接"
PoseWebSocketClient.State.RECONNECTING -> "重连中(第 $attempts 次)"
PoseWebSocketClient.State.CLOSED -> "已断开"
PoseWebSocketClient.State.HANDSHAKING -> "握手中…"
PoseWebSocketClient.State.STREAMING -> "推流中"
PoseWebSocketClient.State.DEGRADED -> "推流中(链路异常,已丢帧)"
PoseWebSocketClient.State.RECONNECTING ->
"重连中(第 $attempts 次,${nextRetryMs} ms 后重试)"
PoseWebSocketClient.State.STOPPED -> "已停止"
}
private fun stateColor(state: PoseWebSocketClient.State): Color = when (state) {
PoseWebSocketClient.State.CONNECTED -> OpenDanceGreen
PoseWebSocketClient.State.STREAMING -> OpenDanceGreen
PoseWebSocketClient.State.DEGRADED -> OpenDanceYellow
PoseWebSocketClient.State.RESOLVING,
PoseWebSocketClient.State.CONNECTING,
PoseWebSocketClient.State.HANDSHAKING,
PoseWebSocketClient.State.RECONNECTING,
-> OpenDanceYellow
else -> OpenDanceRed
@@ -0,0 +1,104 @@
package com.opendance.studio.util
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedDeque
/**
* T-AND-011:连接日志(契约 §8「可诊断」)
*
* 记录连接状态迁移、close code / reason、子协议协商结果、首帧时间等关键事件。
* 只保留最近 [MAX_ENTRIES] 条,避免长时间运行占用内存;可一键导出为文本文件
* 并通过系统分享面板回传(真机问题基本只能靠这份日志定位)。
*
* 这是**进程内单例**:连接客户端与界面都通过它记录/读取,保证时序一致。
* 不使用 AndroidX Startup 之类的初始化框架,显式 [install] 即可。
*/
object ConnectionLog {
private const val MAX_ENTRIES = 500
private const val TAG = "ConnectionLog"
private const val FILE_NAME = "connection-log.txt"
private val entries = ConcurrentLinkedDeque<String>()
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
@Volatile
private var startedAt: Long = 0L
/** 记录一条事件(线程安全;可由 OkHttp 回调线程直接调用) */
fun record(event: String) {
if (startedAt == 0L) startedAt = System.currentTimeMillis()
val stamp = timeFormat.format(Date())
entries.addLast("[$stamp] $event")
while (entries.size > MAX_ENTRIES) {
entries.pollFirst()
}
}
fun entries(): List<String> = entries.toList()
fun size(): Int = entries.size
fun clear() {
entries.clear()
startedAt = 0L
}
/** 导出的完整文本:带设备 / 应用 / ABI 头部,便于直接贴进 issue */
fun toText(context: Context): String = buildString {
appendLine("OpenDance Studio 辅助端 —— 连接日志")
appendLine("应用版本:${context.packageName} ${appVersion(context)}")
appendLine("设备:${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
appendLine("Android:${android.os.Build.VERSION.RELEASE}(API ${android.os.Build.VERSION.SDK_INT})")
appendLine("ABI:${android.os.Build.SUPPORTED_ABIS.joinToString()}")
appendLine("导出时间:${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())}")
appendLine("记录条数:${entries.size}")
appendLine("----------------------------------------")
entries.forEach { appendLine(it) }
}
private fun appVersion(context: Context): String = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?"
}.getOrDefault("?")
/** 写入 filesDir/connection-log.txt 并返回文件 */
fun exportToFile(context: Context): File {
val file = File(context.filesDir, FILE_NAME)
file.writeText(toText(context))
return file
}
/**
* 导出并弹出系统分享面板;没有可用分享目标时返回 null(调用方提示路径即可)。
*/
fun share(context: Context): File? {
val file = exportToFile(context)
val uri = runCatching {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}.getOrElse {
android.util.Log.w(TAG, "无法生成 content:// URI(缺少 FileProvider 配置?)", it)
return file
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "OpenDance 连接日志")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "导出连接日志")
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return runCatching {
context.startActivity(chooser)
file
}.getOrElse {
android.util.Log.w(TAG, "没有可用的分享目标,日志已保存到 ${file.absolutePath}", it)
file
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
T-AND-011:FileProvider 可分享的路径。
只暴露 filesDir 根目录(连接日志写在 filesDir/connection-log.txt),
不暴露缓存目录与外部存储。
-->
<paths>
<files-path name="opendance_files" path="." />
</paths>
@@ -71,6 +71,78 @@ class CreditsTest {
@Test
fun contractVersionIsPinned() {
assertEquals("契约版本必须与 opendance-web v0.1.0 对齐", "0.1.0", Credits.CONTRACT_VERSION)
// 本端是 websocket-keypoints 的发送方,这项必须与契约快照一致
assertEquals(
"websocket-keypoints 契约版本必须与 opendance-web 冻结版本一致",
"0.2.0",
Credits.CONTRACT_VERSION,
)
}
@Test
fun everyContractVersionMatchesTheSnapshot() {
// 契约版本是"单一来源":contracts/VERSION 决定一切,界面不得手写
val snapshot = readContractVersionFile()
assertTrue("能读到 contracts/VERSION", snapshot.isNotEmpty())
assertEquals(
"契约项数与 contracts/VERSION 一致(新增契约时忘记同步会导致这里失败)",
snapshot.size,
Credits.contractVersions.size,
)
for ((name, version) in Credits.contractVersions) {
assertEquals(
"契约 $name 的版本与快照一致",
snapshot[name] ?: "",
version,
)
}
}
@Test
fun appVersionMatchesProjectYaml() {
// 文档 §11.2:界面展示的版本必须与 project.yaml / 产物文件名同源
val yaml = readProjectYamlVersion()
assertTrue("能读到 project.yaml 的 version", yaml.isNotEmpty())
assertEquals("BuildConfig.VERSION_NAME 必须等于 project.yaml 的 version", yaml, Credits.appVersion)
assertEquals("Credits.appVersion 与 BuildConfig 同源(不得手写)", BuildConfig.VERSION_NAME, Credits.appVersion)
}
@Test
fun contractSummaryListsAllContracts() {
val summary = Credits.contractSummary()
for ((name, version) in Credits.contractVersions) {
assertTrue("摘要含 $name", summary.contains(name))
assertTrue("摘要含 $name 的版本 v$version", summary.contains("v$version"))
}
}
// ------------------------------------------------------------ 文件读取
/** 从模块目录逐级向上找仓库根(含 contracts/VERSION 的那一级) */
private fun repoFile(relative: String): java.io.File? {
var dir: java.io.File = java.io.File(System.getProperty("user.dir")).absoluteFile
repeat(6) {
val candidate = java.io.File(dir, relative)
if (candidate.exists()) return candidate
dir = dir.parentFile ?: return null
}
return null
}
private fun readContractVersionFile(): Map<String, String> {
val file = repoFile("contracts/VERSION") ?: return emptyMap()
return file.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains(":") }
.associate { it.substringBefore(":").trim() to it.substringAfter(":").trim() }
}
private fun readProjectYamlVersion(): String {
val file = repoFile("project.yaml") ?: return ""
return file.readLines()
.firstOrNull { it.trimStart().startsWith("version:") }
?.substringAfter(":")
?.trim()
?: ""
}
}
@@ -0,0 +1,177 @@
package com.opendance.studio
import com.opendance.studio.pose.PoseDiagnostics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* T-AND-012:MediaPipe 初始化失败诊断
*
* `ExceptionInInitializerError: null` 本身没有信息量,真因在 cause 链里。
* 这里逐条核对"能不能把真因翻出来、能不能给出 ABI 维度的人话结论"。
*/
class PoseDiagnosticsTest {
// ------------------------------------------------------------ cause 链
@Test
fun causeChainUnwrapsInitializerError() {
val link = UnsatisfiedLinkError("dlopen failed: library \"libmediapipe_tasks_vision_jni.so\" not found")
val outer = ExceptionInInitializerError(link)
val chain = PoseDiagnostics.causeChain(outer)
assertTrue("链里含外层异常类名", chain.contains("ExceptionInInitializerError"))
assertTrue("链里含真因类名", chain.contains("UnsatisfiedLinkError"))
assertTrue("链里含缺失的库名", chain.contains("libmediapipe_tasks_vision_jni.so"))
assertSame("rootCause 应返回最内层真因", link, PoseDiagnostics.rootCause(outer))
}
@Test
fun causeChainTerminatesOnCyclicCauses() {
// 手工 setCause 过的第三方库可能形成环(A -> B -> A);处理不当会死循环。
// 注意 JVM 禁止自引用(t.initCause(t) 直接抛 IllegalArgumentException),
// 因此这里构造的是两节点环。
val a = RuntimeException("A")
val b = RuntimeException("B")
a.initCause(b)
b.initCause(a)
val chain = PoseDiagnostics.causeChain(a)
assertTrue("环上的异常都出现在链里", chain.contains("A") && chain.contains("B"))
assertTrue("环不会导致死循环(有长度上限)", chain.length < 1000)
val root = PoseDiagnostics.rootCause(a)
assertTrue("rootCause 在环上收敛(不抛错)", root === a || root === b)
}
@Test
fun causeChainHandlesNullMessage() {
// message 为 null 时仍要给出类名,否则用户只看到 "null"
val chain = PoseDiagnostics.causeChain(ExceptionInInitializerError(RuntimeException()))
assertTrue("消息为 null 时仍输出外层类名", chain.contains("ExceptionInInitializerError"))
assertTrue("消息为 null 时仍输出真因类名", chain.contains("RuntimeException"))
}
@Test
fun nativeLoadFailureIsDetectedThroughTheChain() {
assertTrue(
"ExceptionInInitializerError(cause=UnsatisfiedLinkError) 属于 native 加载失败",
PoseDiagnostics.isNativeLoadFailure(
ExceptionInInitializerError(UnsatisfiedLinkError("dlopen failed")),
),
)
assertTrue("直接的 UnsatisfiedLinkError 属于 native 加载失败", PoseDiagnostics.isNativeLoadFailure(UnsatisfiedLinkError("x")))
assertTrue("NoClassDefFoundError 也归入该类", PoseDiagnostics.isNativeLoadFailure(NoClassDefFoundError("x")))
assertFalse("普通业务异常不算 native 加载失败", PoseDiagnostics.isNativeLoadFailure(IllegalStateException("模型文件缺失")))
}
// ------------------------------------------------------------ ABI
@Test
fun abisAreExtractedFromApkEntries() {
val entries = listOf(
"lib/arm64-v8a/libmediapipe_tasks_vision_jni.so",
"lib/arm64-v8a/libopencv_java4.so",
"lib/armeabi-v7a/libmediapipe_tasks_vision_jni.so",
"assets/pose_landmarker_lite.task",
"classes.dex",
"META-INF/MANIFEST.MF",
)
assertEquals(
"只取 lib/<abi>/ 下的目录名并去重排序",
listOf("arm64-v8a", "armeabi-v7a"),
PoseDiagnostics.abisFromEntries(entries),
)
assertTrue("arm64-v8a 含 mediapipe native 库", PoseDiagnostics.hasMediaPipeNative(entries, "arm64-v8a"))
assertFalse("x86_64 没有 mediapipe native 库", PoseDiagnostics.hasMediaPipeNative(entries, "x86_64"))
}
@Test
fun abisAreEmptyWhenApkHasNoNativeLibs() {
assertEquals(
"没有 lib/ 条目时返回空列表",
emptyList<String>(),
PoseDiagnostics.abisFromEntries(listOf("classes.dex", "assets/x.task")),
)
}
@Test
fun releaseApkOnX86EmulatorIsDiagnosed() {
// 这是"debug 好用、release 崩"的根因:release 只打包真机 ABI
val hint = PoseDiagnostics.abiMismatchHint(
deviceAbis = listOf("x86_64", "x86"),
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
)
assertNotNull("x86_64 模拟器 + 仅 ARM 的 APK 必须给出提示", hint)
assertTrue("提示里要说清 APK 实际包含的 ABI", hint!!.contains("arm64-v8a"))
assertTrue("提示里要说清设备需要的 ABI", hint.contains("x86_64"))
assertTrue("提示里要给出可执行建议", hint.contains("真机") || hint.contains("debug"))
}
@Test
fun arm64DeviceWithArmApkIsNotFlagged() {
assertNull(
"真机 arm64 + ARM APK 正常,不应提示",
PoseDiagnostics.abiMismatchHint(
deviceAbis = listOf("arm64-v8a", "armeabi-v7a"),
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
),
)
}
@Test
fun arm64DeviceCanStillUseApkThatAlsoShipsArm32() {
assertNull(
"设备支持多个 ABI 时只要有一个命中即可",
PoseDiagnostics.abiMismatchHint(
deviceAbis = listOf("arm64-v8a"),
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
),
)
}
@Test
fun unknownAbisAreNotFlagged() {
assertNull(
"信息缺失时不应误报",
PoseDiagnostics.abiMismatchHint(emptyList(), listOf("arm64-v8a")),
)
assertNull(
"APK 无 native 库时不误报(由别的检查负责)",
PoseDiagnostics.abiMismatchHint(listOf("arm64-v8a"), emptyList()),
)
}
// ------------------------------------------------------------ 汇总报告
@Test
fun describeProducesActionableReport() {
val error = ExceptionInInitializerError(UnsatisfiedLinkError("dlopen failed: library \"libx.so\" not found"))
val report = PoseDiagnostics.describe(
t = error,
deviceAbis = listOf("x86_64"),
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
)
assertTrue("报告含原始异常", report.contains("ExceptionInInitializerError"))
assertTrue("报告含真因", report.contains("UnsatisfiedLinkError"))
assertTrue("报告含判定结论", report.contains("native 库加载"))
assertTrue("报告含设备 ABI", report.contains("x86_64"))
assertTrue("报告含 APK ABI", report.contains("arm64-v8a"))
assertTrue("报告给出可执行建议", report.contains("真机") || report.contains("debug"))
}
@Test
fun describeOnOrdinaryFailureDoesNotClaimNativeProblem() {
val report = PoseDiagnostics.describe(
t = IllegalStateException("模型文件 pose_landmarker_lite.task 缺失"),
deviceAbis = listOf("arm64-v8a"),
apkAbis = listOf("arm64-v8a"),
)
assertFalse("普通异常不应被判定为 native 失败", report.contains("native 库加载"))
assertTrue("仍然带上设备与 APK 的 ABI 供排查", report.contains("arm64-v8a"))
}
}
@@ -0,0 +1,128 @@
package com.opendance.studio
import com.opendance.studio.net.ReconnectPolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* T-AND-011:重连策略(契约 websocket-keypoints.md v0.2.0 §7 / §7.1)
*
* 这是"频繁重连"缺陷的核心逻辑,必须逐值核对:
* 1. 退避序列 0.5s → 1 → 2 → 4 → 8 → 15s(封顶)+ ±20% 抖动;
* 2. 只有真正断开才重连(1000 / 1001 / 1002 与主动停止都不重连);
* 3. 心跳间隔必须严格小于静默超时的一半(契约 §1.1)。
*/
class ReconnectPolicyTest {
@Test
fun backoffSequenceMatchesContract() {
assertEquals("第 1 次重连等待 500ms", 500L, ReconnectPolicy.baseDelayMs(1))
assertEquals("第 2 次重连等待 1s", 1_000L, ReconnectPolicy.baseDelayMs(2))
assertEquals("第 3 次重连等待 2s", 2_000L, ReconnectPolicy.baseDelayMs(3))
assertEquals("第 4 次重连等待 4s", 4_000L, ReconnectPolicy.baseDelayMs(4))
assertEquals("第 5 次重连等待 8s", 8_000L, ReconnectPolicy.baseDelayMs(5))
}
@Test
fun backoffIsCappedAt15Seconds() {
assertEquals("第 6 次起封顶 15s(500*2^5=16s 被截断)", 15_000L, ReconnectPolicy.baseDelayMs(6))
assertEquals("第 7 次仍封顶", 15_000L, ReconnectPolicy.baseDelayMs(7))
assertEquals("很大的次数也不溢出", 15_000L, ReconnectPolicy.baseDelayMs(64))
assertEquals("非法次数返回 0", 0L, ReconnectPolicy.baseDelayMs(0))
assertEquals("负数次数返回 0", 0L, ReconnectPolicy.baseDelayMs(-3))
}
@Test
fun jitterStaysWithinTwentyPercent() {
// randomFraction 0.0 -> 下界 -20%;1.0 -> 上界 +20%
assertEquals("抖动下界为 -20%", 400L, ReconnectPolicy.delayWithJitterMs(1, 0.0))
assertEquals("抖动中点为基数", 500L, ReconnectPolicy.delayWithJitterMs(1, 0.5))
assertEquals("抖动上界为 +20%", 600L, ReconnectPolicy.delayWithJitterMs(1, 1.0))
// 越界的随机数必须被夹紧,不能放大到任意倍
assertEquals("越界随机数夹紧到下界", 400L, ReconnectPolicy.delayWithJitterMs(1, -5.0))
assertEquals("越界随机数夹紧到上界", 600L, ReconnectPolicy.delayWithJitterMs(1, 9.0))
assertEquals("无重连计划时不抖动", 0L, ReconnectPolicy.delayWithJitterMs(0, 0.5))
}
@Test
fun jitterIsAlwaysPositiveAcrossTheWholeRange() {
for (attempt in 1..8) {
for (step in 0..100) {
val delay = ReconnectPolicy.delayWithJitterMs(attempt, step / 100.0)
assertTrue("等待时间必须为正(attempt=$attempt,step=$step)", delay > 0)
}
}
}
@Test
fun intentionalClosesDoNotReconnect() {
// 契约 §7:主动结束 / 1000 / 1001 / 1002 一律不重连
assertFalse("1000 正常关闭不重连", ReconnectPolicy.shouldReconnect(1000, manualClose = false))
assertFalse("1001 对端离开不重连", ReconnectPolicy.shouldReconnect(1001, manualClose = false))
assertFalse("1002 协议错误不重连", ReconnectPolicy.shouldReconnect(1002, manualClose = false))
assertFalse("主动停止不重连", ReconnectPolicy.shouldReconnect(1006, manualClose = true))
assertFalse("主动停止(1000)不重连", ReconnectPolicy.shouldReconnect(1000, manualClose = true))
}
@Test
fun abnormalClosesDoReconnect() {
// 网络抖动 / 未收到关闭帧 / 对端崩溃 -> 重连
assertTrue("1006 异常断开要重连", ReconnectPolicy.shouldReconnect(1006, manualClose = false))
assertTrue("未收到关闭帧(0)要重连", ReconnectPolicy.shouldReconnect(0, manualClose = false))
assertTrue("1011 对端内部错误要重连", ReconnectPolicy.shouldReconnect(1011, manualClose = false))
assertTrue("1012 对端重启要重连", ReconnectPolicy.shouldReconnect(1012, manualClose = false))
assertTrue("1013 对端过载要重连", ReconnectPolicy.shouldReconnect(1013, manualClose = false))
}
@Test
fun closeCodeLabelsAreHumanReadable() {
assertTrue(ReconnectPolicy.closeCodeLabel(1001).contains("对端离开"))
assertTrue(ReconnectPolicy.closeCodeLabel(1002).contains("协议错误"))
assertTrue(ReconnectPolicy.closeCodeLabel(0).contains("未收到关闭帧"))
assertTrue(ReconnectPolicy.closeCodeLabel(4321).contains("4321"))
}
@Test
fun heartbeatIsAlwaysFasterThanHalfTheSilentTimeout() {
// 契约 §1.1:heartbeatIntervalMs 必须小于 silentTimeoutMs 的一半
val declared = listOf(5_000L, 10_000L, 15_000L, 30_000L)
for (timeout in declared) {
val heartbeat = ReconnectPolicy.heartbeatIntervalMs(timeout)
assertTrue(
"超时 ${timeout}ms 时心跳 ${heartbeat}ms 必须安全",
ReconnectPolicy.isHeartbeatSafe(heartbeat, timeout),
)
}
}
@Test
fun heartbeatFallsBackToConservativeDefaults() {
// 旧版服务端(契约 v0.1.0)不在 welcome 里声明超时 -> 用保守默认值
val heartbeat = ReconnectPolicy.heartbeatIntervalMs(0)
assertEquals(
"未声明超时时按默认 5s 的三分之一计算",
ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS / 3,
heartbeat,
)
assertTrue(
"默认心跳对默认静默超时(5s)必须安全",
ReconnectPolicy.isHeartbeatSafe(heartbeat, ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS),
)
}
@Test
fun heartbeatIsClampedIntoReasonableRange() {
assertEquals("极小超时也不低于下限", ReconnectPolicy.MIN_HEARTBEAT_MS, ReconnectPolicy.heartbeatIntervalMs(10))
assertEquals("极大超时不超过上限", ReconnectPolicy.MAX_HEARTBEAT_MS, ReconnectPolicy.heartbeatIntervalMs(600_000))
}
@Test
fun heartbeatSafetyRejectsUnsafeCombinations() {
assertFalse("未声明超时视为不安全", ReconnectPolicy.isHeartbeatSafe(1_000, 0))
assertFalse("心跳等于超时的一半不安全(必须严格小于)", ReconnectPolicy.isHeartbeatSafe(5_000, 10_000))
assertFalse("心跳大于超时的一半不安全", ReconnectPolicy.isHeartbeatSafe(6_000, 10_000))
assertTrue("心跳小于超时的一半才安全", ReconnectPolicy.isHeartbeatSafe(4_000, 10_000))
}
}