Files
opendance-android/app/build.gradle.kts
T
zijunchiang 6dfd90f7ca fix(reconnect): 上一次会话的关闭码污染下一次会话(v0.2.1)+ 重连链路的真实 WebSocket 集成测试
ReconnectPolicyTest(11 项)验的是**纯函数**「给定关闭码应该怎么退避」,而 ⑦
「连接频繁重连」的缺陷往往出在**调用点**。新增 PoseWebSocketReconnectTest(8 项):
用 MockWebServer.withWebSocketUpgrade 在 JVM 上起**真实** WebSocket 服务端
(能按 §3.2 回 welcome、按 §3.6 回 pong、按指定关闭码关连接),无需设备即可覆盖
「该重连的」与「不该重连的」两条分支。Robolectric 只用来提供 android.os.Build 桩。

它抓到的真实缺陷(本版修复):
  上一次会话的关闭码会污染下一次会话,导致重新开始推流后失去自动重连能力。
  复现:第一次连接被对端以 1001 关闭(按 §7 不重连,正确)→ 用户再点「开始推流」
  → 这一次是网络层失败(服务端还没起来),按 §7 **应当**重连 → 但 connect() 只重置了
  reconnectCounter 与 lastPongAtMs,**没有清 lastCloseCode** → scheduleReconnect()
  仍按 1001 判定 → 直接 stopped,**再也不会自动重连**。
  测试失败信息(修复前):
    ✗ 上一次 1001 关闭后重新连接,遇到网络失败仍应按策略重连
      AssertionError: 当前 state=STOPPED,lastCloseCode=1001,attempts=0

修复(PoseWebSocketClient.kt):
  * connect():清掉会话级关闭状态(lastCloseCode=0、lastCloseReason="");
  * onFailure():记录 recordClose(0, …)(契约 §7 中 0=未收到关闭帧 → 应重连),
    顺带让界面「最近一次断开原因」显示本次失败而不是旧值。

用例清单:握手→streaming 与 welcome 保活参数解析、按协商间隔发 ping 收 pong、
1011 按退避重连且等待落在策略 ±20% 区间、成功后计数清零、1001/1002 不重连、
主动断开不重连、上一次 1001 后再连遇网络失败仍应重连(本次回归锁)、
服务端未启动时 1.5s 内 ≤3 次尝试(退避生效)。

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

踩坑:第一版 MockWebServer 夹具的 101 响应没有回显 Sec-WebSocket-Protocol
(RFC 6455 要求),客户端记录的 negotiatedProtocol 为空导致断言失败 —— 那是
**夹具**不合规而非客户端问题,已在夹具里补上。写「真实对端」夹具时要按 RFC/契约
把服务端做对,否则会把夹具缺陷误判成产品缺陷。
2026-09-17 05:44:03 +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 = 201
versionName = "0.2.1"
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 模型资源存在")
}
}