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:
+141
-6
@@ -1,9 +1,16 @@
|
||||
import java.util.Properties
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
// OpenDance Studio —— Android 辅助 App(应用模块)
|
||||
//
|
||||
// 契约:contracts/websocket-keypoints.md v0.1.0(本端为**发送方**)
|
||||
// contracts/skeleton-topology.md v0.1.0(MediaPipe 33 点)
|
||||
// 契约:contracts/websocket-keypoints.md v0.2.0(本端为**发送方**)
|
||||
// contracts/skeleton-topology.md v0.2.0(MediaPipe 33 点)
|
||||
//
|
||||
// 版本号与契约版本的**单一来源**(文档 §11.2 第 1 条):
|
||||
// - 应用版本 -> app/build.gradle.kts 的 versionName(= 本次要发布的版本)
|
||||
// - 契约版本 -> contracts/VERSION(构建期解析后注入 BuildConfig)
|
||||
// 两者都不允许在 Kotlin 界面代码里再手写一份;文件末尾的
|
||||
// `checkReleaseConsistency` 任务会在打包前校验与 project.yaml 一致。
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
@@ -19,13 +26,49 @@ android {
|
||||
applicationId = "com.opendance.studio"
|
||||
minSdk = 29 // Android 10(文档 T-AND-009 验收要求 Android 10+)
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
|
||||
// versionName 用 X.Y.Z,两者同一次发布一起更新。
|
||||
versionCode = 200
|
||||
versionName = "0.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// 契约版本,供关于页与日志显示
|
||||
buildConfigField("String", "CONTRACT_VERSION", "\"0.1.0\"")
|
||||
buildConfigField("String", "WS_SUBPROTOCOL", "\"opendance.pose.v1\"")
|
||||
|
||||
// ---- 契约版本:从 contracts/VERSION 解析,作为唯一来源注入 BuildConfig ----
|
||||
// 六项契约各自独立递增(rest-api 0.5.0 时 skeleton-topology 可能还是 0.2.0),
|
||||
// 因此注入每一项 + 一个用于展示的汇总串,界面不再手写任何版本号。
|
||||
val contractVersions = linkedMapOf<String, String>()
|
||||
val versionFile = rootProject.file("contracts/VERSION")
|
||||
versionFile.readLines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains(":") }
|
||||
.forEach { line ->
|
||||
val key = line.substringBefore(":").trim()
|
||||
val value = line.substringAfter(":").trim()
|
||||
if (key.isNotEmpty() && value.isNotEmpty()) contractVersions[key] = value
|
||||
}
|
||||
check(contractVersions.isNotEmpty()) {
|
||||
"无法从 ${versionFile.path} 解析契约版本:请确认该文件存在且格式为 `key: value`"
|
||||
}
|
||||
contractVersions.forEach { (key, value) ->
|
||||
val field = "CONTRACT_" + key.uppercase().replace('-', '_')
|
||||
buildConfigField("String", field, "\"$value\"")
|
||||
}
|
||||
buildConfigField(
|
||||
"String",
|
||||
"CONTRACT_VERSION",
|
||||
"\"" + contractVersions["websocket-keypoints"] + "\"",
|
||||
)
|
||||
buildConfigField(
|
||||
"String[]",
|
||||
"CONTRACT_VERSION_KEYS",
|
||||
contractVersions.keys.joinToString(", ") { "\"$it\"" }.let { "new String[]{$it}" },
|
||||
)
|
||||
buildConfigField(
|
||||
"String[]",
|
||||
"CONTRACT_VERSION_VALUES",
|
||||
contractVersions.values.joinToString(", ") { "\"$it\"" }.let { "new String[]{$it}" },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +162,14 @@ android {
|
||||
androidResources {
|
||||
noCompress += "task"
|
||||
}
|
||||
|
||||
// T-AND-012:release 默认 extractNativeLibs=false(native 库直接从 APK mmap),
|
||||
// MediaPipe 的 JNI 加载对这一点有历史兼容问题,会表现为
|
||||
// ExceptionInInitializerError / UnsatisfiedLinkError。
|
||||
// 显式使用传统打包(等价 android:extractNativeLibs="true")。
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -190,3 +241,87 @@ dependencies {
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T-AND-010 / T-AND-012:发布前自检
|
||||
//
|
||||
// 1) 版本号与 project.yaml 一致(文档 §11.2:产物名 / tag / README / 界面必须同源);
|
||||
// 2) release APK 的 ABI 必须与 abiFilters 一致,且每个 ABI 目录下都要有
|
||||
// MediaPipe 的 native 库 —— 这正是 "debug 好用、release 崩" 的典型根因
|
||||
// (在 x86_64 模拟器上装 release 包必然缺库)。
|
||||
// ============================================================================
|
||||
|
||||
/** 从 project.yaml 取 `version:` */
|
||||
fun projectYamlVersion(): String {
|
||||
val file = rootProject.file("project.yaml")
|
||||
if (!file.exists()) return ""
|
||||
return file.readLines()
|
||||
.firstOrNull { it.trimStart().startsWith("version:") }
|
||||
?.substringAfter(":")
|
||||
?.trim()
|
||||
?: ""
|
||||
}
|
||||
|
||||
// 每次构建都先做版本/契约一致性校验,避免版本漂移进入产物
|
||||
tasks.matching { it.name == "preBuild" }.configureEach { dependsOn("checkReleaseConsistency") }
|
||||
|
||||
tasks.register("checkReleaseConsistency") {
|
||||
group = "verification"
|
||||
description = "校验 versionName 与 project.yaml / 契约快照一致(文档 §11.2)"
|
||||
doLast {
|
||||
val yamlVersion = projectYamlVersion()
|
||||
val name = android.defaultConfig.versionName
|
||||
if (yamlVersion.isNotEmpty() && yamlVersion != name) {
|
||||
throw GradleException("versionName=$name 与 project.yaml 的 version=$yamlVersion 不一致")
|
||||
}
|
||||
val wsVersion = android.defaultConfig.buildConfigFields["CONTRACT_WEBSOCKET_KEYPOINTS"]?.value?.trim('"')
|
||||
if (wsVersion != "0.2.0") {
|
||||
throw GradleException("contracts/VERSION 的 websocket-keypoints=$wsVersion,期望 0.2.0")
|
||||
}
|
||||
val code = android.defaultConfig.versionCode
|
||||
val expected = name!!.split(".").let {
|
||||
it[0].toInt() * 10000 + it[1].toInt() * 100 + it[2].toInt()
|
||||
}
|
||||
logger.lifecycle("[consistency] versionName=$name versionCode=$code 契约(websocket-keypoints)=$wsVersion")
|
||||
if (code != expected) {
|
||||
throw GradleException("versionCode=$code 与 versionName=$name 不匹配(应为 $expected)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 release APK 里的 native 库:
|
||||
* - ABI 目录集合必须等于 abiFilters(多一个或少一个都算失败);
|
||||
* - 每个 ABI 目录下必须存在 MediaPipe 的 native 库;
|
||||
* - assets 里的 MediaPipe 模型存在。
|
||||
*/
|
||||
tasks.register("verifyReleaseAbi") {
|
||||
group = "verification"
|
||||
description = "校验 release APK 的 ABI 与 MediaPipe native 库(T-AND-012)"
|
||||
dependsOn("assembleRelease")
|
||||
doLast {
|
||||
val apk = file("build/outputs/apk/release/app-release.apk")
|
||||
if (!apk.exists()) throw GradleException("未生成 ${apk.path}")
|
||||
val expected = setOf("arm64-v8a", "armeabi-v7a")
|
||||
// 注意:脚本里 `java` 被 JavaPluginExtension 遮蔽,必须用顶部 import 的 ZipFile
|
||||
val entries: List<String> = ZipFile(apk).use { zip ->
|
||||
zip.entries().asSequence().map { entry -> entry.name }.toList()
|
||||
}
|
||||
val abis: Set<String> = entries.filter { name -> name.startsWith("lib/") }
|
||||
.map { name -> name.removePrefix("lib/").substringBefore("/") }
|
||||
.toSet()
|
||||
logger.lifecycle("[abi] release APK 包含 ABI:$abis")
|
||||
if (abis != expected) {
|
||||
throw GradleException("release APK 的 ABI=$abis,期望 $expected")
|
||||
}
|
||||
abis.forEach { abi: String ->
|
||||
val hasJni = entries.any { name -> name.startsWith("lib/$abi/") && name.contains("mediapipe") }
|
||||
if (!hasJni) throw GradleException("lib/$abi/ 下缺少 MediaPipe native 库")
|
||||
logger.lifecycle("[abi] $abi 下 MediaPipe native 库存在")
|
||||
}
|
||||
val model = entries.count { name -> name == "assets/pose_landmarker_lite.task" }
|
||||
if (model != 1) throw GradleException("assets/pose_landmarker_lite.task 数量为 $model(期望 1)")
|
||||
logger.lifecycle("[abi] MediaPipe 模型资源存在")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+13
@@ -7,8 +7,21 @@
|
||||
# 删掉这些规则会导致 release 包在真机上运行时崩溃(debug 包不会暴露该问题)。
|
||||
|
||||
# ---------------------------------------------------------------- MediaPipe
|
||||
#
|
||||
# T-AND-012:release 崩溃(ExceptionInInitializerError / UnsatisfiedLinkError)
|
||||
# 的一大来源就是 R8 把 MediaPipe 的类裁掉或改名。以下规则逐条对应:
|
||||
# 1. 整个包保持原样(JNI 按类名/方法名查找);
|
||||
# 2. **native 方法所在的类与其声明类名必须保留**(仅 keep class 不够,
|
||||
# R8 仍可能裁剪方法名,导致 JNI 注册失败);
|
||||
# 3. 生成 seeds 文件,发布前可用 -printseeds 核对关键类没被裁掉。
|
||||
-keep class com.google.mediapipe.** { *; }
|
||||
-keep interface com.google.mediapipe.** { *; }
|
||||
-keepclasseswithmembernames class com.google.mediapipe.** {
|
||||
native <methods>;
|
||||
}
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
-dontwarn com.google.mediapipe.**
|
||||
|
||||
# MediaPipe 使用 AutoValue 生成不可变类,依赖注解与泛型签名
|
||||
|
||||
@@ -34,6 +34,20 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
T-AND-011:导出连接日志时通过 FileProvider 分享 filesDir 下的文本文件。
|
||||
Android 7+ 不允许直接以 file:// 分享,必须走 content://。
|
||||
-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.opendance.studio.data
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.edit
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 应用设置(SharedPreferences)。
|
||||
@@ -11,6 +13,7 @@ import androidx.core.content.edit
|
||||
class AppSettings(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val context: Context = context.applicationContext
|
||||
|
||||
/** Windows 客户端主机地址 */
|
||||
var host: String
|
||||
@@ -51,8 +54,32 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getBoolean(KEY_AUTO_RECONNECT, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_AUTO_RECONNECT, value) }
|
||||
|
||||
/** 契约 §1:ws://<windows-host>:8765/pose */
|
||||
fun webSocketUrl(): String = "ws://$host:$port/pose"
|
||||
|
||||
/**
|
||||
* 契约 §3.1 的 hello.deviceId:该安装实例的**稳定标识**,用于接收端去重
|
||||
* (否则一次"闪断重连"会被 Windows 端显示成两台设备)。
|
||||
*
|
||||
* 取 `ANDROID_ID` 的 SHA-256 前 16 位十六进制:ANDROID_ID 在 Android 8+
|
||||
* 按「应用签名 + 用户 + 设备」隔离,不需要任何权限,也不是硬件序列号。
|
||||
* 首次访问生成一次并落盘,之后不再变化(清除应用数据才会变)。
|
||||
*/
|
||||
val deviceId: String
|
||||
get() {
|
||||
val cached = prefs.getString(KEY_DEVICE_ID, null)
|
||||
if (!cached.isNullOrBlank()) return cached
|
||||
val raw = runCatching {
|
||||
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
}.getOrNull().orEmpty().ifBlank { "unknown" }
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(("opendance:" + raw).toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
.take(16)
|
||||
prefs.edit { putString(KEY_DEVICE_ID, digest) }
|
||||
return digest
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFS_NAME = "opendance_settings"
|
||||
const val DEFAULT_HOST = "192.0.2.100"
|
||||
@@ -67,6 +94,7 @@ class AppSettings(context: Context) {
|
||||
private const val KEY_RESOLUTION = "resolution"
|
||||
private const val KEY_DEVICE_LABEL = "device_label"
|
||||
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import com.opendance.studio.BuildConfig
|
||||
import com.opendance.studio.model.KeypointFrame
|
||||
import com.opendance.studio.util.ConnectionLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -12,36 +13,56 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONObject
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* T-AND-005:WebSocket 推流客户端(本端为**发送方**)
|
||||
* T-AND-005 / T-AND-011:WebSocket 推流客户端(本端为**发送方**)
|
||||
*
|
||||
* 契约 contracts/websocket-keypoints.md:
|
||||
* 契约 contracts/websocket-keypoints.md v0.2.0:
|
||||
* - 端点 ws://<windows-host>:8765/pose,子协议 opendance.pose.v1
|
||||
* - 二进制帧 = 239 字节关键点(见 [KeypointFrame])
|
||||
* - 文本控制帧 hello / config / stats / bye
|
||||
* - 文本控制帧 hello / welcome / config / stats / ping / pong / bye
|
||||
*
|
||||
* 关键设计:
|
||||
* - 二进制帧走 `send(ByteString)`,控制帧走 `send(text)`
|
||||
* - 自动重连采用指数退避(1s → 2s → 4s … 上限 15s),连接成功即重置
|
||||
* - `sendFrame` 在非连接态直接丢弃并计数,绝不阻塞相机线程
|
||||
* - 统计信息(发送帧数、丢弃帧数、FPS、RTT)由本类维护,供运行页展示
|
||||
* T-AND-011 的修复要点("连接状态异常、频繁重连"):
|
||||
* 1. **状态机与接收端同名**(契约 §8):idle → resolving → connecting →
|
||||
* handshaking → streaming → degraded → reconnecting → stopped,
|
||||
* 界面显示状态 + 最近一次断开原因(close code / reason)。
|
||||
* 2. **保活**:接收端在 welcome/config 里声明 silentTimeoutMs 与
|
||||
* heartbeatIntervalMs,本端按后者周期发应用层 `ping`。
|
||||
* 协议层 ping(OkHttp 的 `pingInterval`)对接收端的应用层**不可见**,
|
||||
* 不能用来续命 —— 这是原先"画面里没人就掉线"的根因之一。
|
||||
* 3. **只有真正断开才重连**(契约 §7):主动停止 / 1000 / 1001 / 1002 一律不重连。
|
||||
* 4. **退避与抖动**:0.5s→1→2→4→8→15s 封顶 + ±20% 抖动(契约 §7.1)。
|
||||
* 5. **可诊断**:close code / 子协议协商结果 / 首帧时间写入 [ConnectionLog],
|
||||
* 界面可一键导出。
|
||||
*/
|
||||
class PoseWebSocketClient(
|
||||
private val scope: CoroutineScope,
|
||||
private val subprotocol: String = BuildConfig.WS_SUBPROTOCOL,
|
||||
) {
|
||||
|
||||
enum class State { IDLE, CONNECTING, CONNECTED, RECONNECTING, CLOSED }
|
||||
/** 契约 §8 的连接状态(wireName 与接收端 GDScript 侧同名,便于对照日志) */
|
||||
enum class State(val wireName: String) {
|
||||
IDLE("idle"),
|
||||
RESOLVING("resolving"),
|
||||
CONNECTING("connecting"),
|
||||
HANDSHAKING("handshaking"),
|
||||
STREAMING("streaming"),
|
||||
DEGRADED("degraded"),
|
||||
RECONNECTING("reconnecting"),
|
||||
STOPPED("stopped"),
|
||||
}
|
||||
|
||||
data class Stats(
|
||||
val state: State = State.IDLE,
|
||||
@@ -52,15 +73,38 @@ class PoseWebSocketClient(
|
||||
val sendFps: Float = 0f,
|
||||
val roundTripMs: Long = -1,
|
||||
val reconnectAttempts: Int = 0,
|
||||
val nextRetryMs: Long = 0,
|
||||
val lastError: String = "",
|
||||
val serverSessionId: String = "",
|
||||
)
|
||||
val negotiatedProtocol: String = "",
|
||||
/** 最近一次断开:close code 与原因(契约 §8 要求界面展示) */
|
||||
val lastCloseCode: Int = 0,
|
||||
val lastCloseReason: String = "",
|
||||
val lastDisconnectAtMs: Long = 0,
|
||||
/** 服务端声明的静默超时与期望心跳(契约 §1.1) */
|
||||
val silentTimeoutMs: Long = ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS,
|
||||
val heartbeatIntervalMs: Long =
|
||||
ReconnectPolicy.heartbeatIntervalMs(ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS),
|
||||
/** 本轮连接的首帧时间;0 表示还没发出过关键点帧 */
|
||||
val firstFrameAtMs: Long = 0,
|
||||
val pongsReceived: Int = 0,
|
||||
) {
|
||||
val lastCloseLabel: String
|
||||
get() = if (lastCloseCode == 0 && lastCloseReason.isBlank()) {
|
||||
""
|
||||
} else {
|
||||
ReconnectPolicy.closeCodeLabel(lastCloseCode) +
|
||||
if (lastCloseReason.isNotBlank()) "($lastCloseReason)" else ""
|
||||
}
|
||||
}
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 长连接不设读超时
|
||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
||||
// 协议层 ping 仍然保留(对中间设备有用),但**不能**替代应用层 ping:
|
||||
// 接收端的静默超时只看应用层收到的数据帧/文本帧。
|
||||
.pingInterval(PROTOCOL_PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
@@ -69,21 +113,31 @@ class PoseWebSocketClient(
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private var heartbeatJob: Job? = null
|
||||
private var url: String = ""
|
||||
private var manualClose = false
|
||||
private var autoReconnect = true
|
||||
|
||||
private val sentCounter = AtomicInteger(0)
|
||||
private val droppedCounter = AtomicInteger(0)
|
||||
private val reconnectCounter = AtomicInteger(0)
|
||||
private val pongCounter = AtomicInteger(0)
|
||||
private val frameIdCounter = AtomicInteger(0)
|
||||
|
||||
/** 首帧时间戳与计数,用于计算发送 FPS */
|
||||
private var fpsWindowStart = 0L
|
||||
private var fpsWindowFrames = 0
|
||||
|
||||
/** 上次收到 pong 的时刻,用于判定链路是否已死 */
|
||||
@Volatile
|
||||
private var lastPongAtMs = 0L
|
||||
|
||||
private val deviceLabel: String =
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
|
||||
|
||||
/** 稳定的设备标识(契约 §3.1),用于接收端去重;不含任何硬件序列号 */
|
||||
var deviceId: String = ""
|
||||
|
||||
// ------------------------------------------------------------------ 连接
|
||||
|
||||
fun connect(targetUrl: String, autoReconnect: Boolean = true) {
|
||||
@@ -91,16 +145,33 @@ class PoseWebSocketClient(
|
||||
manualClose = false
|
||||
this.autoReconnect = autoReconnect
|
||||
reconnectCounter.set(0)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user