testsAndMisc/python_pkg/android_ui/_elements.py
Krzysztof kuhy Rudnicki 88ffa37fe1
Some checks are pending
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
Find Material text fields by their hint label
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
2026-08-27 20:39:46 +02:00

140 lines
4.7 KiB
Python

"""The element and error types the UI driver works in, and the XML parser.
Split out of :mod:`python_pkg.android_ui.driver` to keep it under the 250-line
cap. ``AndroidUi`` stays there because that is the name the tests patch;
everything here is re-imported by ``driver`` so the package's public re-exports
in ``__init__`` are unchanged.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import re
from defusedxml.ElementTree import ParseError, fromstring
_logger = logging.getLogger(__name__)
_BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
class UiAutomationError(RuntimeError):
"""Base class for every failure this package reports."""
class ElementNotFoundError(UiAutomationError):
"""No element matched the query."""
class AmbiguousElementError(UiAutomationError):
"""More than one element matched, so acting would be a coin flip."""
@dataclass(frozen=True)
class UiElement:
"""One node from the accessibility tree."""
text: str
content_desc: str
resource_id: str
class_name: str
bounds: tuple[int, int, int, int]
enabled: bool
focused: bool
# Material/Flutter text fields publish their label as `hint` and leave
# text/content-desc empty, so without this an entire form is anonymous
# edit boxes and the only way to fill it is by screen position.
hint: str = ""
@property
def label(self) -> str:
"""The best human-facing name for this element.
``hint`` ranks below the others but above nothing: it is a field's
placeholder, which Material clears once the field has content, so it
names an EMPTY field and defers to real text as soon as one is typed.
"""
return self.text or self.content_desc or self.hint or self.resource_id
@property
def center(self) -> tuple[int, int]:
"""Tap point: the centre of the element's CURRENT bounds."""
left, top, right, bottom = self.bounds
return ((left + right) // 2, (top + bottom) // 2)
def matches(self, query: str, *, exact: bool = False) -> bool:
"""Return True if ``query`` names this element."""
haystacks = (self.text, self.content_desc, self.hint, self.resource_id)
if exact:
return any(h == query for h in haystacks)
return any(query.lower() in h.lower() for h in haystacks if h)
def __str__(self) -> str:
"""Return a one-line description naming the element and its centre."""
cls = self.class_name.rsplit(".", 1)[-1]
x, y = self.center
return f"{self.label!r} <{cls}> at ({x},{y})"
def _escape(text: str) -> str:
r"""Escape text for ``adb shell input text``.
Backslash is escaped FIRST and excluded from the loop below. Escaping it
inside the loop double-escapes every backslash the loop itself just added
(``&`` -> ``\\&`` -> ``\\\\&``), which silently types the wrong password.
"""
out = text.replace("\\", "\\\\")
out = out.replace("%", "%%").replace(" ", "%s")
for char in "()<>|;&*~\"'`$":
out = out.replace(char, "\\" + char)
return out
def _parse_tree(xml: str) -> list[UiElement]:
"""Return every labelled node in an accessibility-tree dump."""
try:
root = fromstring(xml)
except ParseError as exc:
_logger.warning(
"UI dump is not valid XML (%s) — treating it as an empty screen; "
"this is usually a snapshot taken mid-animation",
exc,
)
return []
elements: list[UiElement] = []
for node in root.iter("node"):
bounds = _BOUNDS_RE.match(node.get("bounds", ""))
if bounds is None:
continue
text = node.get("text", "")
desc = node.get("content-desc", "")
hint = node.get("hint", "")
res = node.get("resource-id", "")
cls = node.get("class", "")
# An EMPTY text field has no text, content-desc or resource-id, so a
# "must be labelled" filter drops it -- and an empty field is exactly
# the thing a caller needs to find in order to type into it. Keep every
# editable node regardless of label.
if not (text or desc or hint or res or cls.endswith("EditText")):
continue
elements.append(
UiElement(
text=text,
content_desc=desc,
hint=hint,
resource_id=res,
class_name=cls,
bounds=(
int(bounds.group(1)),
int(bounds.group(2)),
int(bounds.group(3)),
int(bounds.group(4)),
),
enabled=node.get("enabled") == "true",
focused=node.get("focused") == "true",
)
)
return elements