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:
Yaojia Wang
2026-07-30 12:14:53 +02:00
parent b09b90e5bb
commit c1612d0145
8 changed files with 784 additions and 36 deletions

View File

@@ -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) }
}
}