testsAndMisc/python_pkg/android_ui
Krzysztof kuhy Rudnicki ed9fb48c51
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 (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Move TODOs to todo app
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 11:29:12 +01:00
..
tests android_ui: add scroll-to, narrow TODO 2026-11-02 07:56:57 +01:00
__init__.py Add android_ui: drive Android apps by element, never by coordinates 2026-08-10 17:01:28 +02:00
__main__.py Re-point paths for the ~ reorganisation (home-tidy) 2026-09-11 20:50:19 +02:00
_a11y_helper.py a11ydump: queue dumps of one phone behind a host lock 2026-09-25 16:36:50 +02:00
_elements.py Find Material text fields by their hint label 2026-08-27 20:39:46 +02:00
_scroll.py android_ui: add scroll-to, narrow TODO 2026-11-02 07:56:57 +01:00
_text_entry.py android_ui: never check or dismiss the keyboard on a virtual display 2026-09-27 17:34:25 +02:00
a11y_dump_manifest.xml phone_guard: lease or block raw adb; android_ui drives a session's own virtual display 2026-09-25 16:09:45 +02:00
A11yDump.java phone_guard: lease or block raw adb; android_ui drives a session's own virtual display 2026-09-25 16:09:45 +02:00
cli.py android_ui: add scroll-to, narrow TODO 2026-11-02 07:56:57 +01:00
DOCS-android-ui-rationale.md Move TODOs to todo app 2026-11-02 11:29:12 +01:00
driver.py android_ui: add scroll-to, narrow TODO 2026-11-02 07:56:57 +01:00
README.md Prettier-format two docs blocking push 2026-11-02 09:11:24 +01:00

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 dump returns only the EditText nodes 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_BACK is 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 tap closes the keyboard first when one is up.
  • Typing is verified. type_into / type_into_field re-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 EditText has no text, content-desc or resource-id; it is kept anyway, addressable by position via editable_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 hint first. Material/Flutter text fields publish their label as hint and leave text/content-desc empty; those ARE addressable (since 2026-08-27) and need no app change. Adding a Semantics(label:) wrapper to such a field is actively harmful — it emits a SECOND labelled node at the same rect, and wrapping in MergeSemantics to collapse them removes the hint from 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 with flutter test: the widget test asserts on Flutter's internal SemanticsNode, while this tool reads Android's AccessibilityNodeInfo. Nine widget tests passed green on the build whose on-device labels had just gone empty.
  • A widget outside the viewport is not in the tree. android-ui scroll-to QUERY (AndroidUi.scroll_to) swipes up until it appears, and stops with an error at the end of the list or after 8 swipes. It only scrolls downward.

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.