Files
opendance-android/app/build.gradle.kts
T
zijunchiang c0d0937023 fix(reconnect): 合规接收端被周期性误判「链路已死」(v0.2.2)+ 可注入计时覆盖退避封顶
一、测试抓到的真实缺陷(T-AND-017)
契约 §3.6 明确:「接收端收到任何一帧都算活动,ping **无需额外应答**即可保活,
pong 只用于诊断」—— 即"合规但不回 pong"的接收端是**合法**的。
但 PoseWebSocketClient.onOpen 里写的是 lastPongAtMs = timing.nowMs(),
于是心跳循环的守卫 if (lastPongAtMs > 0 && silentFor > limit) **恒为真**:
对任何接收端都会「判定链路已死 → 主动断链 → 重连」,默认参数
(heartbeatIntervalMs=5000)下即**每 15 秒断一次**,正是用户抱怨的「频繁重连」。

为什么以前没暴露:我们自己的接收端(Windows PoseServer)**会**回 pong
(T-WIN-020 实测 9 次 ping 全部收到 pong),这条路径在自家联调里永远走不到。

修复:onOpen 复位 lastPongAtMs = 0L(0 = 本连接还没收到过 pong),
只有「曾经回过 pong、之后又不回了」才判定链路已死 —— 这正是该诊断本来的意图。

二、可注入计时 Timing(时钟 + 等待)
契约 §7.1 的退避序列 0.5s→1→2→4→8→15s 封顶若要端到端验证,真实等待约 30 秒;
「连续 3 个心跳周期没收到 pong」也要等几十秒。抽成接口后:
  * 生产默认 SystemTiming = 真实时钟 + 真实 delay,行为与抽取前完全一致
    (10 处 System.currentTimeMillis() 与 2 处 delay() 改走 timing);
  * 测试注入「可控时钟 + 立即返回的等待」,毫秒级跑完整条序列。

三、新增 4 项 JVM 用例(PoseWebSocketBackoffTest,无需设备)
  * 退避序列在 15 秒封顶且不无限增长:连续失败下断言第 1..8 次等待分别落在
    500/1000/2000/4000/8000/15000/15000/15000 的 ±20% 区间内,且真实耗时 < 5 秒;
  * 等待走注入实现:前三次重连真实耗时 < 1.5 秒(真实退避需 3.5 秒);
  * **合规但从不回 pong 的接收端不会被判定链路已死**(修复前此处失败):
    走过 6 个心跳周期仍为 streaming、无「判定链路已死」日志;
  * 曾经回过 pong 之后停止回复时判定链路已死并重连:诊断仍然有效。

实测:./gradlew :app:testDebugUnitTest → 7 个测试类 / 68 项用例 / 0 失败(原 64)。
build/verify.sh 全绿(签名、APK 元数据 0.2.2/202、native 体检、ABI、R8、dex)。
版本号 0.2.1/201 → 0.2.2/202。

写测试踩的两个坑(已写入任务文档):
  1. 不要在瞬时重连下读 stats.nextRetryMs(等待立即返回后已被清零,与重连循环竞争),
     等待时长应取注入实现记录的事实;
  2. 虚拟时钟需要一个真实让出点(FakeTiming.await 里 2ms 真实 delay),
     否则心跳循环会在网络回调落地前把时钟推过阈值。
2026-09-18 02:34:54 +08:00

333 lines
14 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import java.util.Properties
import java.util.zip.ZipFile
// OpenDance Studio —— Android 辅助 App(应用模块)
//
// 契约: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")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.opendance.studio"
compileSdk = 34
defaultConfig {
applicationId = "com.opendance.studio"
minSdk = 29 // Android 10(文档 T-AND-009 验收要求 Android 10+)
targetSdk = 34
// 文档 §11.1:versionCode 用整数按 10000*主 + 100*次 + 修订 递增,
// versionName 用 X.Y.Z,两者同一次发布一起更新。
versionCode = 202
versionName = "0.2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
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}" },
)
}
/**
* 签名配置:优先读取 keystore.properties(已被 .gitignore 忽略),
* 否则读取环境变量;两者都没有时回退到 debug 签名。
*
* 这样 `./gradlew assembleRelease` 在没有任何私密文件的情况下也能产出**已签名**的
* APK(用 debug key),便于 CI 与验收;正式发布必须提供自己的 keystore
* (见 build/gen-keystore.sh 与 README.txt)。
*/
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
fun signingValue(key: String, env: String): String? =
keystoreProperties.getProperty(key) ?: System.getenv(env)
val releaseStoreFile = signingValue("storeFile", "KEYSTORE_PATH")
val hasReleaseKeystore = releaseStoreFile != null && file(releaseStoreFile).exists()
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = file(releaseStoreFile!!)
storePassword = signingValue("storePassword", "KEYSTORE_PASSWORD")
keyAlias = signingValue("keyAlias", "KEY_ALIAS")
keyPassword = signingValue("keyPassword", "KEY_PASSWORD")
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
// debug 保留全部 ABI,方便在模拟器(x86_64)上调试
ndk {
abiFilters.clear()
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"))
}
}
release {
// 发布包只打包真实手机用得到的 ABI。
// x86/x86_64 仅供模拟器使用,却占掉约 27 MB(几乎是发布包的一半),
// 对真机用户纯属浪费。
ndk {
abiFilters.clear()
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a"))
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
// 回退:用 debug 签名,保证 CI 上 assembleRelease 也能产出已签名 APK
signingConfigs.getByName("debug")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/DEPENDENCIES",
"/META-INF/LICENSE*",
"/META-INF/NOTICE*",
)
}
// MediaPipe 的 .task 模型必须保持不压缩,否则运行时按文件路径读取会失败
androidResources {
noCompress += "task"
}
// T-AND-012:release 默认 extractNativeLibs=false(native 库直接从 APK mmap),
// MediaPipe 的 JNI 加载对这一点有历史兼容问题,会表现为
// ExceptionInInitializerError / UnsatisfiedLinkError。
// 显式使用传统打包(等价 android:extractNativeLibs="true")。
jniLibs {
useLegacyPackaging = true
}
}
testOptions {
unitTests {
isReturnDefaultValues = true
// Robolectric 需要真实资源与 Manifest 才能组合 Compose 界面
isIncludeAndroidResources = true
}
}
lint {
abortOnError = false
}
}
dependencies {
// ---- Android 基础 ----
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.activity:activity-compose:1.9.2")
// ---- Compose ----
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
// ---- CameraX(T-AND-002)----
val cameraX = "1.4.0"
implementation("androidx.camera:camera-core:$cameraX")
implementation("androidx.camera:camera-camera2:$cameraX")
implementation("androidx.camera:camera-lifecycle:$cameraX")
implementation("androidx.camera:camera-view:$cameraX")
// ---- MediaPipe Pose 端侧推理(T-AND-003)----
implementation("com.google.mediapipe:tasks-vision:0.10.35")
// ---- WebSocket 推流(T-AND-005)----
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// 权限:使用平台 Activity Result API(androidx.activity)。
// 原先用 accompanist-permissions 0.34.0,它发布于 2024-02,会传递引入
// compose animation-core 1.6.0,与 Compose BOM 存在版本偏斜风险;
// 该库已进入维护状态,官方也建议改用 Activity Result API。
// ---- 单元测试(T-AND-004 等纯逻辑,跑在 JVM 上,无需设备)----
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21")
// MockWebServer:JVM 上起**真实** WebSocket 服务端(支持 withWebSocketUpgrade),
// 用来验证「断线 → 按契约 §7.1 退避重连 / 特定关闭码不重连」这条只在真机上
// 才暴露的链路。版本与 okhttp 保持一致,避免协议实现偏斜。
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
// Robolectric:在 JVM 上跑 Android 框架 + Compose,用于验证
// 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷
testImplementation("org.robolectric:robolectric:4.13")
testImplementation("androidx.test:core-ktx:1.6.1")
testImplementation("androidx.test.ext:junit:1.2.1")
testImplementation("androidx.compose.ui:ui-test-junit4")
// 必须用 debugImplementation:它往 debug 变体的合并 Manifest 里注册
// androidx.activity.ComponentActivity,Compose 测试规则要靠它启动宿主 Activity。
// 放 testImplementation 不会参与 Manifest 合并,运行时报
// "Unable to resolve activity for Intent ... ComponentActivity"。
debugImplementation("androidx.compose.ui:ui-test-manifest")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
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 模型资源存在")
}
}