Extends preflight's local emulator E2E to cover the top of the CI matrix plus the API 37 preview, so local mirrors CI:
api35 + api36 run via their Gradle Managed Devices (api35DebugAndroidTest / api36DebugAndroidTest).
api37 (preview) is hand-provisioned by a new cross-platform Python 3 helper, .claude/skills/preflight/api37_e2e.py, because there is no Gradle Managed Device DSL path to its image.
.claude/skills/preflight/SKILL.md, CLAUDE.md (preflight + Definition of Done), and the app/build.gradle.kts managed-device comment are updated to match.
Which GMD tasks already existed vs. were added
Task
Status
api35DebugAndroidTest
Already existed in app/build.gradle.kts (part of the listOf(29..36) loop). Wired into preflight docs.
api36DebugAndroidTest
Already existed (same loop) — the level preflight already ran.
api37DebugAndroidTest
Does not exist and cannot — no GMD DSL path to the android-37.0 image (see below). Covered by the new hand-provisioning script instead.
Confirmed with a light, no-emulator check: .\gradlew :app:tasks --group verification -q lists api29…api36*DebugAndroidTest, and no api37* task.
API 37: why a script, not a GMD
API 37's only published system image is the nonstandard android-37.0 / google_apis_ps16k (16 KB page size) pairing. AGP's ManagedVirtualDevice 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 — which is the same root cause .github/workflows/ci.yml's e2e-preview job documents for why reactivecircus/android-emulator-runner can't provision it either, so that job hand-rolls sdkmanager/avdmanager/emulator. (docs/perf/issue-124-unified-inbox-paging.md corroborates: its API 37 numbers came from a physical Pixel, not an AVD.)
Per the repo owner's decision, preflight now hand-provisions API 37 locally (not deferred to CI) via api37_e2e.py, which mirrors the e2e-preview job:
Same system image string: system-images;android-37.0;google_apis_ps16k;x86_64.
Emulator flags identical except the GPU mode (one deliberate local divergence): -no-window -no-audio -no-boot-anim -no-snapshot -accel on -gpu auto-no-window -camera-back none -camera-front none. CI's e2e-preview uses -gpu swiftshader_indirect (software rendering, deterministic on a headless CI runner); the local run uses -gpu auto-no-window to render on the host GPU — faster, and the mode that boots cleanly on this machine.
Same sequence: install image with sdkmanager → create AVD with avdmanager (-d pixel_2) → cold-boot headless → adb wait-for-device + poll sys.boot_completed (two-attempt boot loop, 300 s each) → adb shell input keyevent 82 → :app:connectedDebugAndroidTest → tear the emulator down and delete the AVD.
Cross-platform (stdlib only): per-OS SDK discovery + tool suffixes (.bat/.exe), Windows .bat launchers wrapped through cmd /c, ANDROID_AVD_HOME pinned so avdmanager and the emulator agree on the AVD dir (the same fix e2e-preview applies). Keep it in lockstep with the e2e-preview job on everything but that GPU flag.
Emulators need a free hardware hypervisor (Intel VT-x / AMD-V; WHPX on Windows, KVM on Linux, HVF on macOS). Shut down VirtualBox / Hyper-V VMs / WSL2 / Docker Desktop / other emulators first — a VM holding the hypervisor starves the AVD and it hangs at 0% CPU, never reaching sys.boot_completed. Added to SKILL.md Preconditions and noted in CLAUDE.md.
The api37 script needs python3 + the Android SDK command-line tools and emulator, located via ANDROID_SDK_ROOT/ANDROID_HOME or the per-OS default SDK path; it installs the preview image itself on first run.
End-to-end validation run of api37_e2e.py pending — to be executed once the single local emulator slot is free (currently in use by another api36 run); the PR will be updated with the provision + boot + connectedDebugAndroidTest result.
Reviewer/owner: run all three E2E steps once on an accelerated host (VMs shut down) before relying on this as the required gate.
## Summary
Extends preflight's local emulator E2E to cover the **top of the CI matrix plus the API 37 preview**, so local mirrors CI:
- **api35 + api36** run via their Gradle Managed Devices (`api35DebugAndroidTest` / `api36DebugAndroidTest`).
- **api37 (preview)** is hand-provisioned by a new cross-platform Python 3 helper, `.claude/skills/preflight/api37_e2e.py`, because there is **no Gradle Managed Device DSL path** to its image.
`.claude/skills/preflight/SKILL.md`, `CLAUDE.md` (preflight + Definition of Done), and the `app/build.gradle.kts` managed-device comment are updated to match.
## Which GMD tasks already existed vs. were added
| Task | Status |
|---|---|
| `api35DebugAndroidTest` | **Already existed** in `app/build.gradle.kts` (part of the `listOf(29..36)` loop). Wired into preflight docs. |
| `api36DebugAndroidTest` | **Already existed** (same loop) — the level preflight already ran. |
| `api37DebugAndroidTest` | **Does not exist and cannot** — no GMD DSL path to the `android-37.0` image (see below). Covered by the new hand-provisioning script instead. |
Confirmed with a light, no-emulator check: `.\gradlew :app:tasks --group verification -q` lists `api29`…`api36` `*DebugAndroidTest`, and no `api37*` task.
## API 37: why a script, not a GMD
API 37's only published system image is the nonstandard **`android-37.0` / `google_apis_ps16k`** (16 KB page size) pairing. AGP's `ManagedVirtualDevice` 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`** — which is the same root cause `.github/workflows/ci.yml`'s `e2e-preview` job documents for why `reactivecircus/android-emulator-runner` can't provision it either, so that job hand-rolls `sdkmanager`/`avdmanager`/`emulator`. (`docs/perf/issue-124-unified-inbox-paging.md` corroborates: its API 37 numbers came from a physical Pixel, not an AVD.)
Per the repo owner's decision, preflight now hand-provisions API 37 **locally** (not deferred to CI) via `api37_e2e.py`, which mirrors the `e2e-preview` job:
- Same system image string: `system-images;android-37.0;google_apis_ps16k;x86_64`.
- Emulator flags identical **except the GPU mode** (one deliberate local divergence): `-no-window -no-audio -no-boot-anim -no-snapshot -accel on -gpu auto-no-window -camera-back none -camera-front none`. CI's `e2e-preview` uses `-gpu swiftshader_indirect` (software rendering, deterministic on a headless CI runner); the **local** run uses `-gpu auto-no-window` to render on the host GPU — faster, and the mode that boots cleanly on this machine.
- Same sequence: install image with `sdkmanager` → create AVD with `avdmanager` (`-d pixel_2`) → cold-boot headless → `adb wait-for-device` + poll `sys.boot_completed` (two-attempt boot loop, 300 s each) → `adb shell input keyevent 82` → `:app:connectedDebugAndroidTest` → tear the emulator down and delete the AVD.
**Cross-platform (stdlib only):** per-OS SDK discovery + tool suffixes (`.bat`/`.exe`), Windows `.bat` launchers wrapped through `cmd /c`, `ANDROID_AVD_HOME` pinned so avdmanager and the emulator agree on the AVD dir (the same fix `e2e-preview` applies). Keep it in lockstep with the `e2e-preview` job on everything but that GPU flag.
## Final preflight command sequence
```bash
./gradlew :app:assembleDebug
./gradlew :app:testDebugUnitTest
./gradlew :app:compileDebugAndroidTestKotlin
./gradlew :app:lintDebug
./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 # API 37 preview E2E (hand-provisioned; Windows: py/python)
```
## Caveats documented
- Emulators need a **free hardware hypervisor** (Intel VT-x / AMD-V; WHPX on Windows, KVM on Linux, HVF on macOS). Shut down VirtualBox / Hyper-V VMs / WSL2 / Docker Desktop / other emulators first — a VM holding the hypervisor starves the AVD and it hangs at 0% CPU, never reaching `sys.boot_completed`. Added to SKILL.md Preconditions and noted in CLAUDE.md.
- The api37 script needs `python3` + the Android SDK command-line tools and `emulator`, located via `ANDROID_SDK_ROOT`/`ANDROID_HOME` or the per-OS default SDK path; it installs the preview image itself on first run.
## Test plan
- [x] `.\gradlew :app:tasks --group verification -q` — confirms `api35`/`api36` `*DebugAndroidTest` exist; no `api37*` GMD task.
- [x] `api37_e2e.py` validated **syntactically** (`python -m py_compile` + `ast.parse` + `--help`).
- [ ] **End-to-end validation run of `api37_e2e.py` pending** — to be executed once the single local emulator slot is free (currently in use by another api36 run); the PR will be updated with the provision + boot + `connectedDebugAndroidTest` result.
- [ ] Reviewer/owner: run all three E2E steps once on an accelerated host (VMs shut down) before relying on this as the required gate.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Extends preflight's local emulator E2E to cover the top of the CI matrix plus the API 37 preview, so local mirrors CI:
api35DebugAndroidTest/api36DebugAndroidTest)..claude/skills/preflight/api37_e2e.py, because there is no Gradle Managed Device DSL path to its image..claude/skills/preflight/SKILL.md,CLAUDE.md(preflight + Definition of Done), and theapp/build.gradle.ktsmanaged-device comment are updated to match.Which GMD tasks already existed vs. were added
api35DebugAndroidTestapp/build.gradle.kts(part of thelistOf(29..36)loop). Wired into preflight docs.api36DebugAndroidTestapi37DebugAndroidTestandroid-37.0image (see below). Covered by the new hand-provisioning script instead.Confirmed with a light, no-emulator check:
.\gradlew :app:tasks --group verification -qlistsapi29…api36*DebugAndroidTest, and noapi37*task.API 37: why a script, not a GMD
API 37's only published system image is the nonstandard
android-37.0/google_apis_ps16k(16 KB page size) pairing. AGP'sManagedVirtualDeviceDSL can only build anandroid-<apiLevel:Int>package id (apiLevel = 37→android-37) or anandroid-<apiPreview:codename>one — neither resolves toandroid-37.0— which is the same root cause.github/workflows/ci.yml'se2e-previewjob documents for whyreactivecircus/android-emulator-runnercan't provision it either, so that job hand-rollssdkmanager/avdmanager/emulator. (docs/perf/issue-124-unified-inbox-paging.mdcorroborates: its API 37 numbers came from a physical Pixel, not an AVD.)Per the repo owner's decision, preflight now hand-provisions API 37 locally (not deferred to CI) via
api37_e2e.py, which mirrors thee2e-previewjob:system-images;android-37.0;google_apis_ps16k;x86_64.-no-window -no-audio -no-boot-anim -no-snapshot -accel on -gpu auto-no-window -camera-back none -camera-front none. CI'se2e-previewuses-gpu swiftshader_indirect(software rendering, deterministic on a headless CI runner); the local run uses-gpu auto-no-windowto render on the host GPU — faster, and the mode that boots cleanly on this machine.sdkmanager→ create AVD withavdmanager(-d pixel_2) → cold-boot headless →adb wait-for-device+ pollsys.boot_completed(two-attempt boot loop, 300 s each) →adb shell input keyevent 82→:app:connectedDebugAndroidTest→ tear the emulator down and delete the AVD.Cross-platform (stdlib only): per-OS SDK discovery + tool suffixes (
.bat/.exe), Windows.batlaunchers wrapped throughcmd /c,ANDROID_AVD_HOMEpinned so avdmanager and the emulator agree on the AVD dir (the same fixe2e-previewapplies). Keep it in lockstep with thee2e-previewjob on everything but that GPU flag.Final preflight command sequence
Caveats documented
sys.boot_completed. Added to SKILL.md Preconditions and noted in CLAUDE.md.python3+ the Android SDK command-line tools andemulator, located viaANDROID_SDK_ROOT/ANDROID_HOMEor the per-OS default SDK path; it installs the preview image itself on first run.Test plan
.\gradlew :app:tasks --group verification -q— confirmsapi35/api36*DebugAndroidTestexist; noapi37*GMD task.api37_e2e.pyvalidated syntactically (python -m py_compile+ast.parse+--help).api37_e2e.pypending — to be executed once the single local emulator slot is free (currently in use by another api36 run); the PR will be updated with the provision + boot +connectedDebugAndroidTestresult.🤖 Generated with Claude Code