Capture on Android, and put both platforms in one map - #8
Conversation
screenmap has been iOS-only. This adds Android as a peer platform: a run captures on iOS, on Android, or on both, and both land in a single .scrmap behind a platform switcher in the viewer. The device layer splits in two. lib/sim.mjs keeps the simctl primitives, lib/android.mjs is its adb counterpart, and lib/device.mjs holds what is genuinely shared — Metro, the dev-client connect loop, the capture helpers — so the platforms cannot quietly drift apart. argent needed no changes at all: its device tools take an Android serial wherever they take an iOS UDID, so committed-flow replay works on both for free. Three things are not one-to-one with simctl. Android has no "Open in …?" scheme prompt, so there is nothing to pre-approve. The emulator's localhost is not the host's, so the driver opens an adb reverse tunnel before pointing the dev client at Metro — which also covers USB devices, unlike the 10.0.2.2 alias. And the status bar is frozen through SystemUI demo mode rather than a dedicated override. Bundles stay backward compatible by construction. A single-platform run emits exactly the v2 layout it always did. Only a multi-platform run becomes v3: screens move under screens/<platform>/, each node gains a `captures` map, and `capture` keeps mirroring the first platform so a v2 reader renders the map rather than showing every screen as missing. The diff bundle gains the same axis under its existing base/head split. Capture status is per platform, which is the point: a screen that renders on iOS and crashes on Android is one node with two verdicts. In CI the platforms are separate jobs, because iOS needs macOS and Android is only worth running on ubuntu, which bills at a tenth of the rate. `screenmap-ci merge` folds their bundles into one map so the split stays an implementation detail of the pipeline rather than something a reviewer has to hold in their head. OCR gets a tesseract backend behind the existing interface. Apple Vision is macOS-only, and without a fallback the landing checks, deep-link verification and system-alert dismissal all go dark on a Linux runner. tesseract reads fewer words than Vision, so the run summary and the PR comment now name the backend whenever it is not Vision — a drift warning from a Linux run deserves to be read with more suspicion than one from macOS. Verified against sample-app and its real captures: its untouched pre-existing config still produces identical v2 output, incremental reuse carries all six real captures forward, the multi-platform bundle and diff round-trip, and the refactored iOS driver boots a real simulator and captures from it. The adb driver itself has not met a live emulator — this machine has no Android SDK — and TODOS.md ranks what to check on the first real run. That testing caught one bug worth naming: a single-platform bundle used to answer for any platform asked of it, so a repo turning Android on "reused" its iOS screenshots as Android captures — labelled Android, and indistinguishable from a real run. Bundles now declare which platforms they hold, and merge refuses an input whose platform does not match its mapping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step assumed the runner image carried platform-tools and emulator, and called adb and avdmanager before checking either existed. GitHub's ubuntu images ship platform-tools, but emulator is not guaranteed, and a missing one surfaced as a bare "command not found" several steps later. sdkmanager now installs platform-tools, emulator and the system image together — a no-op when they are already there — and the two binaries are checked with a message that names what is missing and where it was looked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A baseline publishes to main/latest.scrmap and a PR restores from it, with no platform anywhere in the path. In a repo that already maps iOS, an Android baseline overwrites the iOS map and the next iOS PR run restores the wrong device's captures. The screenmaps_branch input isolates a first Android experiment, which is enough to test with and not an answer to keep. Written down with the two ways it could resolve and the back-compat constraint either one has to meet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub runs `shell: bash` with -eo pipefail. `yes | sdkmanager` therefore fails the step the moment sdkmanager stops reading and SIGPIPE kills `yes` — even on a clean install. The first real ubuntu run died here, on the line after adb started its daemon, with nothing but "yes: standard output: Broken pipe". Take sdkmanager's own status out of PIPESTATUS, and keep its output so a real install failure says something instead of being swallowed by >/dev/null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first ubuntu run got all the way through an EAS Android build and then died on "no Android device connected and no AVD defined". The AVD was fine. The emulator binary could not start at all: qemu-system-x86_64: error while loading shared libraries: libpulse.so.0: cannot open shared object file GitHub's ubuntu images do not carry libpulse0, so the Linux dependency step now installs it for android runs, along with the X libs different system images pull in — those one at a time and best-effort, since the names drift between ubuntu releases and one missing optional package should not fail a run. Two diagnosis fixes so this cannot mislead again. The `emulator -version` check was `|| true`, which hid the broken binary and let the failure surface seventeen minutes and one EAS build later, pointing at the wrong thing; it is now a hard gate that prints qemu's own complaint. And listAvds() returned an empty array both when the emulator failed to run and when there were genuinely no AVDs, so it now reports which, and ensureBooted says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run three created the AVD, printed the emulator version, and then failed a step later with "no AVD defined". Both were true: avdmanager wrote one and `emulator -list-avds` could not see it, because the two resolve the AVD directory through different chains — $ANDROID_AVD_HOME, then $ANDROID_SDK_HOME/.android/avd for one and $HOME/.android/avd for the other — and a runner that sets ANDROID_SDK_HOME sends them to different places. Pin ANDROID_AVD_HOME for both and carry it to the boot step through GITHUB_ENV. Then verify it rather than assume: the step now requires the AVD to appear in `emulator -list-avds` before declaring it ready, and on failure prints the AVD home, its contents and what the emulator actually reported. Creating an AVD the emulator cannot see is this step's most likely failure and the next step can only describe it as the thing that is not wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first green Android baseline captured all eight screens of the app behind a grey scrim reading "Pixel Launcher isn't responding". An emulator on software rendering is slow enough to trip Android's watchdog, the dialog is modal, and nothing dismisses it — so it appears in every capture taken after it, the same poisoned tail a sticky error boundary causes. Two layers, matching how the iOS side handles system alerts. hide_error_dialogs (plus anr_show_background) stops the system drawing them at all, set right after sys.boot_completed alongside the keyguard dismissal. And ALERT_HINTS learns the ANR wording so dismissAlert() can clear one that still gets through, ordered so "Wait" is chosen over "Close app" — the app under test is what is being mapped, and killing it ends the run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The baseline template has offered a workflow_dispatch input described as "Rebuild the whole map instead of incrementally" since it was written, but action.yml never read it and never passed --full to the CLI. Dispatching with full=true did an ordinary incremental run. Found while trying to re-test an Android fix: the run reused all eight captures from the previous baseline, never booted the emulator, and reported success without exercising the change at all. The action takes `full` as an input and forwards it; the template passes its dispatch input through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The driver is no longer unverified: it ran green on GitHub Actions against screenmap-test, capturing and publishing eight screens. Notably it needed no changes of its own after the first successful boot — every fix was in the Action or in how a failure was reported. Replaces the pre-run list of guesses with what the six runs cost and why, keeps the three things still genuinely unproven (muteDevMenu, the status-bar clock across runs, and the PR lane), and records the incidental finds: EAS needs no manual keystore, fingerprint reuse works, and `full` was never wired up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified on CI — and it took six runs to get thereMarking this ready. The Android lane now runs green on GitHub Actions against The
Two of those cost far more than they should have because the run reported the wrong thing. Corrections to the original description
Found in passing, fixed hereThe baseline workflow template has offered a Still unproven
🤖 Generated with Claude Code |
screenmap-test#17 exercised the diff lane on Android: suspects narrowed to the one changed screen, only that screen was captured on the head side, the base side came from the Android baseline, and the comment rendered with the before/after pair and the tesseract line. It also answers the status-bar question, which turns out not to be one. Base and head agree — neither shows a clock, because the app draws edge-to-edge over that area. The 9:41 in the earlier run was the anomaly: the ANR dialog was changing the window insets. What a diff needs is the two sides agreeing, and they do. Replaces those two entries with what is genuinely still untested on Android: flow replay, and the agent lane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PR lane works on Android tooscreenmap-test#17 — a one-file copy change to Suspects narrowed to exactly the one changed screen, only that screen was captured on the head side, the base side came from the Android baseline, and the sticky comment rendered with the before/after pair, the right reason ("its own source changed (grind.tsx)"), the Android device name, and the new OCR line:
The status-bar clock was a red herringI flagged this as needing settling before the PR lane could be trusted. It doesn't. Base and head agree — neither shows a clock. The status-bar strip in both contains only the app's own eyebrow text, because this app draws edge-to-edge over that area. The Demo mode is still worth setting for the icons, but on an edge-to-edge app the clock it pins may never be visible — and that's fine, because what a diff needs is the two sides agreeing, which they do. The captured pair differs only where the copy changed (text jaccard 0.53, distinct words on each side). Still untested on AndroidNarrower than before, and neither blocks this:
🤖 Generated with Claude Code |
Two findings from review on #8, both real. ensureBooted discarded waitBootComplete's result on both paths, so an emulator adb could see but that never flipped sys.boot_completed was returned anyway. The session then ran install and launch against a half-booted system and failed later with opaque package-manager errors. Both paths now throw, saying which device and that it was the boot that timed out. The `avd` input created an AVD nothing then selected: ensureBooted reads config.android.device, which defaulted to null and was never set from the input, so the driver took avds[0]. With one AVD that happens to be right; on a runner with others it boots an image the Action never provisioned. The action now passes both provisioned devices as SCREENMAP_DEVICE_IOS / SCREENMAP_DEVICE_ANDROID, loadConfig reads them per platform, and an explicit config.<platform>.device still wins. The driver also logs which AVD it picked, and warns when the requested one was not among those defined. Also fixes the platform switcher, which this found in passing: passing asChild straight from TooltipTrigger to ToggleGroupItem let the tooltip's data-state overwrite the item's, so the selected style never rendered and Radix reported the wrong item as checked — the viewer opened on Android with neither button highlighted. Wrapping the item in a span, as the Changes item already does, restores both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # action/cli/lib/agent.mjs # action/cli/lib/bundle.mjs # action/cli/screenmap-ci.mjs # apps/visualiser/src/components/ScreenNode.jsx # plugins/screenmap/skills/screenmap/SKILL.md # plugins/screenmap/skills/screenmap/scripts/pack-map.mjs
screenmap has been iOS-only. This adds Android as a peer platform: a run captures on iOS, on Android, or on both, and both land in a single
.scrmapbehind a platform switcher in the viewer.The shape of it
The device layer splits in two.
lib/sim.mjskeeps thesimctlprimitives,lib/android.mjsis itsadbcounterpart, andlib/device.mjsholds what is genuinely shared — Metro, the dev-client connect loop, the capture helpers — so the two platforms cannot quietly drift apart.argent needed no changes at all. Its device tools take an Android serial wherever they take an iOS UDID, so committed-flow replay — the thing that makes deterministic runs work — came along for free.
Three things are not one-to-one with
simctl:localhostis not the host's, so the driver opens anadb reversetunnel before pointing the dev client at Metro. That also covers USB devices, which the10.0.2.2alias does not.Configuration
platforms: ["ios"](the default),["android"], or both, with per-platformios.*/android.*blocks. The pre-multi-platform top-leveldevice/bundleId/appPathkeys still mean iOS, so existing configs need no edits — verified againstsample-app's untouched config.Bundles stay compatible by construction
A single-platform run emits exactly the v2 layout it always did. Only a multi-platform run becomes v3: screens move under
screens/<platform>/, each node gains acapturesmap, andcapturekeeps mirroring the first platform so a v2 reader renders the map rather than showing every screen as missing. The diff bundle gains the same axis under its existing base/head split.Capture status is per platform, which is the point — a screen that renders on iOS and crashes on Android is one node with two verdicts, not two maps to compare by eye.
CI
The platforms are separate jobs: iOS needs macOS, and Android is only worth running on ubuntu, which bills at a tenth of the rate.
screenmap-ci mergefolds their bundles into one map, so the split stays an implementation detail of the pipeline rather than something a reviewer has to hold in their head. The workflow templates carry the two-job shape.OCR on Linux
Apple Vision is macOS-only, and without a fallback the landing checks, deep-link verification and system-alert dismissal all go dark on a Linux runner. So
ocr.mjsgains a tesseract backend behind the unchangedocr()/words()interface.I measured it rather than assuming:
1 - (y + h/2). Across four real captures, 22 of 23 strings both backends read agree on the resulting tap-y to within 0.006. A wrong flip would have sent every alert dismissal to the mirror image of the button.--psm/--oem/ scale tuning moved that.The run summary and the PR comment now name the backend whenever it is not Vision.
Testing
Run against
sample-appand its real captures: its pre-existing config still produces identical v2 output; incremental reuse carries all six real captures forward; the multi-platform bundle and diff round-trip; the viewer's switcher swaps captures and the bezel now takes its aspect ratio from the image rather than a hardcoded iPhone ratio. The refactored iOS driver boots a real simulator and captures from it.That testing caught one bug worth naming. A single-platform bundle used to answer for any platform asked of it, so a repo turning Android on "reused" its iOS screenshots as Android captures — labelled Android in the viewer, and indistinguishable from a real run. Bundles now declare which platforms they hold, and
mergerefuses an input whose platform does not match its mapping.What is not verified
The
adbdriver has never met a live emulator — this machine has no Android SDK.TODOS.mdranks what to check on the first real run; the riskiest ismuteDevMenu(), whose SharedPreferences filename and keys are inferred from the iOSEXDevMenu*defaults rather than read off a device. It is deliberately non-fatal: a wrong guess costs a floating button in the corner of every screenshot, not a failed run. The KVM setup inaction.ymlis likewise unproven on a real ubuntu runner.Opening as a draft for that reason.
Left alone deliberately:
diff-map.mjsstill defaults its output to.appmapdiffand reportsgenerator: 'expo-map/2.0'. The CLI always passes--outso it rarely fires, but those look like leftover branding rather than the two deliberateappmapreferences — worth a separate look rather than folding a rename into this change.🤖 Generated with Claude Code