From 1774a331582bc396086f34cf7da01b5bd5aec54b Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 16:05:12 -0500 Subject: [PATCH 1/3] ci(preflight): add API 35 and API 37 emulator E2E to preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the local preflight gate from api36 (the sole latest-API GMD run) to api35 + api36, the top two stable levels in the E2E matrix. Both Gradle Managed Devices already existed in app/build.gradle.kts (the api29..36 loop) — confirmed via `:app:tasks --group verification`, no emulator run needed. API 37 (preview) was investigated but NOT added as a GMD: its only published system image is the nonstandard "android-37.0" / google_apis_ps16k pairing that ci.yml's e2e-preview job installs by hand via sdkmanager. ManagedVirtualDevice's apiLevel (Int) builds "android-" and apiPreview (codename) builds "android-" — neither produces "android-37.0", the same gap ci.yml documents as why reactivecircus/android-emulator-runner can't provision it either. docs/perf/issue-124-unified-inbox-paging.md independently corroborates this: its API 37 measurements used a physical Pixel, not an AVD. There is no api37DebugAndroidTest task to run, so it stays CI-only (e2e-preview) until a managed-device-compatible image ships; the comment above testOptions.managedDevices in app/build.gradle.kts now documents this in detail for the next person who looks. .claude/skills/preflight/SKILL.md and CLAUDE.md are updated to run both api35DebugAndroidTest and api36DebugAndroidTest as part of the required gate, with the API 37 gap called out inline. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/preflight/SKILL.md | 49 ++++++++++++++++++------------- CLAUDE.md | 28 ++++++++++-------- app/build.gradle.kts | 15 ++++++++-- 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/.claude/skills/preflight/SKILL.md b/.claude/skills/preflight/SKILL.md index a64e159..dc873f9 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) before pushing or opening a PR. Mirrors the merge gate and runs the two highest stable API levels via their Gradle Managed Devices; the rest of the multi-API matrix and the API 37 preview job stay CI-only. Use before treating a change as done. --- # /preflight @@ -13,12 +13,12 @@ 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 two E2E steps each boot an emulator through their own 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 each AVD itself — no manual emulator setup — + but the first run per API level is slow while its image downloads. If the host has no + accelerated emulator and a managed device cannot boot, report the E2E step as not run rather + than treating the gate as green. ## Steps @@ -30,7 +30,8 @@ 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) ``` `compileDebugAndroidTestKotlin` compiles the `androidTest` source set — the E2E/instrumented @@ -44,24 +45,32 @@ 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. +`api35DebugAndroidTest` and `api36DebugAndroidTest` run last because they are the slowest: each +runs the full instrumented/E2E suite — on `api35`, then `api36`, the top two stable levels in +the E2E matrix — via its own Gradle Managed Device, which Gradle provisions, boots, and tears +down automatically. These two levels are the E2E that preflight runs locally and both must +pass; CI fans the same suite out across the whole API matrix (API 29–36) plus the API 37 +preview job. API 37 has no Gradle Managed Device yet — 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 CI's `e2e-preview` job provisions +it by hand instead, and there is no `api37DebugAndroidTest` task to run here. Keep +`api35DebugAndroidTest` / `api36DebugAndroidTest` in lockstep with the top of the managed-device +list in `app/build.gradle.kts` and the E2E matrix in `.github/workflows/ci.yml` — when a newer +API level is added there, run the new top two 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 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 + - E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g. `.../api35/`, + `.../api36/`) +- Run only the **top two stable-API** levels here (`api35DebugAndroidTest` + + `api36DebugAndroidTest`); the rest of the multi-API matrix and the API 37 preview job stay + CI's job — API 37 has no Gradle Managed Device to run locally (see Steps above). If the host + has no accelerated emulator and a managed device cannot boot, report that E2E could not run rather than treating the gate as green. diff --git a/CLAUDE.md b/CLAUDE.md index 171d3af..e7c3a2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,15 +28,18 @@ 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 `/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). Running those two levels locally is required; the rest of the multi-API matrix +(API 29–34) stays CI's job. API 37 (preview) still has no Gradle Managed Device to run locally +— see the comment above `testOptions.managedDevices` in `app/build.gradle.kts` for why — so it +remains exercised only by CI's `e2e-preview` job. ## Build-config gotchas @@ -70,9 +73,10 @@ 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`, the two highest stable API levels in the E2E matrix and their Gradle +Managed Device tasks) and both 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..24807e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,8 +146,19 @@ 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. Once a managed-device-compatible + // image is published, add `api37` here (and to the CI matrix) and delete the e2e-preview job. managedDevices { localDevices { listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api -> -- 2.47.3 From 5ecc2401e3e6444ff5f9806e40909a84723b280c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 16:22:31 -0500 Subject: [PATCH 2/3] ci(preflight): hand-provision API 37 preview E2E locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the repo owner's decision, preflight now runs the API 37 preview emulator locally instead of leaving it to CI. Since there is no Gradle Managed Device DSL path to the nonstandard android-37.0 / google_apis_ps16k image, add a stdlib-only, cross-platform Python 3 helper (.claude/skills/preflight/api37_e2e.py) that mirrors CI's e2e-preview job EXACTLY: same system image string (system-images;android-37.0;google_apis_ps16k;x86_64), same emulator flags, same provisioning/boot sequence. It installs the image via sdkmanager, creates the AVD via avdmanager, cold-boots headless, waits for sys.boot_completed, runs :app:connectedDebugAndroidTest, then tears the emulator + AVD down. Cross-platform: per-OS tool discovery/suffixes and cmd /c wrapping for Windows .bat launchers. Update SKILL.md + CLAUDE.md so preflight runs api35 + api36 (GMDs) + api37 (this script), and the app/build.gradle.kts managed-devices comment now points at the script. Add a caveat that emulators need a free hardware hypervisor (VT-x/WHPX) — shut down VirtualBox/other VMs first or the AVD hangs at 0% CPU. Validated syntactically only (python -m py_compile + ast.parse + argparse --help); no emulator was booted and no build was run, to avoid contending with an in-progress api36 run. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/preflight/SKILL.md | 72 ++++--- .claude/skills/preflight/api37_e2e.py | 275 ++++++++++++++++++++++++++ CLAUDE.md | 36 ++-- app/build.gradle.kts | 7 +- 4 files changed, 345 insertions(+), 45 deletions(-) create mode 100644 .claude/skills/preflight/api37_e2e.py diff --git a/.claude/skills/preflight/SKILL.md b/.claude/skills/preflight/SKILL.md index dc873f9..056494b 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 top-of-matrix emulator E2E (currently API 35 + 36) before pushing or opening a PR. Mirrors the merge gate and runs the two highest stable API levels via their Gradle Managed Devices; the rest of the multi-API matrix and the API 37 preview job stay 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 two E2E steps each boot an emulator through their own 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 each AVD itself — no manual emulator setup — - but the first run per API level is slow while its image downloads. If the host has no - accelerated emulator and a 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 @@ -32,6 +40,7 @@ Run these, stopping at the first failure: ./gradlew :app:ktlintCheck :app:detekt ./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 @@ -45,32 +54,41 @@ 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. -`api35DebugAndroidTest` and `api36DebugAndroidTest` run last because they are the slowest: each -runs the full instrumented/E2E suite — on `api35`, then `api36`, the top two stable levels in -the E2E matrix — via its own Gradle Managed Device, which Gradle provisions, boots, and tears -down automatically. These two levels are the E2E that preflight runs locally and both must -pass; CI fans the same suite out across the whole API matrix (API 29–36) plus the API 37 -preview job. API 37 has no Gradle Managed Device yet — 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 CI's `e2e-preview` job provisions -it by hand instead, and there is no `api37DebugAndroidTest` task to run here. Keep +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 exactly the way CI's `e2e-preview` job does — installs the image with `sdkmanager`, creates +the AVD with `avdmanager`, cold-boots it headless with the same emulator flags, waits for +`sys.boot_completed`, runs `:app:connectedDebugAndroidTest`, then kills the emulator and deletes +the AVD. Because it mirrors `e2e-preview`, local preflight == CI on API 37. + +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 the E2E matrix in `.github/workflows/ci.yml` — when a newer -API level is added there, run the new top two instead. +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) — 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, api35+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. `.../api35/`, - `.../api36/`) -- Run only the **top two stable-API** levels here (`api35DebugAndroidTest` + - `api36DebugAndroidTest`); the rest of the multi-API matrix and the API 37 preview job stay - CI's job — API 37 has no Gradle Managed Device to run locally (see Steps above). If the host - has no accelerated emulator and a 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..a3ee5c1 --- /dev/null +++ b/.claude/skills/preflight/api37_e2e.py @@ -0,0 +1,275 @@ +#!/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, mirroring CI's `e2e-preview` job in +.github/workflows/ci.yml EXACTLY (same system image string, same emulator flags, same +provisioning/boot sequence) so that local preflight == CI. Keep the two in lockstep: 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 EXACTLY (cold headless boot, SwiftShader + # GPU, hardware accel required, no cameras). Keep in lockstep with that job. + flags = [ + "-avd", AVD_NAME, + "-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", "-accel", "on", + "-gpu", "swiftshader_indirect", "-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 e7c3a2e..e0ea68d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,18 +28,21 @@ 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 top-of-matrix emulator E2E `api35DebugAndroidTest` + `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. `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). Running those two levels locally is required; the rest of the multi-API matrix -(API 29–34) stays CI's job. API 37 (preview) still has no Gradle Managed Device to run locally -— see the comment above `testOptions.managedDevices` in `app/build.gradle.kts` for why — so it -remains exercised only by CI's `e2e-preview` 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), 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 @@ -73,10 +76,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 top-of-matrix emulator E2E locally (`api35DebugAndroidTest` + -`api36DebugAndroidTest`, the two highest stable API levels in the E2E matrix and their Gradle -Managed Device tasks) and both must be green before the change is done. CI then runs the full -multi-API matrix plus the API 37 preview job. +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 24807e1..40108f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -157,8 +157,11 @@ android { // 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. Once a managed-device-compatible - // image is published, add `api37` here (and to the CI matrix) and delete the e2e-preview job. + // 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). 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 -> -- 2.47.3 From ac5ed162aefc1160c23eb8067fcdeabb8185c285 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 16:31:41 -0500 Subject: [PATCH 3/3] ci(preflight): use host-GPU auto-no-window for local api37 emulator Change the api37_e2e.py emulator launch from `-gpu swiftshader_indirect` to `-gpu auto-no-window`. For a LOCAL run the host GPU is faster and auto-no-window is the mode that boots cleanly on this machine; CI's e2e-preview keeps swiftshader_indirect for headless-runner determinism. This is now the single deliberate divergence from e2e-preview; the image string, provisioning, boot sequence, and every other emulator flag stay in lockstep. Updated the script comments/docstring, SKILL.md, CLAUDE.md, and the build.gradle.kts managed-devices comment to document it. Syntax-only change (python -m py_compile clean); emulator not run. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/preflight/SKILL.md | 15 +++++++++------ .claude/skills/preflight/api37_e2e.py | 18 ++++++++++++------ CLAUDE.md | 5 +++-- app/build.gradle.kts | 7 ++++--- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/.claude/skills/preflight/SKILL.md b/.claude/skills/preflight/SKILL.md index 056494b..47a8343 100644 --- a/.claude/skills/preflight/SKILL.md +++ b/.claude/skills/preflight/SKILL.md @@ -64,17 +64,20 @@ Managed Device — its only published system image is the nonstandard `android-3 `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 exactly the way CI's `e2e-preview` job does — installs the image with `sdkmanager`, creates -the AVD with `avdmanager`, cold-boots it headless with the same emulator flags, waits for -`sys.boot_completed`, runs `:app:connectedDebugAndroidTest`, then kills the emulator and deletes -the AVD. Because it mirrors `e2e-preview`, local preflight == CI on API 37. +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) — when a newer API level is added -there, run the new top levels instead. +`.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 diff --git a/.claude/skills/preflight/api37_e2e.py b/.claude/skills/preflight/api37_e2e.py index a3ee5c1..6a72e89 100644 --- a/.claude/skills/preflight/api37_e2e.py +++ b/.claude/skills/preflight/api37_e2e.py @@ -11,9 +11,11 @@ API 37's only published system image is the nonstandard "android-37.0" / google_ "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, mirroring CI's `e2e-preview` job in -.github/workflows/ci.yml EXACTLY (same system image string, same emulator flags, same -provisioning/boot sequence) so that local preflight == CI. Keep the two in lockstep: when a +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). @@ -117,12 +119,16 @@ def create_avd(avdmanager: str, emulator: str) -> None: 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 EXACTLY (cold headless boot, SwiftShader - # GPU, hardware accel required, no cameras). Keep in lockstep with that job. + # 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", "swiftshader_indirect", "-camera-back", "none", "-camera-front", "none", + "-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: diff --git a/CLAUDE.md b/CLAUDE.md index e0ea68d..1acc90b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,8 +39,9 @@ top two stable API levels in the E2E matrix, each via its own Gradle Managed Dev 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), boots it headless, runs -`connectedDebugAndroidTest`, and tears it down. Running all three levels locally is required; the +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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 40108f3..1f390ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -159,9 +159,10 @@ android { // (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). 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. + // 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 -> -- 2.47.3