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.
- android_driver/__init__.py +3 -0
- android_driver/actions.py +209 -0
- android_driver/adb.py +355 -0
- android_driver/build.py +81 -0
- android_driver/config.py +211 -0
- android_driver/drivers/__init__.py +22 -0
- android_driver/drivers/adb_driver.py +104 -0
- android_driver/drivers/base.py +146 -0
- android_driver/drivers/factory.py +29 -0
- android_driver/drivers/u2_driver.py +100 -0
- android_driver/emulator.py +277 -0
- android_driver/expect.py +190 -0
- android_driver/log.py +16 -0
- android_driver/recipes.py +547 -0
- android_driver/record.py +112 -0
- android_driver/run.py +292 -0
- android_driver/scan.py +155 -0
- android_driver/server.py +777 -0
- android_driver/session.py +144 -0
- android_driver/ui.py +261 -0
- android_driver-0.0.1.dist-info/METADATA +270 -0
- android_driver-0.0.1.dist-info/RECORD +25 -0
- android_driver-0.0.1.dist-info/WHEEL +4 -0
- android_driver-0.0.1.dist-info/entry_points.txt +2 -0
- android_driver-0.0.1.dist-info/licenses/LICENSE +21 -0
android_driver/config.py
ADDED
|
@@ -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
|