build(android): make release buildable and instrumented testing possible
Four gaps that made "ship it" and "test it on hardware" impossible. RELEASE SIGNING. assembleRelease was silently producing app-release-unsigned.apk because there was no signingConfigs block at all — the failure mode where you only notice at install time. Credentials now resolve from keystore.properties, then local.properties, then WEBTERM_RELEASE_* env; nothing is created or committed, and keystore.properties.example is the template. The degradation is ENFORCED, not documented. The signingConfig is only created when credentials actually resolve — an empty config is precisely what makes AGP emit an unsigned artifact quietly — and a guard on packageRelease throws with an actionable message, distinguishing "not configured" from "credentials found but the keystore file is missing: <path>". It hooks the PACKAGE task on purpose: R8 has already run and reported by then, so a developer without a keystore still gets the full shrink result rather than an early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails loudly, and no unsigned APK is written. VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01", where the suffix is a factual claim about device verification rather than decoration: alpha means device QA is essentially unstarted, beta means the A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole checklist is ticked. versionCode stays 1 because no artifact has ever left the build machine. MINIFICATION. release had isMinifyEnabled = false and an empty rules file — so nothing had ever exercised R8 on this app, and the first release build would have been the experiment. Now minify + resource shrinking, with a deliberately short rules file: the actual artifacts were checked, and kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship their own consumer rules, so none are re-declared. The termux packages are kept whole because the JitPack artifacts ship no rules and :terminal-view binds to non-API internals — it writes the public TerminalView.mEmulator field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting that wrong yields a build that crashes only in production. lintVitalRelease also passes now, the two pre-existing lint ERRORS having been fixed earlier this session. ANDROID TEST ENABLEMENT. :app had no androidTest source set and no instrumentation runner, which is the mechanical reason A34/A35 have no code at all. The runner (Hilt-aware) and dependencies are wired, and a :macrobenchmark module exists. The tests themselves are a separate change. Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleBenchmark :macrobenchmark:assembleBenchmark :app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0 failures on a forced re-execution with test-results deleted and --no-build-cache.
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
// 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)
|
||||
@@ -17,6 +19,83 @@ plugins {
|
||||
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=<path, relative to android/ or absolute>
|
||||
| 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
|
||||
@@ -28,24 +107,87 @@ android {
|
||||
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: `<server-line>-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"
|
||||
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 = false
|
||||
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 <profileable android:shell="true"/> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +256,41 @@ dependencies {
|
||||
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<Test>().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) }
|
||||
}
|
||||
}
|
||||
|
||||
121
android/app/proguard-rules.pro
vendored
121
android/app/proguard-rules.pro
vendored
@@ -1,4 +1,117 @@
|
||||
# WebTerm :app — R8/ProGuard rules.
|
||||
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
|
||||
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
|
||||
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# WebTerm :app — R8 keep rules (release + benchmark; both isMinifyEnabled = true).
|
||||
#
|
||||
# DISCIPLINE: every rule below is justified, and rules that turned out to be
|
||||
# UNNECESSARY are recorded as such rather than added "just in case". A superfluous
|
||||
# -keep silently costs size and hides real reachability problems; a MISSING one
|
||||
# crashes only in production. Both were checked against the merged configuration
|
||||
# R8 actually used, which is dumped to:
|
||||
# app/build/outputs/mapping/<variant>/configuration.txt
|
||||
# Read that file before adding anything here — most of our stack already ships its
|
||||
# own consumer rules, and duplicating them is how this file rots.
|
||||
#
|
||||
# ALREADY COVERED BY EMBEDDED CONSUMER RULES — do NOT re-declare here:
|
||||
# · kotlinx.serialization — kotlinx-serialization-core-jvm ships
|
||||
# META-INF/com.android.tools/r8/kotlinx-serialization-{common,r8}.pro, which keeps
|
||||
# @Serializable classes' `Companion` fields, `serializer(...)`, object `INSTANCE`,
|
||||
# the `$$serializer.descriptor` field, and RuntimeVisibleAnnotations (the R8
|
||||
# full-mode fix). Verified present in 1.9.0. Our @Serializable models live in
|
||||
# :wire-protocol / :api-client; the rules are global, so they are covered.
|
||||
# · Hilt / Dagger — hilt-android + dagger AARs ship proguard.txt; all injection is
|
||||
# generated code with compile-time references, no runtime reflection to preserve.
|
||||
# · Tink — tink-android ships META-INF/proguard/protobuf.pro, keeping <fields> on
|
||||
# subclasses of the shaded protobuf GeneratedMessageLite (the reflective lite
|
||||
# runtime). Tink's key managers are registered explicitly by AeadConfig.register(),
|
||||
# not discovered reflectively, so nothing else needs keeping.
|
||||
# · FCM — firebase-messaging ships consumer rules, AND `.push.FcmService`,
|
||||
# `.push.DenyBroadcastReceiver`, `.push.AllowTrampolineActivity`, `.MainActivity`
|
||||
# and `.WebTermApp` are all declared in AndroidManifest.xml, for which AGP
|
||||
# auto-generates keep rules (build/intermediates/aapt_proguard_file/.../aapt_rules.txt).
|
||||
# Manifest-declared components therefore need no rule here — ever.
|
||||
# · OkHttp / Okio, Compose, CameraX, ML Kit barcode, DataStore, navigation-compose,
|
||||
# biometric — all ship their own consumer rules.
|
||||
# · JNI — the default proguard-android-optimize.txt already contains
|
||||
# `-keepclasseswithmembernames class * { native <methods>; }`, which covers the
|
||||
# Termux emulator's native entry points even though we never fork a subprocess.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ── Termux terminal emulator + view (the ONE thing that genuinely needs a rule) ──
|
||||
#
|
||||
# Why this is not "just in case":
|
||||
# 1. The JitPack artifacts (com.github.termux.termux-app:terminal-{emulator,view},
|
||||
# v0.118.0) ship NO consumer rules at all — nobody upstream has reasoned about R8
|
||||
# for these modules, because Termux itself ships unminified.
|
||||
# 2. :terminal-view deliberately binds to NON-API internals of the pinned version
|
||||
# (plan §6.1 — TerminalView is `public final`, so we fork rather than subclass):
|
||||
# · RemoteTerminalHostView writes and reads the public field
|
||||
# `com.termux.view.TerminalView.mEmulator` directly (installEmulator /
|
||||
# the TerminalViewClient callbacks / computeVerticalScrollRange).
|
||||
# · TerminalResizeDriver calls `TerminalRenderer.getFontWidth()` and
|
||||
# `getFontLineSpacing()` — public accessors verified with `javap -p`, but not
|
||||
# part of any documented API surface.
|
||||
# · TerminalScrollGesture reads `mEmulator` and drives KeyHandler.
|
||||
# Field/method RENAMING alone would survive that (R8 rewrites both sides), but R8
|
||||
# full mode also does class merging, member inlining and access relaxation across
|
||||
# a library whose invariants we are already stretching. A wrong outcome here is a
|
||||
# crash in the app's single core surface — the terminal — visible ONLY in release.
|
||||
# 3. The cost is bounded and small: two Apache-2.0 modules, ~120 KB of classes.
|
||||
#
|
||||
# So: keep them whole. Revisit only with a device-verified minified build in hand.
|
||||
-keep class com.termux.terminal.** { *; }
|
||||
-keep class com.termux.view.** { *; }
|
||||
|
||||
|
||||
# ── ML Kit / Firebase component discovery (reflective no-arg constructors) ───────
|
||||
#
|
||||
# OBSERVED, not speculative. Installing the minified `benchmark` APK on an API-35
|
||||
# emulator and cold-starting it produced, with no rule here:
|
||||
#
|
||||
# W ComponentDiscovery: Invalid component registrar.
|
||||
# Could not instantiate com.google.mlkit.common.internal.CommonComponentRegistrar
|
||||
# ... at com.google.mlkit.common.internal.MlKitInitProvider.onCreate
|
||||
# Caused by: java.lang.NoSuchMethodException:
|
||||
# com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []
|
||||
# (and the same for com.google.mlkit.vision.common.internal.VisionCommonRegistrar)
|
||||
#
|
||||
# Firebase's ComponentDiscovery instantiates registrars named in <meta-data> by
|
||||
# `getDeclaredConstructor()`. The class names survive, but R8 removed the no-arg
|
||||
# constructors because nothing in the app calls them. The failure is a WARNING, not a
|
||||
# crash: the app starts fine and the ML Kit component graph is simply never registered,
|
||||
# so **QR pairing (A19 barcode scanning) silently stops working in minified builds
|
||||
# only**. Exactly the release-only breakage this file has to prevent.
|
||||
#
|
||||
# `<init>();` (no access modifier) matches any visibility, which is what
|
||||
# getDeclaredConstructor needs. Scoped to registrars, so it keeps ~a dozen tiny classes.
|
||||
-keep class * implements com.google.firebase.components.ComponentRegistrar {
|
||||
<init>();
|
||||
}
|
||||
|
||||
|
||||
# ── javax.naming does not exist on Android ──────────────────────────────────────
|
||||
#
|
||||
# R8 refuses to run without these two, so they are load-bearing for ANY minified build.
|
||||
# They are NOT a benign suppression — read this before deleting them:
|
||||
#
|
||||
# :client-tls CertificateSummary.kt uses `javax.naming.ldap.LdapName` to pull the CN
|
||||
# out of an RFC2253 DN. That package is JDK-only; it is absent from android.jar and
|
||||
# from the ART bootclasspath. So on a real device `CertificateSummaryReader.commonName`
|
||||
# throws NoClassDefFoundError, which is an Error and therefore slips straight through
|
||||
# that function's `catch (_: Exception)` guard.
|
||||
#
|
||||
# The module is pure-JVM and its unit tests run on a real JDK where LdapName DOES
|
||||
# exist, which is why the green JVM suite never caught it. Tracked as a handoff to
|
||||
# :client-tls's owner (hand-parse the DN, or catch Throwable). This -dontwarn only
|
||||
# stops R8 from failing the build over an unresolvable reference; it does not and
|
||||
# cannot fix the device behaviour.
|
||||
-dontwarn javax.naming.ldap.LdapName
|
||||
-dontwarn javax.naming.ldap.Rdn
|
||||
|
||||
|
||||
# ── Crash triage ────────────────────────────────────────────────────────────────
|
||||
# Keep line numbers so release stack traces are usable, and rename the source-file
|
||||
# attribute to a single constant so no original filenames leak in the APK. Retrace
|
||||
# release traces with app/build/outputs/mapping/release/mapping.txt (ARCHIVE IT with
|
||||
# every distributed artifact — without it a crash report is unreadable).
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
Reference in New Issue
Block a user