|
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Android runs one instrumentation per package: a second `am instrument`
force-stops the first ("stop com.kuhy.a11ydump due to start instr"), and
so does `install -r`. The live two-session run on the Pixel caught two
sessions' dumps killing each other ("Process crashed"). dump_display now
runs install + instrument under a per-serial flock; a dump takes well
under a second, so queueing costs nothing noticeable.
After the fix the same 300 s run had 0 failures across 659 actions, and
the real screen and both apps stayed where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
|
||
|---|---|---|
| .. | ||
| tests | ||
| __init__.py | ||
| __main__.py | ||
| _a11y_helper.py | ||
| _elements.py | ||
| _text_entry.py | ||
| a11y_dump_manifest.xml | ||
| A11yDump.java | ||
| cli.py | ||
| driver.py | ||
| README.md | ||
android_ui — drive Android apps by element, never by coordinates
Shared across every repo with an Android surface (~/src/screen-locker's
workout_app, ~/src/todo, ~/src/dufs-cloud/app). Works against a plain release APK
on a physical device over adb — no root, no emulator, no Flutter debug
connection, no change to app source.
Why
Driving an app by tapping pixel positions read off a screenshot fails in ways that look like success. All of these were observed in one session (2026-08-10), verifying the workout app's Firebase restore:
| Failure | What it looks like |
|---|---|
| Tap lands on an unfocused field | Typed text goes nowhere; nothing errors; the next screenshot shows an empty box |
| Soft keyboard opens | A field moves 1572 → 1319; every coordinate captured a step earlier is wrong |
| Widget sits behind the keyboard | The tree reports its laid-out position (1549) not its real one (1939) — a 390px error that taps a letter key |
| Screenshot scaling | The harness renders 1080×2400 at 900×2000, so every read coordinate needs a manual ×1.2 |
| Typing into a filled field | input text inserts at the cursor: old@example.comnew@example.com |
Two device quirks are handled rather than documented, because both produced false results before they were understood:
uiautomator dumpreturns only theEditTextnodes while a Flutter text field holds focus. The button you are about to tap is simply missing, and it stays missing across retries — a naive retry loop never converges.KEYCODE_BACKis a route pop in Flutter, not a keyboard dismiss. It navigates out of the screen and discards typed input.
Use
export PYTHONPATH=~/src/testsAndMisc
python3 -m python_pkg.android_ui dump
python3 -m python_pkg.android_ui find "Connect Firebase"
python3 -m python_pkg.android_ui --exact tap "Back"
python3 -m python_pkg.android_ui wait "Connected." --timeout 30
python3 -m python_pkg.android_ui focus
Every subcommand exits non-zero and names the query on failure, so a script can tell a real failure from a successful no-op.
from python_pkg.android_ui import AndroidUi
ui = AndroidUi() # or AndroidUi(serial="23181JEGR08034")
ui.tap("Connect Firebase", exact=True)
ui.type_into_field(0, "kuhy@example.com") # verifies the field changed
ui.wait_for("Connected.", timeout=30)
Guarantees
- Ambiguity is an error. A query matching 0 or >1 elements raises; it never
silently acts on the first match. (
tap "Back"correctly refuses when "OFFLINE BACKUP" is also on screen — use--exact.) - Coordinates are never reused. Every action re-reads the tree immediately
before acting, and
tapcloses the keyboard first when one is up. - Typing is verified.
type_into/type_into_fieldre-read the field and raise if the contents did not change. They clear before typing, so a filled field is replaced, not appended to. - Empty fields are findable. An empty
EditTexthas no text, content-desc or resource-id; it is kept anyway, addressable by position viaeditable_fields()/type_into_field(index, …). - Partial trees are retried, and
keyboard_is_up()distinguishes "element absent" from "element behind the keyboard".
Known gaps
- A widget with no accessibility label cannot be targeted at all. The workout
app's Settings gear is one — that is an a11y bug in the app, and this tool
surfacing it is the point. Add a
Semantics(label: …)and it becomes addressable.- But check
hintfirst. Material/Flutter text fields publish their label ashintand leave text/content-desc empty; those ARE addressable (since 2026-08-27) and need no app change. Adding aSemantics(label:)wrapper to such a field is actively harmful — it emits a SECOND labelled node at the same rect, and wrapping inMergeSemanticsto collapse them removes thehintfrom Android's tree entirely, making the whole form anonymous. Verified on the manual-workout form; reverted. - Verify any a11y change with
adb exec-out uiautomator dump, never withflutter test: the widget test asserts on Flutter's internalSemanticsNode, while this tool reads Android'sAccessibilityNodeInfo. Nine widget tests passed green on the build whose on-device labels had just gone empty.
- But check
- Scrolling is not yet modelled: a widget outside the viewport is not in the
tree. Scroll with
adb shell input swipefirst, or addscroll_to().
One phone, many sessions
Sessions share the phone by working on separate displays. By default every
command drives the real screen (display 0) and takes this session's
pkg:__screen__ lease (python_pkg.phone_lease: owner = the nearest
ancestor claude process, 180 s past the last call). A call from another
session waits up to 60 s, then exits 3 naming the holder. Do not retry in a
loop.
--display self drives this session's virtual display instead
(~/.claude/scripts/phone_vd.sh start <package> creates it) and takes only
that app's lease, so several sessions can act at once:
android-ui --display self dump
android-ui --display self tap "Save"
uiautomator dump sees display 0 only, so on a virtual display the tree
comes from the com.kuhy.a11ydump instrumentation helper (A11yDump.java,
_a11y_helper.py): built once into ~/.cache/android_ui/ and installed
with adb install -r -t on first use. Its package must stay in
phone-focus-mode's whitelist, or the focus-owner sweep hides it. Android
runs one instrumentation per package, so a second am instrument kills
the first: dumps of one phone queue behind a host-side flock
(~/.cache/android_ui/a11ydump-<serial>.lock). Every
input call carries -d (0 for the real screen), because input without it
goes to whichever display last took focus. A numeric --display that is
another session's virtual display is refused.
Raw adb from a Claude Bash call is covered too: the
~/.claude/hooks/phone_guard_pretool.sh hook (python_pkg.phone_guard)
takes the matching lease or blocks the call. See the phone-deploy skill,
section 3, for the full rules.