// ───────────────────────────────────────────────────────────────────────────── // :app — the Android application (Compose Material 3 + Adaptive design system, // Hilt DI skeleton, launcher MainActivity). Mirrors iOS App/WebTerm. // // A13 establishes the Android UI-stack version matrix for every later Android task // (the app-stack baseline, analogous to how A1 established the JVM catalog). All // UI versions are resolved in gradle/libs.versions.toml. // // Plugins (AGP 9 has BUILT-IN Kotlin → never apply org.jetbrains.kotlin.android): // com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android // ───────────────────────────────────────────────────────────────────────────── import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.ksp) alias(libs.plugins.hilt) } // ───────────────────────────────────────────────────────────────────────────── // Release signing credentials — NEVER committed. // // Looked up in this order, first non-blank wins: // 1. `android/keystore.properties` (the conventional path; see keystore.properties.example) // 2. `android/local.properties` (already gitignored — the zero-setup local option) // 3. `WEBTERM_RELEASE_*` env vars (CI) // // DEGRADATION CONTRACT: absent credentials leave debug (and the `benchmark` variant, // which signs with the debug key) fully working, and make the RELEASE package task fail // with RELEASE_SIGNING_HELP — never a silent `app-release-unsigned.apk`. // ───────────────────────────────────────────────────────────────────────────── val signingPropertyFiles = listOf("keystore.properties", "local.properties") val signingProperties = Properties().apply { // Later files must not override earlier ones, so load in reverse and let the // higher-priority file overwrite. signingPropertyFiles.reversed() .map(rootProject::file) .filter(File::exists) .forEach { file -> file.inputStream().use(::load) } } fun signingCredential(propertyKey: String, envKey: String): String? = (signingProperties.getProperty(propertyKey) ?: System.getenv(envKey))?.takeIf(String::isNotBlank) val releaseStoreFile = signingCredential("webterm.release.storeFile", "WEBTERM_RELEASE_STORE_FILE") val releaseStorePassword = signingCredential("webterm.release.storePassword", "WEBTERM_RELEASE_STORE_PASSWORD") val releaseKeyAlias = signingCredential("webterm.release.keyAlias", "WEBTERM_RELEASE_KEY_ALIAS") val releaseKeyPassword = signingCredential("webterm.release.keyPassword", "WEBTERM_RELEASE_KEY_PASSWORD") // `storeFile` may be absolute or relative to `android/` — rootProject.file handles both. val releaseKeystore = releaseStoreFile?.let(rootProject::file) val hasAllReleaseCredentials = releaseStoreFile != null && releaseStorePassword != null && releaseKeyAlias != null && releaseKeyPassword != null val isReleaseSigningConfigured = hasAllReleaseCredentials && releaseKeystore?.exists() == true // Two distinct failures deserve two distinct messages: "you have not set this up" and // "you set it up but pointed it at a file that isn't there" have completely different fixes. val MISSING_KEYSTORE_HELP = """ |:app release signing credentials were found, but the keystore file does not exist: | ${releaseKeystore?.absolutePath} | |Fix `webterm.release.storeFile` (absolute, or relative to the `android/` directory), or |point WEBTERM_RELEASE_STORE_FILE at the real file. Nothing was signed, and no unsigned |APK was emitted. """.trimMargin() val UNCONFIGURED_SIGNING_HELP = """ |:app release signing is NOT configured — refusing to emit an unsigned release artifact. | |Provide the four credentials in ONE of these places (first match wins): | 1. android/keystore.properties — copy android/keystore.properties.example. | MAKE SURE `keystore.properties` is in android/.gitignore first. | 2. android/local.properties — already gitignored; zero extra setup. | 3. env: WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI) | |Keys (property form): | webterm.release.storeFile= | webterm.release.storePassword=<...> | webterm.release.keyAlias=<...> | webterm.release.keyPassword=<...> | |No keystore yet? Generate one OUTSIDE the repo: | keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \ | -alias webterm -keyalg RSA -keysize 4096 -validity 10000 |Then record its SHA-256 in the App Links assetlinks.json (plan §8). | |Unaffected by this failure: `:app:assembleDebug`, `:app:assembleBenchmark`, |`:app:assembleDebugAndroidTest`, every unit test, and R8 itself (which already ran). """.trimMargin() android { namespace = "wang.yaojia.webterm" // compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for // Kotlin 2.3.21 requires compiling against SDK 36+ (BOM 2025.11 → ui 1.9.5). // targetSdk stays 35 per plan §2 (compileSdk ≥ targetSdk is the normal rule). compileSdk = 36 defaultConfig { applicationId = "wang.yaojia.webterm" minSdk = 29 targetSdk = 35 // ── Versioning policy ──────────────────────────────────────────────── // versionCode: a plain monotonic counter, +1 for EVERY artifact handed to // anyone (Play track, internal APK, benchmark run that gets archived). // Never derived from versionName — decoupling them is what lets a hotfix // ship without renumbering. This is still 1 because no artifact has ever // left this machine; the first distributed build takes 2. // versionName: `-alphaNN`. The Android client tracks the server's // 0.1.x line (root package.json is 0.1.0). `-alpha01` is the honest state: // the P0+P1 surface from ANDROID_CLIENT_PLAN §1 is implemented and JVM-tested, // but NOTHING in DEVICE_QA_CHECKLIST.md has been signed off on real hardware. // Drop to `-beta01` when the checklist's A34/A35 blocks pass on a device, and // to plain `0.1.0` when the whole checklist is ticked. versionCode = 1 versionName = "0.1.0-alpha01" // Hilt instrumented testing REQUIRES a custom runner: AndroidJUnitRunner is the // only hook that can swap the app-under-test's Application for the generated // `HiltTestApplication` (the androidTest manifest cannot — it is merged into the // TEST apk, not into the app under test). See android/README.md → "Instrumented tests". testInstrumentationRunner = "wang.yaojia.webterm.HiltTestRunner" } buildFeatures { compose = true } signingConfigs { // Populated ONLY when credentials were found; an empty config would make AGP // fall back to emitting `app-release-unsigned.apk`, which is exactly the silent // failure this block exists to prevent (the release buildType leaves // `signingConfig` null instead, and the guard task below fails the build). if (isReleaseSigningConfigured) { create("release") { storeFile = rootProject.file(releaseStoreFile!!) storePassword = releaseStorePassword keyAlias = releaseKeyAlias keyPassword = releaseKeyPassword // v1 (JAR signing) is only needed below API 24 and is the weakest scheme — // off. v2/v3 are left enabled and apksigner picks what the minSdk range // actually requires: verified output for minSdk 29 is v1=false, v2=false, // v3=true (every device that can install this understands v3, so v2 is // redundant). Do not read v2=false as a misconfiguration. enableV1Signing = false enableV2Signing = true enableV3Signing = true } } } buildTypes { debug { // No applicationId suffix — keep it stable for deep-link testing (A32). } release { isMinifyEnabled = true // Resource shrinking requires minification; it is what strips the unused // CameraX/ML-Kit/Firebase resources the 56 MB unminified APK carried. isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) signingConfig = signingConfigs.findByName("release") } // `benchmark` — what :macrobenchmark (A35) measures against. It must match the // shipping variant in everything that affects performance (non-debuggable, R8'd, // resource-shrunk) while needing NO release keystore, so it signs with the debug // key. It is therefore also the variant that proves the R8 pipeline end-to-end // (`:app:assembleBenchmark` produces a real, installable, minified APK). create("benchmark") { initWith(getByName("release")) signingConfig = signingConfigs.getByName("debug") // Library modules only publish debug/release; resolve `benchmark` to release. matchingFallbacks += listOf("release") isDebuggable = false // Injects into the merged manifest, which // macrobenchmark needs to read traces on API 29-30 (implicit from API 31). // Done here BECAUSE AndroidManifest.xml must not carry a benchmark-only tag. isProfileable = true isMinifyEnabled = true isShrinkResources = true } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } } kotlin { jvmToolchain(17) } dependencies { // Modules A15 wires into the DI graph. implementation(project(":wire-protocol")) implementation(project(":session-core")) implementation(project(":api-client")) implementation(project(":transport-okhttp")) implementation(project(":client-tls")) // A15: bridge the mTLS device identity (:client-tls-android) onto the shared client, and // provide the DataStore-backed stores (:host-registry). :client-tls-android exposes // :client-tls via `api`, but the explicit dep above is kept for clarity. implementation(project(":client-tls-android")) implementation(project(":host-registry")) // Terminal render (A16): RemoteTerminalSession/RemoteTerminalView for the live terminal (A21) and // the raw Termux TerminalEmulator for A18's off-screen thumbnail rasterisation (§6.7). :terminal-view // scopes Termux as `implementation`, so :app names the emulator directly for the off-screen path. implementation(project(":terminal-view")) implementation(libs.termux.terminal.emulator) // QR pairing (A19): CameraX + on-device ML Kit barcode scanning. implementation(libs.bundles.camerax) implementation(libs.mlkit.barcode.scanning) // Push (A30) + nav/deep-links (A32). implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) implementation(libs.androidx.biometric) implementation(libs.androidx.navigation.compose) // OkHttp is `implementation` (not `api`) in :transport-okhttp, so :app names OkHttpClient / // ConnectionPool directly in NetworkModule/TlsModule. implementation(libs.okhttp) // Coroutines are used directly by the wiring (EventBus fan-out, engine confinement scope). implementation(libs.kotlinx.coroutines.core) // Preferences DataStore is named in StorageModule's @Provides return types. implementation(libs.androidx.datastore.preferences) // AndroidX foundation implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) // Compose (the BOM governs every version below) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) debugImplementation(libs.androidx.compose.ui.tooling) // Hilt (Dagger) — KSP annotation processing implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) // JVM unit tests (no device) — the frozen design-token spec, EventBus fan-out, and the // RetainedSessionHolder lifecycle invariant driven by a fake transport under virtual time. testImplementation(libs.bundles.unit.test) testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6) testRuntimeOnly(libs.junit.platform.launcher) // ── Instrumented tests (androidTest — device/emulator only; plan §7) ────────── // Homes A34 (E2E against a real `npm start` host) and the Espresso/Compose half of // A35. JUnit4 only: AndroidJUnitRunner cannot drive the JUnit5 platform. androidTestImplementation(libs.bundles.android.instrumented.test) // Compose UI assertions (gate banner / plan sheet / reconnect-banner precedence / // session rows / continue-last banner). BOM-managed, same BOM as main. androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) // Supplies the empty host Activity that `createComposeRule()` launches into. Must be // debugImplementation (it contributes a manifest entry to the app under test). debugImplementation(libs.androidx.compose.ui.test.manifest) // Hilt instrumented testing: HiltAndroidRule + the generated HiltTestApplication. androidTestImplementation(libs.hilt.android.testing) kspAndroidTest(libs.hilt.compiler) } // Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules. // Instrumented (androidTest) tasks are NOT of type Test, so they keep JUnit4. tasks.withType().configureEach { useJUnitPlatform() } // ───────────────────────────────────────────────────────────────────────────── // Release-signing guard. // // Hooked as a doFirst on the PACKAGE task (not as a dependency) on purpose: packaging // runs after dexing, so R8/resource-shrinking have fully completed and been reported by // the time this fires. An unconfigured machine therefore still gets a real, verifiable // R8 run — it just cannot produce a distributable artifact. // ───────────────────────────────────────────────────────────────────────────── if (!isReleaseSigningConfigured) { val help = if (hasAllReleaseCredentials) MISSING_KEYSTORE_HELP else UNCONFIGURED_SIGNING_HELP tasks.matching { it.name == "packageRelease" || it.name == "packageReleaseBundle" } .configureEach { doFirst { throw GradleException(help) } } }