Runs the same app through many device configurations and reports which ones
break. scripts/devmatrix.py applies each cell, verifies it by reading the
configuration back off the device, captures evidence, and restores everything
it touched.
Two questions in one sweep:
- Layout — does the UI hold at 320dp and at 840dp, in landscape, at 200% text, in dark mode, behind a cutout?
- Capability absence — the app declares features and permissions it can live without. Does it actually survive losing them?
The fidelity limit, stated first: wm size/wm density remap the logical
metrics of one emulator's panel. They cannot fake a display cutout you did not
enable, a real device's aspect ratio, a hinge, or a vendor skin. A pass here
means "it fits this rectangle", not "it fits that device". Cells that need a
real AVD are reported as recipes rather than silently faked.
When NOT to use
- Touch-target sizes and unlabelled controls →
android-ux-audit(this skill produces the per-cell snapshots that skill reads; it does not re-implement it). - A full written review →
android-app-review, which orchestrates this one. - Startup, jank, memory →
android-app-profiling. - Reading the manifest for its own sake →
android-apk-analysis. - Web-page responsive breakpoints — this drives adb, not a browser.
Prerequisites
adb, and android-ui-driver alongside it (snapshots come from its uia.py, so
every cell is readable by android-ux-audit without a second format). aapt2
(SDK build-tools) is optional but recommended: dumpsys package does not
report uses-feature on API 35, so without aapt2 the capability surface is
reported as unknown rather than as empty.
Workflow
Run as python3 "${CLAUDE_SKILL_DIR}/scripts/devmatrix.py" <cmd>.
-
See the cells before touching the device.
devmatrix.py profiles # quick · thorough · a11y · capability devmatrix.py plan --profile thorough # dp arithmetic per cell, mutates nothing -
Ask what this app can actually lose. The matrix should be app-specific: a feature declared
required="false"is a promise the app still works without it, and that promise is what the capability cells test.devmatrix.py capabilities com.example.appIt splits the surface three ways: testable now at runtime, needs a purpose-built AVD (printed as
config.inilines), and already absent. -
Sweep.
devmatrix.py run com.example.app --profile quick --out ./matrixPer cell: apply → cold-start the app → wait for it to reach the foreground → rotate if asked → verify → snapshot + screenshot → detect → undo. Exits non-zero if any cell had a hard failure.
-
Audit any cell you care about — the snapshots are ordinary
android-ui-driversnapshots:uxcheck.py audit ./matrix/cells/compact-sw320.json --density 360Target-size findings are density-dependent, so a control that passes at 420dpi can fail at 360dpi. That is the point of running the audit per cell.
-
If a run is interrupted, the device is not left wrecked:
devmatrix.py doctor # what is non-default right now devmatrix.py restore # put back exactly what was saved
Configuring the matrix
Profiles are plain JSON. Start from a built-in one and edit:
devmatrix.py profiles --dump thorough > my-matrix.json
devmatrix.py run com.example.app --matrix my-matrix.json
A cell is a dict; every key is optional:
{"id": "tablet-portrait", "size": "1440x2560", "density": 320,
"rotation": 0, "font_scale": 1.0, "night": "yes", "cutout": "tall",
"deny": "permission:CAMERA", "note": "why this cell exists"}
size is pixels and density is dpi because that is what wm accepts;
the dp the device actually entered is measured and reported. The arithmetic is
dp = px × 160 ÷ dpi, so 720px at 360dpi is 320dp. Width dp picks the
WindowSizeClass: compact <600, medium 600–839, expanded 840–1199, large
1200–1599, extra-large ≥1600.
deny accepts airplane, wifi, data, location,
permission:<NAME> and appops:<OP>.
What counts as a failure
Only mechanically unambiguous things gate the run:
| Hard (fails, exit 1) | How it is detected |
|---|---|
| Crash | FATAL EXCEPTION naming the package in the crash buffer |
| ANR | am_anr in the events buffer (ANRs are not in the crash buffer) |
| Native crash | dumpsys dropbox data_app_native_crash (never a FATAL EXCEPTION) |
| Process death | the pid is gone and does not come back |
| Never foregrounded | the app never owns the top resumed activity |
Everything from the accessibility tree is suspected and never gates: blank
screen, no actionable control, off-screen or zero-area controls, a refused
rotation, a process restart. A system prompt taking the foreground
(permissioncontroller, the GMS "location is off" warning) is suspected too —
that is the platform interposing, not the app breaking, and in a capability cell
it is often the correct behaviour.
Text truncation is deliberately not detected. The accessibility tree carries
the full string even when the view ellipsises it, so any check would be a guess.
Look at the largest-font and worst-case screenshots instead.
Output spec
<out>/report.json— the machine-readable contract other skills read: per-cellrequestedvsverified(widthDp,heightDp,smallestWidthDp,sizeClass,rotation),status,findings[]withseverity+evidenceType, andartifactspaths.<out>/cells/<id>.json— anandroid-ui-driversnapshot per cell.<out>/cells/<id>.png— a screenshot per cell.- A verdict line, plus the restore log of every key put back.
Gotchas
- Rotation must be applied after the app is running. An orientation-locked
activity rewrites
user_rotationback to its own orientation as it starts, so a rotation set before launch silently reads back as portrait. The script rotates post-launch and reportsorientation_lockedwhen the app refuses, rather than labelling a portrait result "landscape". appops set … defaultis not a restore. An op whose original mode isforegroundis left broken bydefault; the exact prior mode string is recorded and written back, to both the uid and package mode.wm size resetrestores the AVD's panel, not the previous cell. If a size override already existed before the run, it is re-applied rather than reset.- A single focus read is not evidence. During a resize the focused window is briefly empty or owned by another process; the script polls for the app to reach the top resumed activity before concluding anything.
wm sizereturns before the display reconfigures. Readingdumpsysstraight after can hand back the previous cell's geometry — an 11-cell sweep came back shifted by one cell, every bucket mislabelled. Each cell now polls until the requested geometry is observed, and a cell that never applies is reported asconfig_not_appliedinstead of passing under a bucket it never entered.- Read the dp bucket from
mGlobalConfiguration. A bare search forswNNNdpindumpsys windowmatches a stalemFullConfigurationbelonging to a window that has not been reconfigured, so every cell reports the native bucket. - Orientation is judged by geometry, not
mRotation. That field also appears more than once, and the first match can belong to another display. - Enabling a cutout overlay restarts SystemUI, which can tear the app off the foreground a moment after launch and look like a crash. The cutout cell waits for that to settle before starting the app.
dumpsys packagedoes not listuses-featureon API 35 — verified across several packages. The manifest is read with aapt2 from the pulled APK; without aapt2 the feature list is reported UNKNOWN, never as "none declared".- Landscape on a phone is often the expanded width class (a 411dp-wide phone becomes 914dp wide), so the landscape cell exercises tablet layout paths too.
- Cells are undone between cells, so a failure in one does not contaminate the next. The full restore runs again at the end, and on Ctrl-C.
- You cannot remove a hardware feature at runtime.
pm list featurescomes from the system image and AVD config. Anything needing that is emitted as aconfig.inirecipe bycapabilities, not faked.
Files
scripts/devmatrix.py— profiles / plan / capabilities / run / restore / doctor, plus--self-test(offline; no device needed).references/matrix-design.md— why these breakpoints, the full runtime-vs-AVD capability split, AVD recipes, and the detector false-positive rationale.