Merge main into feat-235-debug-report-accounts

This commit is contained in:
Jason Ross
2026-07-03 17:25:01 -05:00
committed by GitHub
4 changed files with 370 additions and 35 deletions
+51 -21
View File
@@ -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.
+281
View File
@@ -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-<apiLevel:Int>" package id (apiLevel = 37 -> "android-37") or an
"android-<apiPreview:codename>" 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())
+21 -12
View File
@@ -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
+17 -2
View File
@@ -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-<apiLevel:Int>" package id (e.g. `apiLevel = 37` → "android-37")
// or an "android-<apiPreview:codename>" 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 ->