diff --git a/.claude/skills/preflight/SKILL.md b/.claude/skills/preflight/SKILL.md index a64e159..47a8343 100644 --- a/.claude/skills/preflight/SKILL.md +++ b/.claude/skills/preflight/SKILL.md @@ -1,6 +1,6 @@ --- name: preflight -description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the latest-API-level emulator E2E before pushing or opening a PR. Mirrors the merge gate and runs the highest-API E2E via its Gradle Managed Device; the full multi-API matrix stays CI-only. Use before treating a change as done. +description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the top-of-matrix emulator E2E (currently API 35 + 36 via Gradle Managed Devices, then API 37 preview via the hand-provisioning script) before pushing or opening a PR. Mirrors the merge gate; the rest of the multi-API matrix stays CI-only. Use before treating a change as done. --- # /preflight @@ -13,12 +13,20 @@ Run the same fast checks CI enforces on every PR, in order, and report the outco with a JDK/AGP version mismatch, check `java -version` / `JAVA_HOME` and point it at a 17–21 JDK (e.g. Android Studio's bundled JBR) before retrying. - PowerShell: invoke the wrapper as `.\gradlew`. Git Bash / the Bash tool: `./gradlew`. -- The final E2E step boots an emulator through a Gradle Managed Device, so the host needs - hardware acceleration (WHPX on Windows, KVM on Linux, HVF on macOS). Gradle downloads the - system image and boots/tears down the AVD itself — no manual emulator setup — but the first - run is slow while the image downloads. If the host has no accelerated emulator and the - managed device cannot boot, report the E2E step as not run rather than treating the gate as - green. +- The final three E2E steps each boot an emulator, so the host needs a **free hardware + hypervisor** (Intel VT-x / AMD-V, exposed as WHPX on Windows, KVM on Linux, HVF on macOS). + Shut down VirtualBox, Hyper-V-based VMs, WSL2, Docker Desktop, or any other emulator first — a + VM holding the hypervisor starves the AVD, and it hangs at 0% CPU and never reaches + `sys.boot_completed`. The api35/api36 steps run through Gradle Managed Devices (Gradle + downloads the image and boots/tears down each AVD itself); the api37 step is hand-provisioned + by `api37_e2e.py` (see Steps). The first run per API level is slow while its system image + downloads. If the host has no accelerated emulator and a device cannot boot, report the E2E + step as not run rather than treating the gate as green. +- The api37 step is a stdlib-only, cross-platform **Python 3** script and needs `python3` plus + the Android SDK command-line tools (`sdkmanager`/`avdmanager`) and `emulator` on the machine, + located via `ANDROID_SDK_ROOT`/`ANDROID_HOME` (or the per-OS default: + `%LOCALAPPDATA%\Android\Sdk` on Windows, `~/Library/Android/sdk` on macOS, `~/Android/Sdk` on + Linux). The script installs the preview system image itself on first run. ## Steps @@ -30,7 +38,9 @@ Run these, stopping at the first failure: ./gradlew :app:compileDebugAndroidTestKotlin ./gradlew :app:lintDebug ./gradlew :app:ktlintCheck :app:detekt -./gradlew :app:api36DebugAndroidTest # latest-API emulator E2E (highest level in the matrix) +./gradlew :app:api35DebugAndroidTest # top-of-matrix emulator E2E (2nd-highest stable level) +./gradlew :app:api36DebugAndroidTest # top-of-matrix emulator E2E (highest stable level) +python3 .claude/skills/preflight/api37_e2e.py # api37 preview E2E (hand-provisioned; on Windows: py or python) ``` `compileDebugAndroidTestKotlin` compiles the `androidTest` source set — the E2E/instrumented @@ -44,24 +54,44 @@ merge gate even when the build and lint are green. Add `--continue` to any comma `:app:ktlintCheck :app:detekt --continue`) to collect every failure in one pass instead of stopping at the first. -`api36DebugAndroidTest` is last because it is the slowest: it runs the full instrumented/E2E -suite on `api36` — the highest API level in the E2E matrix — via its Gradle Managed Device, -which Gradle provisions, boots, and tears down automatically. This one latest-API level is the -E2E that preflight runs locally and it must pass; CI fans the same suite out across the whole -API matrix (plus the API 37 preview job). Keep `api36DebugAndroidTest` in lockstep with the -highest managed device in `app/build.gradle.kts` and the E2E matrix in -`.github/workflows/ci.yml` — when a newer API level is added there, run that one instead. +The three E2E steps run last because they are the slowest. `api35DebugAndroidTest` and +`api36DebugAndroidTest` run the full instrumented/E2E suite on `api35`, then `api36` — the top +two stable levels in the E2E matrix — each via its own Gradle Managed Device, which Gradle +provisions, boots, and tears down automatically. + +`api37_e2e.py` then runs the same suite on the **API 37 preview** emulator. API 37 has no Gradle +Managed Device — its only published system image is the nonstandard `android-37.0` / +`google_apis_ps16k` pairing, which neither `ManagedVirtualDevice`'s `apiLevel` (Int) nor +`apiPreview` (codename) DSL resolves (see the comment above `testOptions.managedDevices` in +`app/build.gradle.kts`), so there is no `api37DebugAndroidTest` task. The script hand-provisions +it essentially the way CI's `e2e-preview` job does — installs the image with `sdkmanager`, +creates the AVD with `avdmanager`, cold-boots it headless, waits for `sys.boot_completed`, runs +`:app:connectedDebugAndroidTest`, then kills the emulator and deletes the AVD. The emulator flags +match `e2e-preview` with one deliberate local exception: the **GPU mode**. CI uses +`-gpu swiftshader_indirect` (software rendering, deterministic on a headless CI runner); the +local run uses `-gpu auto-no-window`, which renders on the host GPU — faster, and the mode that +boots cleanly on a dev machine. + +All three levels are the E2E that preflight runs locally and all three must pass; CI fans the +same suite out across the whole matrix (API 29–36 in `e2e`, plus API 37 in `e2e-preview`). Keep +`api35DebugAndroidTest` / `api36DebugAndroidTest` in lockstep with the top of the managed-device +list in `app/build.gradle.kts`, and keep `api37_e2e.py` in lockstep with the `e2e-preview` job in +`.github/workflows/ci.yml` (same image string + emulator flags, apart from the intentional GPU-mode +difference noted above) — when a newer API level is added there, run the new top levels instead. ## Reporting -- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, api36 E2E"). +- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, api35+api36+api37 E2E"). - On failure, surface the actual Gradle error and point at the relevant report: - unit tests → `app/build/reports/tests/testDebugUnitTest/` - lint → `app/build/reports/lint-results-debug.html` - ktlint → `app/build/reports/ktlint/` (per source set, e.g. `ktlintTestSourceSetCheck/`) - detekt → `app/build/reports/detekt/` - - E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g. `.../api36/`) -- Run only the **latest-API** E2E here (`api36DebugAndroidTest`, the highest level in the - matrix); the full multi-API matrix and the API 37 preview job stay CI's job. If the host has - no accelerated emulator and the managed device cannot boot, report that E2E could not run - rather than treating the gate as green. + - api35/api36 E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g. + `.../api35/`, `.../api36/`) + - api37 E2E → `app/build/reports/androidTests/connected/` (the `connectedDebugAndroidTest` + report the script drives); the emulator's own boot log is at the temp path the script prints. +- Run the **top two stable-API** levels (`api35DebugAndroidTest` + `api36DebugAndroidTest`) plus + the **API 37 preview** via `api37_e2e.py`; the rest of the multi-API matrix (API 29–34) stays + CI's job. If the host has no accelerated emulator and a device cannot boot (see the hypervisor + note in Preconditions), report that E2E could not run rather than treating the gate as green. diff --git a/.claude/skills/preflight/api37_e2e.py b/.claude/skills/preflight/api37_e2e.py new file mode 100644 index 0000000..6a72e89 --- /dev/null +++ b/.claude/skills/preflight/api37_e2e.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Hand-provision an API 37 (Android 17, preview) emulator, run LibreMail's instrumented/E2E +suite against it, then tear the emulator and AVD down. Invoked by the /preflight skill as the +third (api37) E2E step, alongside the api35/api36 Gradle Managed Devices. + +WHY THIS SCRIPT EXISTS +---------------------- +API 37's only published system image is the nonstandard "android-37.0" / google_apis_ps16k +(16 KB page size) pairing. AGP's Gradle Managed Device DSL can only build an +"android-" package id (apiLevel = 37 -> "android-37") or an +"android-" one -- neither resolves to "android-37.0" -- so there is NO +api37DebugAndroidTest task to run. This script custom-provisions the emulator with +sdkmanager / avdmanager / emulator directly, closely mirroring CI's `e2e-preview` job in +.github/workflows/ci.yml (same system image string, same provisioning/boot sequence, same +emulator flags EXCEPT the GPU mode -- CI uses `-gpu swiftshader_indirect` for headless +determinism, while this local run uses `-gpu auto-no-window` to render on the host GPU, which +is faster; see start_emulator). Keep the two in lockstep on everything but that GPU flag: when a +stable, GMD-compatible API 37 image ships, delete this script, fold api37 into +testOptions.managedDevices, and fold 37 into CI's `e2e` matrix (dropping the `e2e-preview` job). + +Pure standard library, cross-platform (Windows / Linux / macOS): tool paths and executable +suffixes are resolved per-OS, and `.bat` launchers are wrapped through `cmd /c` on Windows. + +HYPERVISOR REQUIREMENT +---------------------- +The emulator boots with `-accel on`, so it needs a FREE hardware hypervisor (Intel VT-x / AMD-V, +exposed as WHPX on Windows, KVM on Linux, HVF on macOS). If VirtualBox, Hyper-V, WSL2, Docker +Desktop, or another emulator is holding it, `-accel on` fails or the AVD hangs at 0% CPU and never +reaches sys.boot_completed. Shut those down before running preflight. + +JDK: the final Gradle step needs a JDK 17-21 daemon (AGP 9.2 fails on JDK 25+), same as the rest +of preflight -- point JAVA_HOME at a 17-21 JDK before invoking. +""" + +from __future__ import annotations + +import argparse +import os +import platform +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +# Defaults mirror .github/workflows/ci.yml e2e-preview (env.API37_IMAGE, env.ANDROID_PLATFORM, +# env.ANDROID_BUILD_TOOLS) and its avdmanager invocation. Do not diverge without updating ci.yml. +API37_IMAGE = "system-images;android-37.0;google_apis_ps16k;x86_64" +PLATFORM_PKG = "platforms;android-37.0" +BUILD_TOOLS = "build-tools;37.0.0" +AVD_NAME = "api37" +DEVICE_PROFILE = "pixel_2" +BOOT_TIMEOUT = 300 + +IS_WINDOWS = os.name == "nt" +BAT = ".bat" if IS_WINDOWS else "" +EXE = ".exe" if IS_WINDOWS else "" + + +def cmd(tool: str, *args: str) -> list[str]: + """Build an argv list, wrapping Windows `.bat`/`.cmd` launchers through `cmd /c`.""" + if IS_WINDOWS and tool.lower().endswith((".bat", ".cmd")): + return ["cmd", "/c", tool, *args] + return [tool, *args] + + +def find_sdk_root() -> str: + candidates = [os.environ.get("ANDROID_SDK_ROOT"), os.environ.get("ANDROID_HOME")] + system = platform.system() + if system == "Windows": + local = os.environ.get("LOCALAPPDATA") + if local: + candidates.append(os.path.join(local, "Android", "Sdk")) + elif system == "Darwin": + candidates.append(os.path.expanduser("~/Library/Android/sdk")) + else: + candidates.append(os.path.expanduser("~/Android/Sdk")) + for candidate in candidates: + if candidate and os.path.isdir(candidate): + return os.path.abspath(candidate) + raise RuntimeError( + "Android SDK not found. Set ANDROID_SDK_ROOT (or ANDROID_HOME) to your SDK location." + ) + + +def resolve_tool(sdk_root: str, rel_paths: list[list[str]], name: str) -> str: + for rel in rel_paths: + path = os.path.join(sdk_root, *rel) + if os.path.isfile(path): + return path + raise RuntimeError( + f"Could not find {name} under {sdk_root}. " + "Install the Android SDK command-line tools + emulator." + ) + + +def run_sdkmanager(sdkmanager: str, args: list[str]) -> None: + # Feed a stream of "y" so any unaccepted (incl. preview) license prompt is auto-accepted; this + # is the non-interactive equivalent of the CI runner having licenses pre-accepted. + result = subprocess.run(cmd(sdkmanager, *args), input="y\n" * 50, text=True, check=False) + if result.returncode != 0: + raise RuntimeError(f"sdkmanager failed (exit {result.returncode}) for: {' '.join(args)}") + + +def create_avd(avdmanager: str, emulator: str) -> None: + print(f"Creating AVD '{AVD_NAME}' from {API37_IMAGE} (device: {DEVICE_PROFILE})...") + # "no" answers avdmanager's "create a custom hardware profile?" prompt, mirroring CI. + result = subprocess.run( + cmd(avdmanager, "create", "avd", "-n", AVD_NAME, "-k", API37_IMAGE, + "-d", DEVICE_PROFILE, "--force"), + input="no\n", text=True, check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"avdmanager create avd failed (exit {result.returncode}).") + print("AVDs visible to the emulator:") + subprocess.run(cmd(emulator, "-list-avds"), check=False) + + +def start_emulator(emulator: str, emu_log: Path, attempt: int) -> subprocess.Popen: + print(f"Starting API 37 emulator (attempt {attempt})...") + # Flags mirror .github/workflows/ci.yml e2e-preview (cold headless boot, hardware accel + # required, no cameras), with ONE deliberate LOCAL exception -- the GPU mode. CI uses + # `-gpu swiftshader_indirect` (software rendering, deterministic on a headless CI runner); + # locally we use `-gpu auto-no-window`, which renders on the host GPU: faster, and the mode + # that boots cleanly on a dev machine. Keep everything except the GPU mode in lockstep with + # that job. + flags = [ + "-avd", AVD_NAME, + "-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", "-accel", "on", + "-gpu", "auto-no-window", "-camera-back", "none", "-camera-front", "none", + ] + log = open(emu_log, "wb") # noqa: SIM115 - handed to the child; closed in the parent below + try: + proc = subprocess.Popen(cmd(emulator, *flags), stdout=log, stderr=subprocess.STDOUT) + finally: + log.close() # the child has inherited its own fd; the parent's copy is no longer needed + return proc + + +def wait_for_boot(adb: str, proc: subprocess.Popen, timeout: int) -> bool: + subprocess.run(cmd(adb, "start-server"), check=False) + deadline = time.monotonic() + timeout + + # Phase 1 -- the literal `adb wait-for-device`, bounded so a dead emulator can't hang the run + # (returns as soon as the emulator registers). Mirrors CI's `adb wait-for-device`. + try: + subprocess.run(cmd(adb, "wait-for-device"), timeout=max(1, deadline - time.monotonic()), + check=False) + except subprocess.TimeoutExpired: + return False + if proc.poll() is not None: + return False + + # Phase 2 -- poll sys.boot_completed until it flips to 1 (mirrors CI's getprop loop). + while time.monotonic() < deadline: + if proc.poll() is not None: + print(f"Emulator process exited during boot (code {proc.returncode}).", + file=sys.stderr) + return False + out = subprocess.run(cmd(adb, "shell", "getprop", "sys.boot_completed"), + capture_output=True, text=True, check=False) + if out.stdout.strip() == "1": + return True + time.sleep(2) + return False + + +def stop_emulator(adb: str | None, proc: subprocess.Popen | None) -> None: + if adb: + subprocess.run(cmd(adb, "emu", "kill"), check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(2) + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +def tail(path: Path, lines: int = 80) -> None: + try: + with open(path, "r", errors="replace") as handle: + content = handle.readlines()[-lines:] + print("--- emulator.log (tail) ---") + print("".join(content)) + except OSError: + pass + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Hand-provision + run the API 37 preview E2E suite.") + parser.add_argument("--boot-timeout", type=int, default=BOOT_TIMEOUT, + help="Seconds to wait for the emulator to reach sys.boot_completed.") + args = parser.parse_args() + + sdk_root = find_sdk_root() + print(f"Using Android SDK at: {sdk_root}") + sdkmanager = resolve_tool(sdk_root, [ + ["cmdline-tools", "latest", "bin", "sdkmanager" + BAT], + ["cmdline-tools", "bin", "sdkmanager" + BAT], + ["tools", "bin", "sdkmanager" + BAT], + ], "sdkmanager") + avdmanager = resolve_tool(sdk_root, [ + ["cmdline-tools", "latest", "bin", "avdmanager" + BAT], + ["cmdline-tools", "bin", "avdmanager" + BAT], + ["tools", "bin", "avdmanager" + BAT], + ], "avdmanager") + + # Pin ANDROID_AVD_HOME so avdmanager (writes it) and the emulator (reads it) agree on the AVD + # dir -- the same fix CI's e2e-preview applies to avoid "Unknown AVD name [api37]". + avd_home = Path.home() / ".android" / "avd" + avd_home.mkdir(parents=True, exist_ok=True) + os.environ["ANDROID_AVD_HOME"] = str(avd_home) + + emu_log = Path(tempfile.gettempdir()) / "libremail-api37-emulator.log" + adb: str | None = None + proc: subprocess.Popen | None = None + test_exit = 1 + + try: + # 1. Install the SDK platform, build-tools, platform-tools, emulator, and preview image. + print(f"Installing SDK packages + API 37 preview system image ({API37_IMAGE})...") + run_sdkmanager(sdkmanager, ["--licenses"]) + run_sdkmanager(sdkmanager, [PLATFORM_PKG, BUILD_TOOLS, "platform-tools", "emulator", + API37_IMAGE]) + + # adb + emulator are only guaranteed present after the install above. + adb = resolve_tool(sdk_root, [["platform-tools", "adb" + EXE]], "adb") + emulator = resolve_tool(sdk_root, [["emulator", "emulator" + EXE]], "emulator") + + # 2. Create the AVD, mirroring CI. + create_avd(avdmanager, emulator) + + # 3. Cold-boot headless, retrying once (mirrors CI's two-attempt boot loop). + booted = False + for attempt in (1, 2): + proc = start_emulator(emulator, emu_log, attempt) + if wait_for_boot(adb, proc, args.boot_timeout): + booted = True + break + print(f"API 37 emulator did not boot within {args.boot_timeout}s (attempt {attempt}).", + file=sys.stderr) + tail(emu_log) + stop_emulator(adb, proc) + proc = None + time.sleep(5) + if not booted: + raise RuntimeError("API 37 preview emulator failed to boot after 2 attempts.") + + # 4. Dismiss the keyguard, then run the instrumented/E2E suite against the booted emulator. + subprocess.run(cmd(adb, "shell", "input", "keyevent", "82"), check=False) + + repo_root = Path(__file__).resolve().parents[3] + gradlew = repo_root / ("gradlew.bat" if IS_WINDOWS else "gradlew") + print(f"Running :app:connectedDebugAndroidTest against {AVD_NAME}...") + test_exit = subprocess.run( + cmd(str(gradlew), ":app:connectedDebugAndroidTest", "--stacktrace"), + cwd=str(repo_root), check=False, + ).returncode + except Exception as exc: # noqa: BLE001 - top-level guard so teardown always runs + print(f"ERROR: {exc}", file=sys.stderr) + test_exit = 1 + finally: + # 5. Always tear the emulator down and delete the AVD, even on failure. + print("Tearing down API 37 emulator and AVD...") + stop_emulator(adb, proc) + subprocess.run(cmd(avdmanager, "delete", "avd", "-n", AVD_NAME), check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + if test_exit != 0: + print(f"api37 connectedDebugAndroidTest failed (exit {test_exit}).", file=sys.stderr) + return test_exit + print("api37 E2E passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/CLAUDE.md b/CLAUDE.md index 171d3af..1acc90b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,15 +28,22 @@ or via Gradle Managed Devices `./gradlew e2eGroupDebugAndroidTest` (whole matrix **Before treating a change as done**, run the fast CI gate: `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + -`detekt` + the latest-API emulator E2E `api36DebugAndroidTest` (the `/preflight` skill does -all of this). `compileDebugAndroidTestKotlin` compiles the `androidTest` source set that the -static part of the gate skips, catching E2E/instrumented-test compile errors before they -surface only in CI. `ktlintCheck`/`detekt` cover the `test`/`androidTest` source sets that -`lintDebug` skips, so they catch style violations that would otherwise fail CI's Static -analysis gate. `api36DebugAndroidTest` runs the instrumented/E2E suite on API 36 — the highest -API level in the E2E matrix — via its Gradle Managed Device (Gradle boots and tears down the -emulator automatically). Running that one latest-API level locally is required; the full -multi-API matrix (and the API 37 preview job) stays CI's job. +`detekt` + the top-of-matrix emulator E2E `api35DebugAndroidTest` + `api36DebugAndroidTest` + +the API 37 preview E2E via `python3 .claude/skills/preflight/api37_e2e.py` (the `/preflight` +skill does all of this). `compileDebugAndroidTestKotlin` compiles the `androidTest` source set +that the static part of the gate skips, catching E2E/instrumented-test compile errors before +they surface only in CI. `ktlintCheck`/`detekt` cover the `test`/`androidTest` source sets that +`lintDebug` skips, so they catch style violations that would otherwise fail CI's Static analysis +gate. `api35DebugAndroidTest` and `api36DebugAndroidTest` run the instrumented/E2E suite on the +top two stable API levels in the E2E matrix, each via its own Gradle Managed Device (Gradle boots +and tears down each emulator automatically). API 37 (preview) has no Gradle Managed Device — its +only image is the nonstandard `android-37.0` / `google_apis_ps16k` pairing (see the comment above +`testOptions.managedDevices` in `app/build.gradle.kts`) — so `api37_e2e.py` hand-provisions it, +mirroring CI's `e2e-preview` job (same image + emulator flags, except it uses host-GPU +`-gpu auto-no-window` locally vs CI's headless `-gpu swiftshader_indirect`), boots it headless, +runs `connectedDebugAndroidTest`, and tears it down. Running all three levels locally is required; the +rest of the multi-API matrix (API 29–34) stays CI's job. Emulators need a free hardware +hypervisor (VT-x/WHPX), so shut down VirtualBox/other VMs first or the AVD hangs at 0% CPU. ## Build-config gotchas @@ -70,9 +77,11 @@ pulled in as a real dependency for unit tests because `android.jar`'s version is A change is not done until it ships with passing **unit tests** and **E2E/instrumented tests** that exercise the new or changed behaviour. Writing and committing that E2E/instrumented test is a required part of every task — and the test must actually **run and pass**, not merely -compile: preflight runs the latest-API-level emulator E2E locally (`api36DebugAndroidTest`, the -highest API level in the E2E matrix and its Gradle Managed Device task) and it must be green -before the change is done. CI then runs the full multi-API matrix. +compile: preflight runs the top-of-matrix emulator E2E locally — `api35DebugAndroidTest` + +`api36DebugAndroidTest` (Gradle Managed Devices) plus the API 37 preview via +`api37_e2e.py` (hand-provisioned, mirroring CI's `e2e-preview` job) — and all three must be +green before the change is done. CI then runs the full multi-API matrix plus the API 37 preview +job. ## Repo etiquette diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4d6630..1f390ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,8 +146,23 @@ android { // `./gradlew api29DebugAndroidTest`. Gradle provisions/boots/tears down the emulators and // downloads the system images on first use. Keep this list in lockstep with the CI matrix in // .github/workflows/ci.yml; when a new Android ships, add it and drop the oldest level that - // has fallen outside ~7 years. API 37 (preview) is exercised on the dev emulator until a - // stable managed-device image is published, so it is intentionally not listed here. + // has fallen outside ~7 years. + // + // API 37 (preview) is intentionally NOT listed here (re-confirmed 2026-07, see PR that added + // API 35/37 to preflight): its only published system image is the nonstandard + // "android-37.0" / google_apis_ps16k pairing that the e2e-preview job in + // .github/workflows/ci.yml installs directly via sdkmanager. ManagedVirtualDevice only knows + // how to build an "android-" package id (e.g. `apiLevel = 37` → "android-37") + // or an "android-" one — neither produces "android-37.0" — so there is + // no DSL path to this image today, the same root cause documented on e2e-preview for why + // reactivecircus/android-emulator-runner can't provision it either. issue #124's perf doc + // (docs/perf/issue-124-unified-inbox-paging.md) independently corroborates this: its API 37 + // cross-check used a physical Pixel, not an emulator/AVD. Locally, preflight covers API 37 + // by hand-provisioning it with .claude/skills/preflight/api37_e2e.py, which mirrors the + // e2e-preview job (same image + emulator flags, except it renders on the host GPU via + // `-gpu auto-no-window` locally instead of CI's headless `-gpu swiftshader_indirect`). Once + // a managed-device-compatible image is published, add `api37` here (and to the CI matrix), + // delete that script, and drop the e2e-preview job. managedDevices { localDevices { listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api ->