android-driver 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ """Project configuration: `.android-driver.yaml` discovery, parsing, defaults.
2
+
3
+ Everything app-specific lives in the *consumer's* repo, never in this package.
4
+ The file is optional — with no config at all the server still exposes every
5
+ generic tool; only `build_app` and the recipe tools need it.
6
+
7
+ Discovery walks up from `$ANDROID_DRIVER_PROJECT` (or the process cwd) looking for
8
+ `.android-driver.yaml` / `.android_driver.yml` / `android_driver.yaml`. The directory
9
+ holding the file becomes `project_root`, and every relative path in the config
10
+ resolves against it — so `apk_glob` and `command` behave the same no matter
11
+ which directory the MCP client happened to launch us from.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from .log import log
24
+
25
+ CONFIG_NAMES = (".android-driver.yaml", ".android-driver.yml", "android-driver.yaml", "android-driver.yml")
26
+
27
+
28
+ class ConfigError(RuntimeError):
29
+ pass
30
+
31
+
32
+ @dataclass
33
+ class AppConfig:
34
+ package: str | None = None
35
+ activity: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class BuildConfig:
40
+ command: str | None = None
41
+ apk_glob: str | None = None
42
+ apk: str | None = None
43
+ timeout_s: int = 900
44
+
45
+
46
+ @dataclass
47
+ class InstallConfig:
48
+ # "uninstall-then-install" is the default for a reason: debug APKs built from
49
+ # different branches carry different signing keys, and `pm install -r` then
50
+ # fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE. Reinstall is offered for
51
+ # projects that deliberately want to preserve app data across installs.
52
+ strategy: str = "uninstall-then-install"
53
+ grant_runtime_perms: bool = True
54
+ appops: list[str] = field(default_factory=list)
55
+
56
+ def __post_init__(self) -> None:
57
+ allowed = {"uninstall-then-install", "reinstall"}
58
+ if self.strategy not in allowed:
59
+ raise ConfigError(f"install.strategy must be one of {sorted(allowed)}, got {self.strategy!r}")
60
+
61
+
62
+ @dataclass
63
+ class TimingConfig:
64
+ # Cold-start settle before the first UI query. Compose recomposition after a
65
+ # long idle regularly takes seconds on slower hardware; undersleeping races
66
+ # the first tap and produces "element not found" on a screen that is fine.
67
+ cold_start_settle_s: float = 2.0
68
+ # Post-click settle. Many Compose buttons render as android.view.View with
69
+ # clickable=false in the accessibility tree, so a fast follow-up query hits
70
+ # pre-animation state and misses the screen that is currently transitioning in.
71
+ click_settle_s: float = 0.25
72
+ boot_timeout_s: int = 300
73
+ default_find_timeout_s: int = 10
74
+
75
+
76
+ @dataclass
77
+ class DriverConfig:
78
+ # "auto" prefers uiautomator2 and silently falls back to the pure-adb driver
79
+ # when u2 is unavailable (not installed, or the device-side agent is missing).
80
+ backend: str = "auto"
81
+
82
+ def __post_init__(self) -> None:
83
+ allowed = {"auto", "uiautomator2", "adb"}
84
+ if self.backend not in allowed:
85
+ raise ConfigError(f"driver.backend must be one of {sorted(allowed)}, got {self.backend!r}")
86
+
87
+
88
+ @dataclass
89
+ class Config:
90
+ project_root: Path
91
+ source: Path | None
92
+ app: AppConfig = field(default_factory=AppConfig)
93
+ build: BuildConfig = field(default_factory=BuildConfig)
94
+ install: InstallConfig = field(default_factory=InstallConfig)
95
+ timing: TimingConfig = field(default_factory=TimingConfig)
96
+ driver: DriverConfig = field(default_factory=DriverConfig)
97
+ markers: dict[str, str] = field(default_factory=dict)
98
+ recipes: dict[str, Any] = field(default_factory=dict)
99
+ selectors: dict[str, Any] = field(default_factory=dict)
100
+ runs_dir: Path = field(init=False)
101
+
102
+ def __post_init__(self) -> None:
103
+ self.runs_dir = self.project_root / "runs"
104
+
105
+ @property
106
+ def package(self) -> str:
107
+ """The configured package, or raise a message that says how to fix it."""
108
+ if not self.app.package:
109
+ raise ConfigError(
110
+ "no app package configured. Either pass `pkg=` explicitly, or add to "
111
+ f"{self.source or (self.project_root / CONFIG_NAMES[0])}:\n"
112
+ " app:\n package: com.example.myapp"
113
+ )
114
+ return self.app.package
115
+
116
+
117
+ # Directories never worth descending into when looking for a config.
118
+ _SKIP_DIRS = {"build", ".git", ".gradle", ".idea", "node_modules", "venv", ".venv", "__pycache__"}
119
+
120
+
121
+ def find_config_file(start: Path | None = None) -> Path | None:
122
+ """Find the project config: walk up from `start`, then look a short way down.
123
+
124
+ Walking up is the normal case. The downward pass exists because the app under
125
+ test is often *not* at the directory the client was opened in — a repo whose
126
+ Android app lives in `app/` or `test_app/` is completely ordinary, and without
127
+ it every tool fails with "no app package configured" while a perfectly good
128
+ config sits one level below. Ambiguity is refused rather than guessed at.
129
+ """
130
+ here = (start or Path(os.environ.get("ANDROID_DRIVER_PROJECT", "."))).expanduser().resolve()
131
+ if here.is_file():
132
+ here = here.parent
133
+ for candidate in [here, *here.parents]:
134
+ for name in CONFIG_NAMES:
135
+ path = candidate / name
136
+ if path.is_file():
137
+ return path
138
+ return find_config_below(here)
139
+
140
+
141
+ def find_config_below(root: Path, max_depth: int = 3) -> Path | None:
142
+ """The single config beneath `root`, or None when there is no single answer."""
143
+ found: list[Path] = []
144
+ for depth in range(1, max_depth + 1):
145
+ for name in CONFIG_NAMES:
146
+ for path in root.glob("/".join(["*"] * depth + [name])):
147
+ if path.is_file() and not any(part in _SKIP_DIRS for part in path.parts):
148
+ found.append(path)
149
+ if found:
150
+ break
151
+ if len(found) == 1:
152
+ log("config", f"no config at {root}; using the one below it: {found[0]}")
153
+ return found[0]
154
+ if found:
155
+ log(
156
+ "config",
157
+ f"{len(found)} configs below {root} and none at it: {[str(p) for p in sorted(found)]}. "
158
+ "Set ANDROID_DRIVER_PROJECT to the one you mean.",
159
+ )
160
+ return None
161
+
162
+
163
+ def _section(raw: dict[str, Any], key: str) -> dict[str, Any]:
164
+ value = raw.get(key) or {}
165
+ if not isinstance(value, dict):
166
+ raise ConfigError(f"config section {key!r} must be a mapping, got {type(value).__name__}")
167
+ return value
168
+
169
+
170
+ def _build_dataclass(cls: type, raw: dict[str, Any], section: str):
171
+ known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
172
+ unknown = set(raw) - known
173
+ if unknown:
174
+ raise ConfigError(f"unknown key(s) in config section {section!r}: {sorted(unknown)}")
175
+ return cls(**raw)
176
+
177
+
178
+ def load(path: Path | None = None) -> Config:
179
+ """Load the project config, falling back to an all-defaults config when absent."""
180
+ source = path or find_config_file()
181
+ if source is None:
182
+ root = Path(os.environ.get("ANDROID_DRIVER_PROJECT", ".")).expanduser().resolve()
183
+ log("config", f"no config file found; using defaults with project_root={root}")
184
+ return Config(project_root=root, source=None)
185
+
186
+ try:
187
+ raw = yaml.safe_load(source.read_text(encoding="utf-8")) or {}
188
+ except yaml.YAMLError as e:
189
+ raise ConfigError(f"{source}: invalid YAML: {e}") from e
190
+ if not isinstance(raw, dict):
191
+ raise ConfigError(f"{source}: top level must be a mapping, got {type(raw).__name__}")
192
+
193
+ known_top = {"app", "build", "install", "timing", "driver", "markers", "recipes", "selectors"}
194
+ unknown_top = set(raw) - known_top
195
+ if unknown_top:
196
+ raise ConfigError(f"{source}: unknown top-level key(s): {sorted(unknown_top)}")
197
+
198
+ cfg = Config(
199
+ project_root=source.parent,
200
+ source=source,
201
+ app=_build_dataclass(AppConfig, _section(raw, "app"), "app"),
202
+ build=_build_dataclass(BuildConfig, _section(raw, "build"), "build"),
203
+ install=_build_dataclass(InstallConfig, _section(raw, "install"), "install"),
204
+ timing=_build_dataclass(TimingConfig, _section(raw, "timing"), "timing"),
205
+ driver=_build_dataclass(DriverConfig, _section(raw, "driver"), "driver"),
206
+ markers=_section(raw, "markers"),
207
+ recipes=_section(raw, "recipes"),
208
+ selectors=_section(raw, "selectors"),
209
+ )
210
+ log("config", f"loaded {source} (package={cfg.app.package}, recipes={len(cfg.recipes)})")
211
+ return cfg
@@ -0,0 +1,22 @@
1
+ """UI driver backends.
2
+
3
+ Two implementations behind one interface:
4
+
5
+ * `uiautomator2` — fast, and its accessibility SET_TEXT path is the only
6
+ reliable way to fill a Jetpack Compose TextField. Needs a one-time
7
+ per-device `python -m uiautomator2 init`, which installs a helper APK.
8
+ * `adb` — pure `uiautomator dump` + `input`. Slower and weaker at text entry,
9
+ but needs nothing on the device, so it works in CI containers and on locked
10
+ down hardware where you cannot install a helper.
11
+
12
+ `create()` picks between them; `driver.backend: auto` in the project config
13
+ prefers uiautomator2 and falls back silently.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .adb_driver import AdbDriver
19
+ from .base import Driver, DriverError
20
+ from .factory import create
21
+
22
+ __all__ = ["AdbDriver", "Driver", "DriverError", "create"]
@@ -0,0 +1,104 @@
1
+ """Zero-setup backend: `uiautomator dump` + `input`, nothing installed on device.
2
+
3
+ Slower than uiautomator2 (a hierarchy dump costs roughly a second) and weaker at
4
+ text entry, but it runs anywhere adb runs — CI containers, corporate-managed
5
+ devices, anywhere you cannot or will not install a helper APK.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ import subprocess
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from .. import adb
16
+ from ..ui import Element
17
+ from .base import Driver, DriverError
18
+
19
+ REMOTE_DUMP = "/sdcard/android-driver-dump.xml"
20
+
21
+
22
+ class AdbDriver(Driver):
23
+ name = "adb"
24
+
25
+ def dump_hierarchy(self, retries: int = 3) -> str:
26
+ """Dump the window hierarchy.
27
+
28
+ `uiautomator dump` refuses to run while the window is animating, reporting
29
+ "could not get idle state". That is a transient condition right after a tap,
30
+ so we retry rather than surfacing it — an agent cannot do anything useful
31
+ with it anyway.
32
+ """
33
+ last = ""
34
+ for attempt in range(retries):
35
+ result = adb.shell_result(self.serial, f"uiautomator dump {REMOTE_DUMP}", timeout=60)
36
+ combined = result["stdout"] + result["stderr"]
37
+ if "dumped to" in combined or "UI hierchary dumped" in combined:
38
+ xml = adb.shell_result(self.serial, f"cat {REMOTE_DUMP}", timeout=60)["stdout"]
39
+ if xml.lstrip().startswith("<"):
40
+ return xml
41
+ last = "dump file was empty or not XML"
42
+ else:
43
+ last = combined.strip()
44
+ if attempt < retries - 1:
45
+ self._log(f"hierarchy dump retry {attempt + 1}/{retries}: {last}")
46
+ time.sleep(1.0)
47
+ raise DriverError(f"uiautomator dump failed after {retries} attempts: {last}")
48
+
49
+ def screenshot(self, path: Path) -> Path:
50
+ path = Path(path)
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ # exec-out keeps the PNG binary-clean; `adb shell screencap -p` mangles
53
+ # newlines on some hosts.
54
+ with path.open("wb") as fh:
55
+ result = subprocess.run(
56
+ ["adb", "-s", self.serial, "exec-out", "screencap", "-p"],
57
+ stdout=fh,
58
+ stderr=subprocess.PIPE,
59
+ check=False,
60
+ timeout=120,
61
+ )
62
+ if result.returncode != 0 or path.stat().st_size == 0:
63
+ raise DriverError(f"screencap failed: {result.stderr.decode(errors='replace').strip()}")
64
+ return path
65
+
66
+ def _click(self, x: int, y: int) -> None:
67
+ adb.shell(self.serial, "input", "tap", str(x), str(y))
68
+
69
+ def long_click(self, x: int, y: int, duration_s: float = 1.0) -> None:
70
+ ms = int(duration_s * 1000)
71
+ adb.shell(self.serial, "input", "swipe", str(x), str(y), str(x), str(y), str(ms))
72
+
73
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, duration_s: float = 0.3) -> None:
74
+ ms = int(duration_s * 1000)
75
+ adb.shell(self.serial, "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(ms))
76
+
77
+ def press(self, key: str) -> None:
78
+ adb.shell(self.serial, "input", "keyevent", self._keycode(key))
79
+
80
+ def set_text(self, element: Element, text: str) -> None:
81
+ """Tap the field, clear it, then type.
82
+
83
+ This is the fragile path — on Jetpack Compose the tap does not always move
84
+ focus, and the text can land in a sibling field. The uiautomator2 backend
85
+ writes to the node directly and does not have this problem; prefer it when
86
+ the app under test uses Compose.
87
+ """
88
+ x, y = element.center
89
+ self.click(x, y)
90
+ # Move the caret to the end, then backspace over whatever was there. The
91
+ # field's current text gives us the count; the margin covers content that
92
+ # scrolled out of the accessibility snapshot.
93
+ self.press("KEYCODE_MOVE_END")
94
+ deletions = len(element.text) + 8
95
+ adb.shell(self.serial, "input", "keyevent", *(["KEYCODE_DEL"] * deletions), check=False)
96
+ if text:
97
+ adb.shell(self.serial, "input", "text", shlex.quote(self._escape(text)))
98
+ time.sleep(0.2)
99
+ self.dismiss_keyboard()
100
+
101
+ @staticmethod
102
+ def _escape(text: str) -> str:
103
+ """`input text` treats a space as an argument separator and %s literally."""
104
+ return text.replace("%", "%%").replace(" ", "%s")
@@ -0,0 +1,146 @@
1
+ """The driver interface, plus the behaviour both backends share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from abc import ABC, abstractmethod
7
+ from pathlib import Path
8
+
9
+ from .. import adb
10
+ from ..log import log
11
+ from ..ui import Element
12
+
13
+
14
+ class DriverError(RuntimeError):
15
+ pass
16
+
17
+
18
+ # Friendly key names → Android keycodes. The names match what an agent would
19
+ # guess; unknown names fall through as-is so raw KEYCODE_* still works.
20
+ KEYCODES = {
21
+ "home": "KEYCODE_HOME",
22
+ "back": "KEYCODE_BACK",
23
+ "menu": "KEYCODE_MENU",
24
+ "enter": "KEYCODE_ENTER",
25
+ "search": "KEYCODE_SEARCH",
26
+ "delete": "KEYCODE_DEL",
27
+ "backspace": "KEYCODE_DEL",
28
+ "tab": "KEYCODE_TAB",
29
+ "space": "KEYCODE_SPACE",
30
+ "power": "KEYCODE_POWER",
31
+ "volume_up": "KEYCODE_VOLUME_UP",
32
+ "volume_down": "KEYCODE_VOLUME_DOWN",
33
+ "camera": "KEYCODE_CAMERA",
34
+ "app_switch": "KEYCODE_APP_SWITCH",
35
+ "recent": "KEYCODE_APP_SWITCH",
36
+ "wake": "KEYCODE_WAKEUP",
37
+ "sleep": "KEYCODE_SLEEP",
38
+ "up": "KEYCODE_DPAD_UP",
39
+ "down": "KEYCODE_DPAD_DOWN",
40
+ "left": "KEYCODE_DPAD_LEFT",
41
+ "right": "KEYCODE_DPAD_RIGHT",
42
+ "center": "KEYCODE_DPAD_CENTER",
43
+ }
44
+
45
+
46
+ class Driver(ABC):
47
+ """One device, one driver. Coordinates are always device pixels."""
48
+
49
+ name = "base"
50
+
51
+ def __init__(self, serial: str, click_settle_s: float = 0.25) -> None:
52
+ self.serial = serial
53
+ self.click_settle_s = click_settle_s
54
+
55
+ # ── required of every backend ────────────────────────────────────────────
56
+
57
+ @abstractmethod
58
+ def dump_hierarchy(self) -> str: ...
59
+
60
+ @abstractmethod
61
+ def screenshot(self, path: Path) -> Path: ...
62
+
63
+ @abstractmethod
64
+ def _click(self, x: int, y: int) -> None: ...
65
+
66
+ @abstractmethod
67
+ def long_click(self, x: int, y: int, duration_s: float = 1.0) -> None: ...
68
+
69
+ @abstractmethod
70
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, duration_s: float = 0.3) -> None: ...
71
+
72
+ @abstractmethod
73
+ def press(self, key: str) -> None: ...
74
+
75
+ @abstractmethod
76
+ def set_text(self, element: Element, text: str) -> None:
77
+ """Replace the contents of a text field with `text`."""
78
+
79
+ # ── shared ───────────────────────────────────────────────────────────────
80
+
81
+ def click(self, x: int, y: int) -> None:
82
+ """Tap, then let the UI settle.
83
+
84
+ The settle is not cosmetic. Many Compose buttons surface as
85
+ `android.view.View` with `clickable=false` in the accessibility tree, so a
86
+ hierarchy query issued immediately after a tap reads pre-animation state
87
+ and reports the *old* screen — which looks exactly like a missed tap.
88
+ """
89
+ self._click(x, y)
90
+ time.sleep(self.click_settle_s)
91
+
92
+ def screen_size(self) -> tuple[int, int]:
93
+ raw = adb.shell(self.serial, "wm", "size", check=False).strip()
94
+ for part in reversed(raw.split()):
95
+ if "x" in part:
96
+ w, _, h = part.partition("x")
97
+ if w.isdigit() and h.isdigit():
98
+ return int(w), int(h)
99
+ raise DriverError(f"could not parse screen size from {raw!r}")
100
+
101
+ def current_app(self) -> dict[str, str]:
102
+ """Package and activity currently in the foreground."""
103
+ out = adb.shell(
104
+ self.serial, "dumpsys activity activities | grep -E 'mResumedActivity|topResumedActivity'",
105
+ check=False,
106
+ )
107
+ for line in out.splitlines():
108
+ for raw in line.split():
109
+ # The component sits inside an ActivityRecord{...} blob, so the
110
+ # token can carry a trailing brace or comma.
111
+ token = raw.strip("{},")
112
+ if "/" in token and "." in token:
113
+ pkg, _, activity = token.partition("/")
114
+ return {"package": pkg, "activity": activity}
115
+ return {"package": "", "activity": ""}
116
+
117
+ def keyboard_is_shown(self) -> bool:
118
+ """Authoritative across OEMs: `mInputShown=true` in the IME dump."""
119
+ try:
120
+ out = adb.shell(
121
+ self.serial, "dumpsys input_method | grep mInputShown", check=False, timeout=20
122
+ )
123
+ except Exception:
124
+ return False
125
+ return "mInputShown=true" in out
126
+
127
+ def dismiss_keyboard(self) -> None:
128
+ """Close the soft keyboard — but only when it is actually open.
129
+
130
+ Pressing Back unconditionally is a classic way to lose an hour: with no
131
+ keyboard up, Back dismisses whatever dialog or screen owns focus instead,
132
+ and the failure surfaces three steps later as a missing element.
133
+ """
134
+ if not self.keyboard_is_shown():
135
+ return
136
+ self.press("back")
137
+ time.sleep(0.3)
138
+
139
+ def close(self) -> None:
140
+ return None
141
+
142
+ def _keycode(self, key: str) -> str:
143
+ return KEYCODES.get(key.lower(), key if key.startswith("KEYCODE_") else f"KEYCODE_{key.upper()}")
144
+
145
+ def _log(self, msg: str) -> None:
146
+ log(f"driver:{self.name}", msg)
@@ -0,0 +1,29 @@
1
+ """Backend selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..log import log
6
+ from .adb_driver import AdbDriver
7
+ from .base import Driver, DriverError
8
+
9
+
10
+ def create(serial: str, backend: str = "auto", click_settle_s: float = 0.25) -> Driver:
11
+ """Build a driver for `serial`.
12
+
13
+ `auto` prefers uiautomator2 and falls back to the pure-adb backend, logging
14
+ which one won so a confused operator can see it in the server's stderr.
15
+ """
16
+ if backend == "adb":
17
+ return AdbDriver(serial, click_settle_s)
18
+
19
+ try:
20
+ from .u2_driver import U2Driver
21
+
22
+ driver = U2Driver(serial, click_settle_s)
23
+ log("driver", f"{serial}: using uiautomator2")
24
+ return driver
25
+ except Exception as e:
26
+ if backend == "uiautomator2":
27
+ raise DriverError(str(e)) from e
28
+ log("driver", f"{serial}: uiautomator2 unavailable ({e}); falling back to the adb backend")
29
+ return AdbDriver(serial, click_settle_s)
@@ -0,0 +1,100 @@
1
+ """uiautomator2 backend — the default when the device-side agent is available."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import xml.etree.ElementTree as ET
7
+ from pathlib import Path
8
+
9
+ from ..ui import Element
10
+ from .base import Driver, DriverError
11
+
12
+
13
+ class U2Driver(Driver):
14
+ name = "uiautomator2"
15
+
16
+ def __init__(self, serial: str, click_settle_s: float = 0.25, find_timeout_s: int = 10) -> None:
17
+ super().__init__(serial, click_settle_s)
18
+ try:
19
+ import uiautomator2 as u2
20
+ except ImportError as e: # pragma: no cover - dependency is declared
21
+ raise DriverError("uiautomator2 is not installed") from e
22
+ try:
23
+ self.d = u2.connect(serial)
24
+ self.d.implicitly_wait(find_timeout_s)
25
+ except Exception as e:
26
+ raise DriverError(
27
+ f"could not connect uiautomator2 to {serial}: {e}. "
28
+ f"Run `python -m uiautomator2 init` against the device, or set "
29
+ "`driver.backend: adb` in your config to use the zero-setup backend."
30
+ ) from e
31
+
32
+ def dump_hierarchy(self) -> str:
33
+ return self.d.dump_hierarchy()
34
+
35
+ def screenshot(self, path: Path) -> Path:
36
+ path = Path(path)
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ self.d.screenshot(str(path))
39
+ return path
40
+
41
+ def _click(self, x: int, y: int) -> None:
42
+ self.d.click(x, y)
43
+
44
+ def long_click(self, x: int, y: int, duration_s: float = 1.0) -> None:
45
+ self.d.long_click(x, y, duration_s)
46
+
47
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, duration_s: float = 0.3) -> None:
48
+ self.d.swipe(x1, y1, x2, y2, duration=duration_s)
49
+
50
+ def press(self, key: str) -> None:
51
+ friendly = key.lower()
52
+ if friendly in {"home", "back", "menu", "enter", "search", "delete", "recent", "power",
53
+ "volume_up", "volume_down", "camera", "left", "right", "up", "down", "center"}:
54
+ self.d.press(friendly)
55
+ else:
56
+ self.d.shell(f"input keyevent {self._keycode(key)}")
57
+
58
+ def set_text(self, element: Element, text: str) -> None:
59
+ """Write directly to the target node via the accessibility SET_TEXT action.
60
+
61
+ Tap-then-type is unreliable on Compose: focus does not always follow the
62
+ tap and the text lands in a sibling TextField. Addressing the EditText by
63
+ its document-order index and calling set_text bypasses focus dispatch
64
+ entirely, which is why this indirection exists.
65
+ """
66
+ index = self._edittext_index(element)
67
+ self.d(className="android.widget.EditText", instance=index).set_text(text)
68
+ time.sleep(0.2)
69
+ self.dismiss_keyboard()
70
+
71
+ def _edittext_index(self, element: Element) -> int:
72
+ """Position of `element` among all EditTexts in the current hierarchy.
73
+
74
+ Uses a single snapshot so we do not race recomposition between locating the
75
+ target and enumerating its siblings.
76
+ """
77
+ root = ET.fromstring(self.dump_hierarchy())
78
+ edittexts = [n for n in root.iter("node") if n.attrib.get("class") == "android.widget.EditText"]
79
+ for i, node in enumerate(edittexts):
80
+ bounds = node.attrib.get("bounds", "")
81
+ if bounds and _bounds_match(bounds, element.bounds):
82
+ return i
83
+ # Compose renders the label as a descendant View of the EditText, so the
84
+ # content-desc we matched on may sit one level down.
85
+ for child in node.iter("node"):
86
+ if element.desc and child.attrib.get("content-desc") == element.desc:
87
+ return i
88
+ if element.rid and child.attrib.get("resource-id") == element.rid:
89
+ return i
90
+ raise DriverError(
91
+ f"could not locate an EditText for {element.label()!r}. "
92
+ "Call `screen` to re-read the current layout."
93
+ )
94
+
95
+
96
+ def _bounds_match(raw: str, bounds: tuple[int, int, int, int]) -> bool:
97
+ from ..ui import BOUNDS_RE
98
+
99
+ m = BOUNDS_RE.match(raw)
100
+ return bool(m) and tuple(int(g) for g in m.groups()) == bounds