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:
@@ -1,19 +1,133 @@
|
|||||||
# Android client — device-QA checklist (A36 / plan §7)
|
# Android client — device-QA checklist (A36 / plan §7)
|
||||||
|
|
||||||
Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator,
|
Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic
|
||||||
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
|
underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules),
|
||||||
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
|
but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM
|
||||||
|
suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a
|
||||||
|
cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because
|
||||||
|
no JVM test instantiates an Android `View` and no JVM test dials a socket.
|
||||||
|
|
||||||
|
**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where
|
||||||
|
noted, an emulator) — and say what you observed. Never tick something because the code looks right or
|
||||||
|
a unit test covers it. Unticked is the honest default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Observed so far (the only on-device evidence that exists)
|
||||||
|
|
||||||
|
**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):**
|
||||||
|
|
||||||
|
- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W`
|
||||||
|
→ `Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a
|
||||||
|
meaningful performance number, only proof of a clean start). Process stayed alive;
|
||||||
|
`logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`.
|
||||||
|
This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release.
|
||||||
|
- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8
|
||||||
|
(`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the
|
||||||
|
`主机地址(http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token
|
||||||
|
theme + Hilt injection therefore all survive minification.
|
||||||
|
- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30).
|
||||||
|
- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before
|
||||||
|
the `ComponentRegistrar` keep rule it logged
|
||||||
|
`NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []`,
|
||||||
|
which would have silently disabled **QR barcode scanning in release builds only**.
|
||||||
|
|
||||||
|
**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because
|
||||||
|
no default options were found` — there is no `google-services.json` yet (see the deploy artifacts
|
||||||
|
below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui`
|
||||||
|
under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`).
|
||||||
|
|
||||||
|
**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected
|
||||||
|
to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Deploy artifacts to provide first (not built here)
|
## Deploy artifacts to provide first (not built here)
|
||||||
|
- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they
|
||||||
|
exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}`
|
||||||
|
in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored
|
||||||
|
first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore
|
||||||
|
out of the repo — a lost signing key cannot be recovered.
|
||||||
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
|
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
|
||||||
absence with `runCatching`, so the app runs without it; push just won't register).
|
absence with `runCatching`, so the app runs without it; push just won't register).
|
||||||
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
||||||
omitted it — applying it without the json fails the build).
|
omitted it — applying it without the json fails the build).
|
||||||
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
||||||
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the
|
SHA-256 (for the verified App Link `autoVerify`). Read it with
|
||||||
`FCM_*` env group (service-account key path + project id).
|
`keytool -list -v -keystore <ks> -alias <alias> | grep SHA256`. Server-side: run `A33`
|
||||||
|
`src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device
|
||||||
|
|
||||||
|
These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none
|
||||||
|
has been exercised on a device.** Until this block is ticked, nothing further in this file is worth
|
||||||
|
running, because every one of these sits on the path to the terminal.
|
||||||
|
|
||||||
|
- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed
|
||||||
|
`WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first
|
||||||
|
keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.)
|
||||||
|
- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an
|
||||||
|
alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude
|
||||||
|
session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll
|
||||||
|
(`onGenericMotionEvent`). (Was: `doScroll` → `handleKeyCode` → `mTermSession` NPE.)
|
||||||
|
- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and
|
||||||
|
focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable;
|
||||||
|
the subtree is now excluded from the autofill structure.)
|
||||||
|
- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering
|
||||||
|
into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the
|
||||||
|
cols×rows the server reports match what `TerminalResizeDriver` computed from
|
||||||
|
`TerminalRenderer.getFontWidth()/getFontLineSpacing()`.
|
||||||
|
- [ ] **Bare-LAN `ws://` connects.** Pair with `http://<lan-ip>:3000` and attach. (Was:
|
||||||
|
`network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives
|
||||||
|
`ws://` from `http://`.)
|
||||||
|
- [ ] **The tunnel domain is still TLS-only.** `http://<anything>.terminal.yaojia.wang` must be refused
|
||||||
|
(the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must
|
||||||
|
refuse a plaintext dial to a non-private host even if the config would allow it.
|
||||||
|
- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you
|
||||||
|
last touched drives the PTY size (latest-writer-wins) and reclaims full screen on
|
||||||
|
pane-show/window-focus without a re-attach.
|
||||||
|
- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS
|
||||||
|
upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an
|
||||||
|
AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to
|
||||||
|
plaintext. Also verify the cookie never appears in `logcat`.
|
||||||
|
⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate
|
||||||
|
is inert at runtime no matter what the server does.
|
||||||
|
- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever
|
||||||
|
look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette**
|
||||||
|
(`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`).
|
||||||
|
- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were
|
||||||
|
both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a
|
||||||
|
real gate now shows the command being approved, and that a malformed preview does not cost the
|
||||||
|
whole frame (the gate must still appear).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate)
|
||||||
|
|
||||||
|
**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock
|
||||||
|
`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have
|
||||||
|
(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste).
|
||||||
|
`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote
|
||||||
|
terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on
|
||||||
|
the **Copy** button, which is worse than not offering it. Long press is therefore consumed and
|
||||||
|
selection never starts.
|
||||||
|
|
||||||
|
**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the
|
||||||
|
selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`.
|
||||||
|
Paste-in is unaffected and already works.
|
||||||
|
|
||||||
|
- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A34 — instrumented E2E vs a real `npm start` host
|
||||||
|
|
||||||
|
Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and
|
||||||
|
Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See
|
||||||
|
`android/README.md` → "Instrumented tests" for the one runner class that must be authored first.
|
||||||
|
|
||||||
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
|
|
||||||
- [ ] `attach → attached → output` round-trip timing.
|
- [ ] `attach → attached → output` round-trip timing.
|
||||||
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
||||||
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
|
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
|
||||||
@@ -22,15 +136,19 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
|
|||||||
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
||||||
|
|
||||||
## A35 — one macrobenchmark / Espresso happy path
|
## A35 — one macrobenchmark / Espresso happy path
|
||||||
|
|
||||||
|
The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s
|
||||||
|
`benchmark` variant out-of-process); it has **no sources yet**.
|
||||||
|
|
||||||
- [ ] pair → attach → type → approve, on a real device.
|
- [ ] pair → attach → type → approve, on a real device.
|
||||||
|
- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU
|
||||||
|
emulator number and must not be quoted as a baseline.
|
||||||
|
|
||||||
## Terminal (A16/A17/A21)
|
## Terminal (A16/A17/A21)
|
||||||
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
||||||
WebView fallback ONLY if fidelity diverges — not expected).
|
WebView fallback ONLY if fidelity diverges — not expected).
|
||||||
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
|
- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.)
|
||||||
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are
|
- [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5).
|
||||||
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs
|
|
||||||
web/iOS on the same device sizes (R5).
|
|
||||||
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
|
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
|
||||||
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
||||||
generation bump → fresh emulator replays.
|
generation bump → fresh emulator replays.
|
||||||
@@ -47,22 +165,27 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
|
|||||||
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
||||||
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
||||||
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
||||||
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
|
- [ ] token registers to every paired host + self-heals on rotation / new host / removal.
|
||||||
on rotation / new host / removal.
|
- [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so
|
||||||
|
re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug.
|
||||||
|
|
||||||
## Pairing / cert / storage (A19/A27/A11/A12)
|
## Pairing / cert / storage (A19/A27/A11/A12)
|
||||||
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
|
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified
|
||||||
|
variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once).
|
||||||
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
||||||
bypass); public host explicit-ack.
|
bypass); public host explicit-ack.
|
||||||
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
||||||
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
||||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
||||||
|
- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN /
|
||||||
|
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
|
||||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||||
|
|
||||||
## Nav / deep links / adaptive (A32/A26/A29)
|
## Nav / deep links / adaptive (A32/A26/A29)
|
||||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||||
right destination; invalid UUID ignored.
|
right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
|
||||||
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
|
`assetlinks.json`.)
|
||||||
|
- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.)
|
||||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||||
|
|
||||||
@@ -85,11 +208,32 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
|
|||||||
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
|
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
|
||||||
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
|
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
|
||||||
|
|
||||||
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
---
|
||||||
|
|
||||||
|
## Known gaps (tracked, non-blocking for QA but must be routed to an owner)
|
||||||
|
|
||||||
|
- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8
|
||||||
|
(`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`).
|
||||||
|
On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the
|
||||||
|
function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM
|
||||||
|
and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite
|
||||||
|
never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes.
|
||||||
|
Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in
|
||||||
|
`proguard-rules.pro` only unblocks R8; it changes nothing at runtime.
|
||||||
|
- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is
|
||||||
|
inert at runtime even though every piece is implemented and tested.
|
||||||
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
||||||
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
||||||
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
||||||
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
||||||
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView`
|
- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a
|
||||||
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
|
`dataExtractionRules` `<device-transfer>` xml resource. Until then a D2D migration could copy the
|
||||||
`:app` and delete the shim.
|
sealed auth cookie to another handset.
|
||||||
|
- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on
|
||||||
|
`ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from
|
||||||
|
a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not
|
||||||
|
use `--rerun-tasks` in a release gate.
|
||||||
|
|
||||||
|
> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** —
|
||||||
|
> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no
|
||||||
|
> reflective getter left to break under R8 and no keep rule is needed for one.
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
|
|||||||
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||||
upward* (ARCHITECTURE §1).
|
upward* (ARCHITECTURE §1).
|
||||||
|
|
||||||
|
> **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts
|
||||||
|
> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from
|
||||||
|
> a device**, and almost nothing in
|
||||||
|
> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is
|
||||||
|
> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything
|
||||||
|
> here done.
|
||||||
|
|
||||||
## Build environment (SDK installed — all modules build)
|
## Build environment (SDK installed — all modules build)
|
||||||
|
|
||||||
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
||||||
@@ -19,10 +26,12 @@ against SDK 35/36.
|
|||||||
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
||||||
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
||||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
||||||
|
- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only
|
||||||
|
on a device/emulator).
|
||||||
|
|
||||||
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
||||||
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
||||||
`./gradlew test :app:assembleDebug koverVerify`.
|
`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`.
|
||||||
|
|
||||||
## Module map (mirror of the iOS SPM packages — plan §3)
|
## Module map (mirror of the iOS SPM packages — plan §3)
|
||||||
|
|
||||||
@@ -37,10 +46,15 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
|
|||||||
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
||||||
|
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
|
||||||
|
|
||||||
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
|
||||||
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
`com.android.test` module — a **separate APK** that drives `:app` out of process via
|
||||||
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
|
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
|
||||||
|
violate "dependencies only flow down": nothing depends on it, and it reaches `:app`
|
||||||
|
through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s
|
||||||
|
`benchmark` variant (see "Build types" below). The benchmark sources themselves are not
|
||||||
|
written yet.
|
||||||
|
|
||||||
### Dependency graph (arrows = "depends on")
|
### Dependency graph (arrows = "depends on")
|
||||||
|
|
||||||
@@ -82,15 +96,124 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Use the committed wrapper for everything.
|
# Use the committed wrapper for everything.
|
||||||
./gradlew help # sanity: the build configures
|
./gradlew help # sanity: the build configures
|
||||||
./gradlew projects # lists the 5 pure modules
|
./gradlew projects # lists every module
|
||||||
./gradlew build # compile all pure modules
|
./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
||||||
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
./gradlew :app:assembleDebug # the installable debug APK
|
||||||
|
./gradlew koverVerify # the ≥80% gate on the pure modules
|
||||||
|
|
||||||
|
# Release path (needs a keystore — see "Release signing")
|
||||||
|
./gradlew :app:assembleRelease # R8 + resource shrinking + signing
|
||||||
|
./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean
|
||||||
|
|
||||||
|
# Minified build WITHOUT a keystore — the practical way to test R8 keep rules
|
||||||
|
./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable
|
||||||
```
|
```
|
||||||
|
|
||||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||||
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
||||||
> small focused files — same discipline as the rest of the repo.
|
> small focused files — same discipline as the rest of the repo.
|
||||||
|
>
|
||||||
|
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
|
||||||
|
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
|
||||||
|
> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state.
|
||||||
|
|
||||||
|
## Build types
|
||||||
|
|
||||||
|
| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) |
|
||||||
|
| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact |
|
||||||
|
| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore |
|
||||||
|
|
||||||
|
`benchmark` exists because a benchmark must measure the code that actually ships. It sets
|
||||||
|
`isProfileable = true`, which makes AGP inject `<profileable android:shell="true"/>` into
|
||||||
|
the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping
|
||||||
|
manifest carries no benchmark-only tag.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`.
|
||||||
|
|
||||||
|
- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone**
|
||||||
|
(a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately
|
||||||
|
NOT derived from `versionName`; keeping them independent is what lets a hotfix ship
|
||||||
|
without renumbering. It is still `1` because no artifact has ever left the build machine;
|
||||||
|
the first distributed build takes `2`.
|
||||||
|
- **`versionName`** is `<server-line>-alphaNN`. The client tracks the server's `0.1.x` line
|
||||||
|
(root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device
|
||||||
|
verification, not marketing:
|
||||||
|
- `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today
|
||||||
|
- `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware.
|
||||||
|
- `0.1.0` — the whole checklist is ticked.
|
||||||
|
|
||||||
|
Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN`
|
||||||
|
only when the server line moves.
|
||||||
|
|
||||||
|
## Release signing
|
||||||
|
|
||||||
|
There is **no keystore in this repository and there must never be one.** Credentials are
|
||||||
|
read at configuration time from the first of these that has them:
|
||||||
|
|
||||||
|
1. `android/keystore.properties` — the conventional path. Copy
|
||||||
|
[`keystore.properties.example`](keystore.properties.example).
|
||||||
|
⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.**
|
||||||
|
The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename.
|
||||||
|
2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys.
|
||||||
|
3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI.
|
||||||
|
|
||||||
|
Keys: `webterm.release.storeFile` (absolute, or relative to `android/`),
|
||||||
|
`.storePassword`, `.keyAlias`, `.keyPassword`.
|
||||||
|
|
||||||
|
**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest`
|
||||||
|
and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with
|
||||||
|
instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine
|
||||||
|
still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk`
|
||||||
|
(which is exactly what it used to do).
|
||||||
|
|
||||||
|
**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact** —
|
||||||
|
release builds keep line numbers but obfuscate names, so without the mapping file a crash
|
||||||
|
report cannot be retraced.
|
||||||
|
|
||||||
|
### R8 / keep rules
|
||||||
|
|
||||||
|
`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most
|
||||||
|
of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX)
|
||||||
|
ships its own consumer rules and must not be re-declared. Two rules are load-bearing and
|
||||||
|
were both derived from **observed** failures, not guesswork:
|
||||||
|
|
||||||
|
- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the
|
||||||
|
pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`)
|
||||||
|
and the JitPack artifacts ship no consumer rules.
|
||||||
|
- `implements com.google.firebase.components.ComponentRegistrar { <init>(); }`, because
|
||||||
|
without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and
|
||||||
|
**QR scanning silently stops working in release builds only**.
|
||||||
|
|
||||||
|
Before adding a rule, read the merged configuration R8 actually used:
|
||||||
|
`app/build/outputs/mapping/<variant>/configuration.txt`. To validate rules, install the
|
||||||
|
`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug.
|
||||||
|
|
||||||
|
## Instrumented tests
|
||||||
|
|
||||||
|
`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test +
|
||||||
|
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
|
||||||
|
`wang.yaojia.webterm.HiltTestRunner`.
|
||||||
|
|
||||||
|
⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write,
|
||||||
|
into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class HiltTestRunner : AndroidJUnitRunner() {
|
||||||
|
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
|
||||||
|
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A custom runner is **required**, not stylistic: `newApplication` is the only hook that can
|
||||||
|
replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting
|
||||||
|
`android:name` in the androidTest manifest cannot do it — that manifest is merged into the
|
||||||
|
*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the
|
||||||
|
class (the runner name is just a manifest value); an actual instrumentation *run* needs it.
|
||||||
|
|
||||||
## Android SDK setup (proven working)
|
## Android SDK setup (proven working)
|
||||||
|
|
||||||
@@ -127,5 +250,32 @@ AGP library module compiled against SDK 35 and produced an AAR):
|
|||||||
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
||||||
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
||||||
|
|
||||||
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
|
- **A `com.android.test` module cannot use a versioned plugin alias.** The root build
|
||||||
|
script already puts AGP on the classpath via the `android.library`/`android.application`
|
||||||
|
aliases, so a third versioned request for the same artifact fails with *"plugin is already
|
||||||
|
on the classpath with an unknown version"*. `:macrobenchmark` therefore applies
|
||||||
|
`id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The
|
||||||
|
version is still pinned once, as `agp` in the catalog.
|
||||||
|
|
||||||
|
## Emulator (installed)
|
||||||
|
|
||||||
|
An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the
|
||||||
|
on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANDROID_HOME=/usr/local/share/android-commandlinetools
|
||||||
|
$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect &
|
||||||
|
$ANDROID_HOME/platform-tools/adb devices -l
|
||||||
|
|
||||||
|
# validate the MINIFIED build (this is what catches bad keep rules)
|
||||||
|
./gradlew :app:assembleBenchmark
|
||||||
|
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
|
||||||
|
adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity
|
||||||
|
adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and
|
||||||
|
useless for performance numbers. Real macrobenchmark figures need physical hardware.
|
||||||
|
|
||||||
|
To add more SDK pieces later:
|
||||||
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
@@ -17,6 +19,83 @@ plugins {
|
|||||||
alias(libs.plugins.hilt)
|
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 {
|
android {
|
||||||
namespace = "wang.yaojia.webterm"
|
namespace = "wang.yaojia.webterm"
|
||||||
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
||||||
@@ -28,24 +107,87 @@ android {
|
|||||||
applicationId = "wang.yaojia.webterm"
|
applicationId = "wang.yaojia.webterm"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
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
|
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 {
|
buildFeatures {
|
||||||
compose = true
|
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 {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
||||||
}
|
}
|
||||||
release {
|
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(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"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(libs.bundles.unit.test)
|
||||||
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
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.
|
// 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 {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
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
|
# WebTerm :app — R8 keep rules (release + benchmark; both isMinifyEnabled = true).
|
||||||
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
|
#
|
||||||
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
|
# 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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
## Version catalog — single source of truth for plugin & dependency versions.
|
## Version catalog — single source of truth for plugin & dependency versions.
|
||||||
## Pure JVM modules only (this env has NO Android SDK). See android/README.md.
|
## Covers BOTH the pure Kotlin/JVM modules and the Android-framework modules (the
|
||||||
|
## SDK is installed on this machine — see android/README.md "Android SDK setup").
|
||||||
## Kotlin pinned to 2.3.21 to match the Kotlin embedded in Gradle 9.6.1 (best
|
## Kotlin pinned to 2.3.21 to match the Kotlin embedded in Gradle 9.6.1 (best
|
||||||
## compatibility with the Kotlin Gradle Plugin on Gradle 9.6).
|
## compatibility with the Kotlin Gradle Plugin on Gradle 9.6).
|
||||||
|
|
||||||
@@ -52,10 +53,18 @@ datastore = "1.1.1"
|
|||||||
# listenablefuture conflict dep is unnecessary here).
|
# listenablefuture conflict dep is unnecessary here).
|
||||||
termux = "v0.118.0"
|
termux = "v0.118.0"
|
||||||
# androidx-test — lets androidTest (instrumented) sources COMPILE here; they only
|
# androidx-test — lets androidTest (instrumented) sources COMPILE here; they only
|
||||||
# RUN on a device (no emulator installed → deferred to device QA, plan §7).
|
# RUN on a device/emulator (plan §7 — the A34 E2E suite needs a real `npm start` host).
|
||||||
androidxTestExtJunit = "1.2.1"
|
androidxTestExtJunit = "1.2.1"
|
||||||
androidxTestCore = "1.6.1"
|
androidxTestCore = "1.6.1"
|
||||||
androidxTestRunner = "1.6.2"
|
androidxTestRunner = "1.6.2"
|
||||||
|
androidxTestRules = "1.6.1"
|
||||||
|
# Espresso + UiAutomator drive the A35 happy path (in-process Compose assertions and
|
||||||
|
# out-of-process device interaction respectively). Pinned to the androidx-test 1.6 line.
|
||||||
|
espresso = "3.6.1"
|
||||||
|
uiautomator = "2.3.0"
|
||||||
|
# Macrobenchmark (A35) — startup/frame timing measured from a SEPARATE `com.android.test`
|
||||||
|
# module against a non-debuggable, minified `benchmark` variant of :app.
|
||||||
|
benchmark = "1.3.4"
|
||||||
# QR pairing (A19): CameraX preview/analysis + on-device ML Kit barcode scanning.
|
# QR pairing (A19): CameraX preview/analysis + on-device ML Kit barcode scanning.
|
||||||
camerax = "1.4.1"
|
camerax = "1.4.1"
|
||||||
mlkitBarcode = "17.3.0"
|
mlkitBarcode = "17.3.0"
|
||||||
@@ -127,6 +136,20 @@ termux-terminal-emulator = { module = "com.github.termux.termux-app:terminal-emu
|
|||||||
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" }
|
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" }
|
||||||
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" }
|
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" }
|
||||||
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
|
||||||
|
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" }
|
||||||
|
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
||||||
|
androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
|
||||||
|
# Compose UI test — BOM-managed (do NOT pin). `ui-test-manifest` is a debugImplementation:
|
||||||
|
# it supplies the empty activity `createComposeRule()` launches into.
|
||||||
|
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||||
|
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
|
# Hilt instrumented testing — supplies `HiltAndroidRule` + the generated
|
||||||
|
# `dagger.hilt.android.testing.HiltTestApplication` that :app's custom runner installs.
|
||||||
|
# REQUIRES kspAndroidTest(hilt-compiler); without the processor HiltTestApplication is
|
||||||
|
# never generated and the runner will not compile.
|
||||||
|
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
|
||||||
|
# Macrobenchmark (A35) — used by the :macrobenchmark `com.android.test` module only.
|
||||||
|
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
|
||||||
|
|
||||||
# QR pairing camera (A19)
|
# QR pairing camera (A19)
|
||||||
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
|
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
|
||||||
@@ -148,6 +171,17 @@ unit-test = ["junit-jupiter", "kotlinx-coroutines-test", "turbine", "mockk"]
|
|||||||
# CameraX (A19 QR pairing) — preview + analysis + PreviewView.
|
# CameraX (A19 QR pairing) — preview + analysis + PreviewView.
|
||||||
camerax = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-view"]
|
camerax = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-view"]
|
||||||
|
|
||||||
|
# Instrumented (androidTest) foundation — wired into :app's androidTestImplementation.
|
||||||
|
# JUnit4 arrives transitively via androidx.test.ext:junit (AndroidJUnitRunner is JUnit4-only;
|
||||||
|
# the JVM unit tests stay on the JUnit5 platform, which instrumented tasks never touch).
|
||||||
|
android-instrumented-test = [
|
||||||
|
"androidx-test-ext-junit",
|
||||||
|
"androidx-test-core",
|
||||||
|
"androidx-test-runner",
|
||||||
|
"androidx-test-rules",
|
||||||
|
"androidx-test-espresso-core",
|
||||||
|
]
|
||||||
|
|
||||||
# Compose UI + Material 3 (+ Adaptive) — wired into :app implementation. All
|
# Compose UI + Material 3 (+ Adaptive) — wired into :app implementation. All
|
||||||
# BOM-managed; add ui-tooling separately as a debugImplementation.
|
# BOM-managed; add ui-tooling separately as a debugImplementation.
|
||||||
compose = [
|
compose = [
|
||||||
@@ -167,6 +201,12 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
|||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
# NOTE: there is deliberately NO `android-test` alias for :macrobenchmark's
|
||||||
|
# `com.android.test` plugin. The two aliases above put AGP on the build classpath with
|
||||||
|
# an "unknown version", so a THIRD versioned alias for the same artifact is rejected
|
||||||
|
# ("plugin is already on the classpath with an unknown version"). :macrobenchmark
|
||||||
|
# therefore applies `id("com.android.test")` bare and inherits the `agp` version above —
|
||||||
|
# exactly the pattern :terminal-view uses for `id("com.android.library")`.
|
||||||
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
|
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
|
||||||
# NOTE: AGP 9+ has BUILT-IN Kotlin support — Android modules apply only the
|
# NOTE: AGP 9+ has BUILT-IN Kotlin support — Android modules apply only the
|
||||||
# android plugin, NOT a separate org.jetbrains.kotlin.android (it errors out).
|
# android plugin, NOT a separate org.jetbrains.kotlin.android (it errors out).
|
||||||
|
|||||||
31
android/keystore.properties.example
Normal file
31
android/keystore.properties.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# TEMPLATE ONLY — copy to `keystore.properties` and fill in. NEVER commit the copy.
|
||||||
|
#
|
||||||
|
# cp keystore.properties.example keystore.properties
|
||||||
|
#
|
||||||
|
# BEFORE you create the real file, make sure `keystore.properties` is listed in
|
||||||
|
# android/.gitignore. If it is not there yet, use `local.properties` instead — that
|
||||||
|
# path is ALREADY gitignored and :app reads the same four keys from it.
|
||||||
|
#
|
||||||
|
# Resolution order in app/build.gradle.kts (first non-blank wins):
|
||||||
|
# 1. android/keystore.properties
|
||||||
|
# 2. android/local.properties
|
||||||
|
# 3. WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI)
|
||||||
|
#
|
||||||
|
# With none of them set, debug + benchmark builds work normally and
|
||||||
|
# `:app:assembleRelease` FAILS at packaging with instructions — it never emits an
|
||||||
|
# unsigned APK.
|
||||||
|
#
|
||||||
|
# Generate a keystore OUTSIDE the repository:
|
||||||
|
# keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \
|
||||||
|
# -alias webterm -keyalg RSA -keysize 4096 -validity 10000
|
||||||
|
#
|
||||||
|
# Then read its SHA-256 (needed for the App Links assetlinks.json — plan §8):
|
||||||
|
# keytool -list -v -keystore ~/.webterm/webterm-release.jks -alias webterm | grep SHA256
|
||||||
|
|
||||||
|
# Path to the keystore. Absolute, or relative to the `android/` directory.
|
||||||
|
# Keep the keystore itself out of the repo (android/.gitignore blocks *.jks / *.keystore,
|
||||||
|
# but out-of-tree is the safer habit — a lost signing key cannot be recovered).
|
||||||
|
webterm.release.storeFile=/absolute/path/to/webterm-release.jks
|
||||||
|
webterm.release.storePassword=CHANGE_ME
|
||||||
|
webterm.release.keyAlias=webterm
|
||||||
|
webterm.release.keyPassword=CHANGE_ME
|
||||||
89
android/macrobenchmark/build.gradle.kts
Normal file
89
android/macrobenchmark/build.gradle.kts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// :macrobenchmark (A35) — the macrobenchmark harness module.
|
||||||
|
//
|
||||||
|
// `com.android.test`, NOT `com.android.library`: macrobenchmark must drive the app
|
||||||
|
// OUT of process (its own APK, its own process, UiAutomator across the boundary) so
|
||||||
|
// that measured startup/frame timings are the real ones and not perturbed by the
|
||||||
|
// test framework sharing the app's process.
|
||||||
|
//
|
||||||
|
// It instruments :app's `benchmark` variant — non-debuggable, R8'd and
|
||||||
|
// resource-shrunk like release, but signed with the debug key so no release keystore
|
||||||
|
// is needed (see app/build.gradle.kts → buildTypes.benchmark).
|
||||||
|
//
|
||||||
|
// This module currently has NO sources: the benchmark itself (pair → attach → type →
|
||||||
|
// approve, plus a cold-start timing) is the next task's deliverable and belongs in
|
||||||
|
// src/main/kotlin/. The module exists so that work is a matter of adding a file.
|
||||||
|
//
|
||||||
|
// Run (device/emulator required — `./gradlew :app:assembleDebug` style CI cannot):
|
||||||
|
// ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest
|
||||||
|
//
|
||||||
|
// AGP 9 has built-in Kotlin → apply ONLY the android plugin (no kotlin.android).
|
||||||
|
// The plugin id is applied BARE (no catalog alias / no version): the root build script
|
||||||
|
// already puts AGP on the classpath via the android.library + android.application
|
||||||
|
// aliases, and Gradle rejects a third versioned request for the same plugin artifact
|
||||||
|
// ("already on the classpath with an unknown version"). The version is still pinned in
|
||||||
|
// one place — `agp` in gradle/libs.versions.toml. Same pattern as :terminal-view.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "wang.yaojia.webterm.macrobenchmark"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// Macrobenchmark itself needs API 23+; matching :app's 29 keeps the variant
|
||||||
|
// matrix honest (a benchmark on an SDK :app cannot run on is meaningless).
|
||||||
|
minSdk = 29
|
||||||
|
targetSdk = 35
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
// The only variant this module is built in. `isDebuggable = true` applies to the
|
||||||
|
// TEST apk (never to :app) — required so the harness can be instrumented at all.
|
||||||
|
create("benchmark") {
|
||||||
|
isDebuggable = true
|
||||||
|
signingConfig = getByName("debug").signingConfig
|
||||||
|
// :app publishes debug/release/benchmark; libraries publish debug/release.
|
||||||
|
matchingFallbacks += listOf("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetProjectPath = ":app"
|
||||||
|
|
||||||
|
// The harness runs in its OWN process and reaches the app only through UiAutomator /
|
||||||
|
// shell, never by linking against its classes. Without this, AGP's
|
||||||
|
// `checkTestedAppObfuscationBenchmark` refuses to build a non-minified test module
|
||||||
|
// against a minified app — a check that is correct for Espresso-style tests (which do
|
||||||
|
// link app symbols) and a false positive for macrobenchmark.
|
||||||
|
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the `benchmark` variant is meaningful; disable the stock debug/release ones so
|
||||||
|
// `./gradlew build` does not try to assemble a harness against a variant of :app that
|
||||||
|
// does not exist.
|
||||||
|
androidComponents {
|
||||||
|
beforeVariants { variant ->
|
||||||
|
variant.enable = variant.buildType == "benchmark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// A `com.android.test` module's `main` source set IS its test code — deps go in
|
||||||
|
// implementation, not androidTestImplementation.
|
||||||
|
implementation(libs.androidx.test.ext.junit)
|
||||||
|
implementation(libs.androidx.test.uiautomator)
|
||||||
|
implementation(libs.androidx.benchmark.macro.junit4)
|
||||||
|
}
|
||||||
@@ -51,3 +51,10 @@ include(":app") // A13 — libs.plugins.android.application + c
|
|||||||
include(":terminal-view") // A16 — android-library + Termux terminal-emulator/-view (JitPack, Apache-2.0)
|
include(":terminal-view") // A16 — android-library + Termux terminal-emulator/-view (JitPack, Apache-2.0)
|
||||||
include(":host-registry") // A12 — android-library + DataStore (Host list / last-session)
|
include(":host-registry") // A12 — android-library + DataStore (Host list / last-session)
|
||||||
include(":client-tls-android") // A11 — android-library + AndroidKeyStore import + Tink AEAD
|
include(":client-tls-android") // A11 — android-library + AndroidKeyStore import + Tink AEAD
|
||||||
|
|
||||||
|
// ── Instrumented-only module (no production code, never on :app's classpath) ─────
|
||||||
|
// :macrobenchmark (A35) — a `com.android.test` module that drives :app's `benchmark`
|
||||||
|
// variant OUT of process via UiAutomator. Separate APK by construction, so it cannot
|
||||||
|
// violate the "dependencies only flow down" rule: nothing depends on it, and it depends
|
||||||
|
// on :app only through `targetProjectPath`, not a project() dependency.
|
||||||
|
include(":macrobenchmark") // A35 — libs.plugins.android.test + benchmark-macro-junit4
|
||||||
|
|||||||
Reference in New Issue
Block a user