A raw `adb shell input tap` took no lease, so one session's tap landed in
another session's app (2026-09-25). python_pkg.phone_guard classifies every
adb call in a Bash command and, as the Claude Code PreToolUse hook, takes
the matching phone_lease (one app / real screen / whole phone) or exits 2:
key/text input without -d, input on another session's virtual display, or
a lease still held elsewhere after 40 s.
android_ui gains --display self|<id>: it drives only this session's
phone_vd display under that app's lease, and every input carries -d.
uiautomator sees display 0 only, so virtual-display trees come from a tiny
instrumentation APK (A11yDump.java) built from source without Gradle.
Prunes the oldest evidence artifact to stay under the 60-file cap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
A Flutter/Material TextField publishes its label as `hint` and leaves text
and content-desc empty, so every field on such a form arrived as an
anonymous EditText. The workout app's manual-workout form is nine of them:
the only way to fill it was by screen position, which is precisely the
mis-tap-writes-the-wrong-field failure this tool exists to prevent.
`hint` ranks below text and content-desc but above resource-id. Material
clears the hint once content arrives, so a filled field still answers to its
real text and a caller can tell an empty field from a completed one.
The app-side alternative does not work and is documented as a trap: wrapping
the field in Semantics(label:) emits a second labelled node at the same rect,
and MergeSemantics collapses them by dropping the hint from Android's tree,
which leaves the form MORE anonymous than before. Widget tests cannot catch
that -- they assert on Flutter's SemanticsNode while this tool reads
Android's AccessibilityNodeInfo, so nine of them passed green on the build
whose on-device labels had just gone empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
android_ui, app_icons, token_audit, focus_policy and random_jpg are now
entirely under 250 lines. Only wsg_grabber's six test modules remain in
python_pkg.
android_ui/driver.py 411 -> 205 took two cuts and a class split: the
element and error types plus the XML parser to _elements.py, and the
text-entry methods to a TextEntryMixin, using the ABC-with-abstract-
methods shape because the tests call them as bound attributes.
app_icons/glyphs.py 291 -> 93 by moving the twelve path fragments out.
Three intermediate failures, all module-level globals that travelled
with their code (_logger, re, _SAME_FIELD_X_TOLERANCE_PX) - the same
class of miss as _make_verifier, which ruff cannot flag.
Counts unchanged: 81, 137, 14, 44, 57.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
Shared across every repo with an Android surface (screen-locker's workout_app,
todo, dufs-cloud/app). Works against a plain release APK on a physical device
over adb — no root, no emulator, no Flutter debug connection, no app changes.
Driving an app by tapping pixel positions read off a screenshot fails in ways
that look like success. All observed in one session (2026-08-10) verifying
workout_app's Firebase restore:
* A tap aimed at a password field landed with the field unfocused. The typed
password went nowhere and NOTHING errored — the next screenshot just showed
an empty box. type_into/type_into_field now re-read the field and raise.
* Opening the soft keyboard moved a field from y=1572 to y=1319, so a
coordinate captured one step earlier was already wrong. Every action
re-resolves the element immediately before acting.
* A widget behind the keyboard reports its LAID-OUT position (1549) not its
real one (1939) — a 390px error that taps a letter key and reports success.
keyboard_is_up() distinguishes "absent" from "behind the keyboard", and tap
closes the keyboard first.
* KEYCODE_BACK is a route pop in Flutter, not a keyboard dismiss: it navigated
out of Settings and discarded typed credentials, twice. dismiss_keyboard
tries ESCAPE then BACK and verifies via dumpsys input_method.
* `input text` inserts at the cursor, so typing over a filled field produced
"canary@example.com321krzychu@gmail.com". Fields are cleared first.
* uiautomator returns ONLY the focused EditText nodes while a Flutter text
field has focus, and stays that way across retries — so a naive retry loop
never converges. Partial trees are retried and the best result kept.
* An EMPTY EditText has no text, content-desc or resource-id, so a
"must be labelled" filter drops the very field you need to fill in. Editable
nodes are kept regardless and addressable by position.
Ambiguity is an error rather than a silent first-match: `tap "Back"` correctly
refuses when "OFFLINE BACKUP" is also on screen.
Its own test suite caught a real bug in _escape: backslash sat inside the
escape loop, so "&" became "\\&" and a password containing shell
metacharacters would have been typed wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG