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() 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 = ZipFile(apk).use { zip -> zip.entries().asSequence().map { entry -> entry.name }.toList() } val abis: Set = entries.filter { name -> name.startsWith("lib/") } .map { name -> name.removePrefix("lib/").substringBefore("/") } .toSet() logger.lifecycle("[abi] release APK 包含 ABI:$abis") if (abis != expected) { throw GradleException("release APK 的 ABI=$abis,期望 $expected") } abis.forEach { abi: String -> val hasJni = entries.any { name -> name.startsWith("lib/$abi/") && name.contains("mediapipe") } if (!hasJni) throw GradleException("lib/$abi/ 下缺少 MediaPipe native 库") logger.lifecycle("[abi] $abi 下 MediaPipe native 库存在") } val model = entries.count { name -> name == "assets/pose_landmarker_lite.task" } if (model != 1) throw GradleException("assets/pose_landmarker_lite.task 数量为 $model(期望 1)") logger.lifecycle("[abi] MediaPipe 模型资源存在") } }