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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Device verbs, factored out of the tool layer.
|
|
2
|
+
|
|
3
|
+
Every function here raises on failure and returns a plain dict of facts on
|
|
4
|
+
success. The MCP tool layer turns that into `{"ok": ...}` and the recipe
|
|
5
|
+
interpreter calls the very same functions — so a recipe step and a hand-driven
|
|
6
|
+
tool call cannot drift apart, which is the whole point of the split.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import adb, ui
|
|
15
|
+
from . import build as build_mod
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .session import Session
|
|
18
|
+
|
|
19
|
+
DIRECTIONS = ("up", "down", "left", "right")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── UI ────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def tap(
|
|
26
|
+
session: Session,
|
|
27
|
+
*,
|
|
28
|
+
ref: str | None = None,
|
|
29
|
+
text: str | None = None,
|
|
30
|
+
contains: str | None = None,
|
|
31
|
+
desc: str | None = None,
|
|
32
|
+
rid: str | None = None,
|
|
33
|
+
cls: str | None = None,
|
|
34
|
+
index: int = 0,
|
|
35
|
+
) -> dict:
|
|
36
|
+
element = session.resolve(
|
|
37
|
+
ref=ref, text=text, contains=contains, desc=desc, rid=rid, cls=cls, index=index
|
|
38
|
+
)
|
|
39
|
+
x, y = element.center
|
|
40
|
+
session.driver.click(x, y)
|
|
41
|
+
session.invalidate()
|
|
42
|
+
return {"tapped": element.label(), "at": [x, y]}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def tap_xy(session: Session, x: int, y: int) -> dict:
|
|
46
|
+
session.driver.click(x, y)
|
|
47
|
+
session.invalidate()
|
|
48
|
+
return {"at": [x, y]}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def long_press(
|
|
52
|
+
session: Session,
|
|
53
|
+
*,
|
|
54
|
+
duration_s: float = 1.0,
|
|
55
|
+
ref: str | None = None,
|
|
56
|
+
text: str | None = None,
|
|
57
|
+
contains: str | None = None,
|
|
58
|
+
desc: str | None = None,
|
|
59
|
+
rid: str | None = None,
|
|
60
|
+
index: int = 0,
|
|
61
|
+
) -> dict:
|
|
62
|
+
element = session.resolve(ref=ref, text=text, contains=contains, desc=desc, rid=rid, index=index)
|
|
63
|
+
x, y = element.center
|
|
64
|
+
session.driver.long_click(x, y, duration_s)
|
|
65
|
+
session.invalidate()
|
|
66
|
+
return {"long_pressed": element.label(), "at": [x, y], "duration_s": duration_s}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def type_text(
|
|
70
|
+
session: Session,
|
|
71
|
+
text: str,
|
|
72
|
+
*,
|
|
73
|
+
ref: str | None = None,
|
|
74
|
+
desc: str | None = None,
|
|
75
|
+
rid: str | None = None,
|
|
76
|
+
contains: str | None = None,
|
|
77
|
+
index: int = 0,
|
|
78
|
+
) -> dict:
|
|
79
|
+
element = session.resolve(ref=ref, desc=desc, rid=rid, contains=contains, index=index)
|
|
80
|
+
session.driver.set_text(element, text)
|
|
81
|
+
session.invalidate()
|
|
82
|
+
return {"field": element.label(), "text": text}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def swipe(session: Session, direction: str = "up", distance: float = 0.6, duration_s: float = 0.3) -> dict:
|
|
86
|
+
if direction not in DIRECTIONS:
|
|
87
|
+
raise ValueError(f"direction must be one of {list(DIRECTIONS)}, got {direction!r}")
|
|
88
|
+
width, height = session.driver.screen_size()
|
|
89
|
+
cx, cy = width // 2, height // 2
|
|
90
|
+
dx = int(width * distance / 2)
|
|
91
|
+
dy = int(height * distance / 2)
|
|
92
|
+
moves = {
|
|
93
|
+
"up": (cx, cy + dy, cx, cy - dy),
|
|
94
|
+
"down": (cx, cy - dy, cx, cy + dy),
|
|
95
|
+
"left": (cx + dx, cy, cx - dx, cy),
|
|
96
|
+
"right": (cx - dx, cy, cx + dx, cy),
|
|
97
|
+
}
|
|
98
|
+
session.driver.swipe(*moves[direction], duration_s=duration_s)
|
|
99
|
+
session.invalidate()
|
|
100
|
+
return {"direction": direction, "distance": distance}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def scroll_to(
|
|
104
|
+
session: Session,
|
|
105
|
+
*,
|
|
106
|
+
ref: str | None = None,
|
|
107
|
+
text: str | None = None,
|
|
108
|
+
contains: str | None = None,
|
|
109
|
+
desc: str | None = None,
|
|
110
|
+
rid: str | None = None,
|
|
111
|
+
direction: str = "up",
|
|
112
|
+
max_swipes: int = 8,
|
|
113
|
+
distance: float = 0.5,
|
|
114
|
+
) -> dict:
|
|
115
|
+
"""Swipe until a selector shows up, then stop. Raises if it never appears.
|
|
116
|
+
|
|
117
|
+
Scrolling is a loop rather than a driver primitive because the pure-adb
|
|
118
|
+
backend has no scroll-to; doing it here means both backends behave the same.
|
|
119
|
+
"""
|
|
120
|
+
selector = {"ref": ref, "text": text, "contains": contains, "desc": desc, "rid": rid}
|
|
121
|
+
if not any(v is not None for v in selector.values()):
|
|
122
|
+
raise ValueError("scroll_to needs a selector: ref / text / contains / desc / rid")
|
|
123
|
+
|
|
124
|
+
for attempt in range(max_swipes + 1):
|
|
125
|
+
try:
|
|
126
|
+
element = ui.find(session.refresh(), **selector)
|
|
127
|
+
return {"found": element.label(), "swipes": attempt, "at": list(element.center)}
|
|
128
|
+
except LookupError:
|
|
129
|
+
if attempt == max_swipes:
|
|
130
|
+
break
|
|
131
|
+
swipe(session, direction=direction, distance=distance)
|
|
132
|
+
time.sleep(0.2)
|
|
133
|
+
active = {k: v for k, v in selector.items() if v is not None}
|
|
134
|
+
raise LookupError(f"nothing matching {active} after {max_swipes} {direction} swipes")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def press_key(session: Session, key: str) -> dict:
|
|
138
|
+
session.driver.press(key)
|
|
139
|
+
session.invalidate()
|
|
140
|
+
return {"key": key}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def screenshot(session: Session, path: Path) -> Path:
|
|
144
|
+
return session.driver.screenshot(path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── app lifecycle ─────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_app(cfg: Config) -> dict:
|
|
151
|
+
return {"apk_path": str(build_mod.build(cfg))}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def install_app(
|
|
155
|
+
session: Session,
|
|
156
|
+
cfg: Config,
|
|
157
|
+
apk_path: str | None = None,
|
|
158
|
+
build_first: bool = False,
|
|
159
|
+
pkg: str | None = None,
|
|
160
|
+
) -> dict:
|
|
161
|
+
apk = build_mod.build(cfg) if build_first else build_mod.resolve_apk(cfg, apk_path)
|
|
162
|
+
result = adb.install(
|
|
163
|
+
session.serial,
|
|
164
|
+
apk,
|
|
165
|
+
pkg or cfg.package,
|
|
166
|
+
strategy=cfg.install.strategy,
|
|
167
|
+
grant_runtime_perms=cfg.install.grant_runtime_perms,
|
|
168
|
+
appops=cfg.install.appops or None,
|
|
169
|
+
)
|
|
170
|
+
session.invalidate()
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def uninstall_app(session: Session, cfg: Config, pkg: str | None = None) -> dict:
|
|
175
|
+
target = pkg or cfg.package
|
|
176
|
+
adb.uninstall(session.serial, target)
|
|
177
|
+
session.invalidate()
|
|
178
|
+
return {"pkg": target}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def launch_app(session: Session, cfg: Config, pkg: str | None = None, cold: bool = True) -> dict:
|
|
182
|
+
target = pkg or cfg.package
|
|
183
|
+
if cold:
|
|
184
|
+
adb.force_stop(session.serial, target)
|
|
185
|
+
adb.launch(session.serial, target, cfg.app.activity if target == cfg.app.package else None)
|
|
186
|
+
time.sleep(cfg.timing.cold_start_settle_s)
|
|
187
|
+
session.invalidate()
|
|
188
|
+
return {"pkg": target, "cold": cold}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def force_stop(session: Session, cfg: Config, pkg: str | None = None) -> dict:
|
|
192
|
+
target = pkg or cfg.package
|
|
193
|
+
adb.force_stop(session.serial, target)
|
|
194
|
+
session.invalidate()
|
|
195
|
+
return {"pkg": target}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def clear_app_data(session: Session, cfg: Config, pkg: str | None = None) -> dict:
|
|
199
|
+
target = pkg or cfg.package
|
|
200
|
+
adb.clear_data(session.serial, target)
|
|
201
|
+
session.invalidate()
|
|
202
|
+
return {"pkg": target}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── shell ─────────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def shell(session: Session, cmd: str) -> dict:
|
|
209
|
+
return adb.shell_result(session.serial, cmd)
|
android_driver/adb.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Generic ADB wrappers. No project-specific knowledge lives here.
|
|
2
|
+
|
|
3
|
+
Two behaviours are load-bearing and deliberately not "simplified":
|
|
4
|
+
|
|
5
|
+
* `install()` defaults to uninstall-then-install rather than `pm install -r`.
|
|
6
|
+
Debug APKs built from different branches are signed with different debug
|
|
7
|
+
keys, and reinstalling over one with the other fails with
|
|
8
|
+
INSTALL_FAILED_UPDATE_INCOMPATIBLE — a confusing error that costs an
|
|
9
|
+
afternoon the first time you hit it.
|
|
10
|
+
* The permission sweep grants every runtime permission the manifest declares
|
|
11
|
+
and then *verifies* none are still denied, with an OEM `appops` pass for
|
|
12
|
+
skins (MIUI and friends) whose permission overlay can keep blocking an app
|
|
13
|
+
after `pm grant` reports success.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .log import log
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AdbError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Ops that OEM permission overlays most often keep blocking after a successful
|
|
30
|
+
# `pm grant`. Cheap to set, harmless when they do not apply.
|
|
31
|
+
OEM_APPOPS = (
|
|
32
|
+
"CAMERA",
|
|
33
|
+
"RECORD_AUDIO",
|
|
34
|
+
"CALL_PHONE",
|
|
35
|
+
"READ_PHONE_STATE",
|
|
36
|
+
"POST_NOTIFICATION",
|
|
37
|
+
"SYSTEM_ALERT_WINDOW",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_VERSION_CODE_RE = re.compile(r"versionCode=(\d+)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _adb(
|
|
44
|
+
args: list[str], *, check: bool = True, timeout: int | None = 120
|
|
45
|
+
) -> subprocess.CompletedProcess[str]:
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(["adb", *args], check=False, text=True, capture_output=True, timeout=timeout)
|
|
48
|
+
except FileNotFoundError as e:
|
|
49
|
+
raise AdbError("`adb` not found on PATH. Install Android platform-tools and retry.") from e
|
|
50
|
+
except subprocess.TimeoutExpired as e:
|
|
51
|
+
raise AdbError(f"adb {' '.join(args)} timed out after {timeout}s") from e
|
|
52
|
+
if check and result.returncode != 0:
|
|
53
|
+
raise AdbError(f"adb {' '.join(args)} failed (exit={result.returncode}): {result.stderr.strip()}")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(serial: str, *args: str, check: bool = True, timeout: int | None = 120) -> str:
|
|
58
|
+
return _adb(["-s", serial, *args], check=check, timeout=timeout).stdout
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def shell(serial: str, *args: str, check: bool = True, timeout: int | None = 120) -> str:
|
|
62
|
+
return run(serial, "shell", *args, check=check, timeout=timeout)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def shell_result(serial: str, cmd: str, timeout: int | None = 120) -> dict:
|
|
66
|
+
"""Run a raw shell command string and return its full result.
|
|
67
|
+
|
|
68
|
+
`cmd` is passed as a single argument, so the device-side `sh -c` interprets
|
|
69
|
+
it — pipes, quoting and redirection all work.
|
|
70
|
+
"""
|
|
71
|
+
result = _adb(["-s", serial, "shell", cmd], check=False, timeout=timeout)
|
|
72
|
+
return {
|
|
73
|
+
"ok": result.returncode == 0,
|
|
74
|
+
"exit_code": result.returncode,
|
|
75
|
+
"stdout": result.stdout,
|
|
76
|
+
"stderr": result.stderr,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── devices ───────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def list_devices() -> list[dict[str, str]]:
|
|
84
|
+
"""Every attached device in state `device`, with its model and AVD name."""
|
|
85
|
+
out = _adb(["devices", "-l"]).stdout
|
|
86
|
+
devices: list[dict[str, str]] = []
|
|
87
|
+
for raw in out.splitlines():
|
|
88
|
+
line = raw.strip()
|
|
89
|
+
if not line or line.startswith("List of devices"):
|
|
90
|
+
continue
|
|
91
|
+
parts = line.split()
|
|
92
|
+
if len(parts) < 2 or parts[1] != "device":
|
|
93
|
+
continue
|
|
94
|
+
serial = parts[0]
|
|
95
|
+
props = dict(p.split(":", 1) for p in parts[2:] if ":" in p)
|
|
96
|
+
devices.append(
|
|
97
|
+
{
|
|
98
|
+
"serial": serial,
|
|
99
|
+
"model": props.get("model", ""),
|
|
100
|
+
"device": props.get("device", ""),
|
|
101
|
+
"is_emulator": str(serial.startswith("emulator-")),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
return devices
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_serials() -> list[str]:
|
|
108
|
+
return [d["serial"] for d in list_devices()]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def pick_device() -> str:
|
|
112
|
+
"""First serial in state `device`. Emulators win ties — this is android_driver."""
|
|
113
|
+
devices = list_devices()
|
|
114
|
+
if not devices:
|
|
115
|
+
raise AdbError(
|
|
116
|
+
"no adb device in state 'device'. Start an emulator with `start_emulator`, "
|
|
117
|
+
"or check `adb devices` for an unauthorized/offline entry."
|
|
118
|
+
)
|
|
119
|
+
emulators = [d for d in devices if d["is_emulator"] == "True"]
|
|
120
|
+
chosen = (emulators or devices)[0]["serial"]
|
|
121
|
+
if len(devices) > 1:
|
|
122
|
+
others = [d["serial"] for d in devices if d["serial"] != chosen]
|
|
123
|
+
log("adb", f"WARNING: {len(devices)} devices attached; picking {chosen!r}. Others: {others}")
|
|
124
|
+
return chosen
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def device_info(serial: str) -> dict[str, str]:
|
|
128
|
+
props = {
|
|
129
|
+
"model": "ro.product.model",
|
|
130
|
+
"manufacturer": "ro.product.manufacturer",
|
|
131
|
+
"android_version": "ro.build.version.release",
|
|
132
|
+
"sdk": "ro.build.version.sdk",
|
|
133
|
+
"abi": "ro.product.cpu.abi",
|
|
134
|
+
"avd_name": "ro.boot.qemu.avd_name",
|
|
135
|
+
}
|
|
136
|
+
info = {"serial": serial}
|
|
137
|
+
for key, prop in props.items():
|
|
138
|
+
info[key] = shell(serial, "getprop", prop, check=False).strip()
|
|
139
|
+
size = shell(serial, "wm", "size", check=False).strip()
|
|
140
|
+
density = shell(serial, "wm", "density", check=False).strip()
|
|
141
|
+
info["screen"] = size.split(":")[-1].strip() if ":" in size else size
|
|
142
|
+
info["density"] = density.split(":")[-1].strip() if ":" in density else density
|
|
143
|
+
return info
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── app lifecycle ─────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_installed(serial: str, pkg: str) -> bool:
|
|
150
|
+
out = shell(serial, "pm", "list", "packages", pkg, check=False)
|
|
151
|
+
return any(line.strip() == f"package:{pkg}" for line in out.splitlines())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def app_info(serial: str, pkg: str) -> dict:
|
|
155
|
+
"""Version metadata from `dumpsys package`. Prefer this over parsing pm output by hand."""
|
|
156
|
+
if not is_installed(serial, pkg):
|
|
157
|
+
return {"installed": False, "pkg": pkg}
|
|
158
|
+
out = shell(serial, "dumpsys", "package", pkg, check=False)
|
|
159
|
+
info: dict = {
|
|
160
|
+
"installed": True,
|
|
161
|
+
"pkg": pkg,
|
|
162
|
+
"version_name": None,
|
|
163
|
+
"version_code": None,
|
|
164
|
+
"first_install_time": None,
|
|
165
|
+
"last_update_time": None,
|
|
166
|
+
"apk_path": None,
|
|
167
|
+
}
|
|
168
|
+
for raw in out.splitlines():
|
|
169
|
+
line = raw.strip()
|
|
170
|
+
if line.startswith("versionName="):
|
|
171
|
+
info["version_name"] = line.split("=", 1)[1].strip()
|
|
172
|
+
elif line.startswith("versionCode="):
|
|
173
|
+
m = _VERSION_CODE_RE.search(line)
|
|
174
|
+
if m:
|
|
175
|
+
info["version_code"] = int(m.group(1))
|
|
176
|
+
elif line.startswith("firstInstallTime="):
|
|
177
|
+
info["first_install_time"] = line.split("=", 1)[1].strip()
|
|
178
|
+
elif line.startswith("lastUpdateTime="):
|
|
179
|
+
info["last_update_time"] = line.split("=", 1)[1].strip()
|
|
180
|
+
elif line.startswith("codePath="):
|
|
181
|
+
info["apk_path"] = line.split("=", 1)[1].strip()
|
|
182
|
+
return info
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def force_stop(serial: str, pkg: str) -> None:
|
|
186
|
+
shell(serial, "am", "force-stop", pkg)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def clear_data(serial: str, pkg: str) -> None:
|
|
190
|
+
shell(serial, "pm", "clear", pkg)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def pidof(serial: str, pkg: str) -> int | None:
|
|
194
|
+
out = shell(serial, "pidof", pkg, check=False).strip()
|
|
195
|
+
if not out:
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
return int(out.split()[0])
|
|
199
|
+
except ValueError:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def launch(serial: str, pkg: str, activity: str | None = None) -> None:
|
|
204
|
+
"""Start the app. With no activity, resolve the launcher intent via monkey."""
|
|
205
|
+
if activity:
|
|
206
|
+
component = activity if "/" in activity else f"{pkg}/{activity}"
|
|
207
|
+
shell(serial, "am", "start", "-n", component)
|
|
208
|
+
return
|
|
209
|
+
result = _adb(
|
|
210
|
+
["-s", serial, "shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1"],
|
|
211
|
+
check=False,
|
|
212
|
+
)
|
|
213
|
+
if "No activities found" in (result.stdout + result.stderr):
|
|
214
|
+
raise AdbError(
|
|
215
|
+
f"{pkg} has no launcher activity. Pass an explicit activity, or set "
|
|
216
|
+
"`app.activity` in your config."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def uninstall(serial: str, pkg: str) -> None:
|
|
221
|
+
"""Uninstall `pkg`. No-op when it is not installed."""
|
|
222
|
+
if not is_installed(serial, pkg):
|
|
223
|
+
return
|
|
224
|
+
result = _adb(["-s", serial, "uninstall", pkg], check=False)
|
|
225
|
+
combined = (result.stdout + result.stderr).lower()
|
|
226
|
+
benign = ("not installed", "unknown package", "delete_failed_internal_error")
|
|
227
|
+
if result.returncode == 0 or any(s in combined for s in benign):
|
|
228
|
+
return
|
|
229
|
+
raise AdbError(f"uninstall {pkg} on {serial} failed: {result.stdout.strip()} {result.stderr.strip()}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def declared_permissions(serial: str, pkg: str) -> list[str]:
|
|
233
|
+
out = shell(serial, "dumpsys", "package", pkg, check=False)
|
|
234
|
+
perms: list[str] = []
|
|
235
|
+
for raw in out.splitlines():
|
|
236
|
+
line = raw.strip()
|
|
237
|
+
if line.startswith("android.permission.") and ":" in line:
|
|
238
|
+
perm = line.split(":", 1)[0].strip()
|
|
239
|
+
if perm not in perms:
|
|
240
|
+
perms.append(perm)
|
|
241
|
+
return perms
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def denied_permissions(serial: str, pkg: str) -> list[str]:
|
|
245
|
+
out = shell(serial, "dumpsys", "package", pkg, check=False)
|
|
246
|
+
return [
|
|
247
|
+
line.strip().split(":", 1)[0].strip()
|
|
248
|
+
for line in out.splitlines()
|
|
249
|
+
if line.strip().startswith("android.permission.") and "granted=false" in line
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def grant(serial: str, pkg: str, perm: str) -> None:
|
|
254
|
+
"""Grant a runtime permission. Install-time perms produce a benign error we swallow."""
|
|
255
|
+
result = _adb(["-s", serial, "shell", "pm", "grant", pkg, perm], check=False)
|
|
256
|
+
if result.returncode == 0:
|
|
257
|
+
return
|
|
258
|
+
combined = (result.stdout + result.stderr).lower()
|
|
259
|
+
if "not a changeable permission" in combined or "not a runtime" in combined:
|
|
260
|
+
return
|
|
261
|
+
raise AdbError(f"pm grant {perm} on {serial} failed: {result.stdout.strip()} {result.stderr.strip()}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def set_appops(serial: str, pkg: str, ops: tuple[str, ...] | list[str] = OEM_APPOPS) -> None:
|
|
265
|
+
"""Force-allow app ops that OEM permission overlays keep blocking after `pm grant`."""
|
|
266
|
+
for op in ops:
|
|
267
|
+
shell(serial, "appops", "set", pkg, op, "allow", check=False)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _install_apk(serial: str, apk: Path) -> None:
|
|
271
|
+
result = _adb(["-s", serial, "install", "-g", "-t", str(apk)], check=False, timeout=600)
|
|
272
|
+
combined = result.stdout + result.stderr
|
|
273
|
+
if result.returncode != 0 or "Success" not in combined:
|
|
274
|
+
if "INSTALL_FAILED_UPDATE_INCOMPATIBLE" in combined:
|
|
275
|
+
raise AdbError(
|
|
276
|
+
f"install {apk.name} failed: signature mismatch with the installed copy. "
|
|
277
|
+
"Set `install.strategy: uninstall-then-install` (the default) in your config."
|
|
278
|
+
)
|
|
279
|
+
raise AdbError(f"install {apk.name} on {serial} failed: {combined.strip()}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def install(
|
|
283
|
+
serial: str,
|
|
284
|
+
apk_path: str | Path,
|
|
285
|
+
pkg: str,
|
|
286
|
+
*,
|
|
287
|
+
strategy: str = "uninstall-then-install",
|
|
288
|
+
grant_runtime_perms: bool = True,
|
|
289
|
+
appops: list[str] | None = None,
|
|
290
|
+
) -> dict:
|
|
291
|
+
"""Full install cycle: stop → (uninstall) → install → grant → verify."""
|
|
292
|
+
apk = Path(apk_path).expanduser().resolve()
|
|
293
|
+
if not apk.is_file():
|
|
294
|
+
raise AdbError(f"APK not found: {apk}")
|
|
295
|
+
|
|
296
|
+
force_stop(serial, pkg)
|
|
297
|
+
if strategy == "uninstall-then-install":
|
|
298
|
+
uninstall(serial, pkg)
|
|
299
|
+
_install_apk(serial, apk)
|
|
300
|
+
|
|
301
|
+
granted: list[str] = []
|
|
302
|
+
if grant_runtime_perms:
|
|
303
|
+
for perm in declared_permissions(serial, pkg):
|
|
304
|
+
grant(serial, pkg, perm)
|
|
305
|
+
granted.append(perm)
|
|
306
|
+
set_appops(serial, pkg, appops or OEM_APPOPS)
|
|
307
|
+
still_denied = denied_permissions(serial, pkg)
|
|
308
|
+
if still_denied:
|
|
309
|
+
raise AdbError(
|
|
310
|
+
f"runtime permissions still denied after the grant sweep: {still_denied}. "
|
|
311
|
+
f"Fix manually: adb -s {serial} shell pm grant {pkg} <perm>"
|
|
312
|
+
)
|
|
313
|
+
return {"apk_path": str(apk), "pkg": pkg, "granted_permissions": granted}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ── logcat ────────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def logcat_clear(serial: str) -> None:
|
|
320
|
+
_adb(["-s", serial, "logcat", "-c"], check=False)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def logcat_dump(
|
|
324
|
+
serial: str,
|
|
325
|
+
*,
|
|
326
|
+
lines: int | None = 2000,
|
|
327
|
+
pkg: str | None = None,
|
|
328
|
+
pattern: str | None = None,
|
|
329
|
+
level: str | None = None,
|
|
330
|
+
buffers: str | None = None,
|
|
331
|
+
) -> list[str]:
|
|
332
|
+
"""Snapshot the log buffer. `pkg` filters by live PID; `pattern` is a regex.
|
|
333
|
+
|
|
334
|
+
`buffers` maps to `logcat -b` — pass "main,crash" to catch native aborts and
|
|
335
|
+
tombstones, which never reach the default buffer. Note that `pkg` resolves to
|
|
336
|
+
a *live* PID, so it silently matches nothing once the process has died; leave
|
|
337
|
+
it unset when you are looking for the crash that killed it.
|
|
338
|
+
"""
|
|
339
|
+
args = ["-s", serial, "logcat", "-d"]
|
|
340
|
+
if buffers:
|
|
341
|
+
args += ["-b", buffers]
|
|
342
|
+
if lines:
|
|
343
|
+
args += ["-t", str(lines)]
|
|
344
|
+
if pkg:
|
|
345
|
+
pid = pidof(serial, pkg)
|
|
346
|
+
if pid is not None:
|
|
347
|
+
args += [f"--pid={pid}"]
|
|
348
|
+
if level:
|
|
349
|
+
args += ["*:" + level.upper()[0]]
|
|
350
|
+
out = _adb(args, check=False, timeout=60).stdout
|
|
351
|
+
result = out.splitlines()
|
|
352
|
+
if pattern:
|
|
353
|
+
rx = re.compile(pattern)
|
|
354
|
+
result = [line for line in result if rx.search(line)]
|
|
355
|
+
return result
|
android_driver/build.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Building the app under test.
|
|
2
|
+
|
|
3
|
+
Fully generic: the project config supplies a shell command and a glob for the
|
|
4
|
+
resulting artifact, so this works for Gradle, Bazel, a Makefile, or a script.
|
|
5
|
+
`build.apk` short-circuits everything for projects that ship a prebuilt binary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .log import log
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BuildError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_apk(cfg: Config, path: str | None = None) -> Path:
|
|
22
|
+
"""Return an APK to install without building: explicit arg, then config, then glob."""
|
|
23
|
+
if path:
|
|
24
|
+
apk = Path(path).expanduser()
|
|
25
|
+
apk = apk if apk.is_absolute() else cfg.project_root / apk
|
|
26
|
+
if not apk.is_file():
|
|
27
|
+
raise BuildError(f"APK not found: {apk}")
|
|
28
|
+
return apk.resolve()
|
|
29
|
+
if cfg.build.apk:
|
|
30
|
+
apk = Path(cfg.build.apk).expanduser()
|
|
31
|
+
apk = apk if apk.is_absolute() else cfg.project_root / apk
|
|
32
|
+
if not apk.is_file():
|
|
33
|
+
raise BuildError(f"build.apk is set but the file does not exist: {apk}")
|
|
34
|
+
return apk.resolve()
|
|
35
|
+
return find_apk(cfg)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_apk(cfg: Config) -> Path:
|
|
39
|
+
if not cfg.build.apk_glob:
|
|
40
|
+
raise BuildError(
|
|
41
|
+
"no APK to install. Add one of these to your config:\n"
|
|
42
|
+
" build:\n apk: path/to/app.apk\n"
|
|
43
|
+
" # or, to build from source:\n"
|
|
44
|
+
" build:\n command: ./gradlew :app:assembleDebug\n"
|
|
45
|
+
" apk_glob: app/build/outputs/apk/debug/*.apk"
|
|
46
|
+
)
|
|
47
|
+
matches = sorted(cfg.project_root.glob(cfg.build.apk_glob))
|
|
48
|
+
if not matches:
|
|
49
|
+
raise BuildError(f"no APK matched {cfg.build.apk_glob!r} under {cfg.project_root}")
|
|
50
|
+
if len(matches) > 1:
|
|
51
|
+
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
52
|
+
log("build", f"WARNING: {len(matches)} APKs matched {cfg.build.apk_glob!r}; picking {matches[0]}")
|
|
53
|
+
return matches[0].resolve()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build(cfg: Config) -> Path:
|
|
57
|
+
"""Run the configured build command and return the artifact it produced."""
|
|
58
|
+
if cfg.build.apk and not cfg.build.command:
|
|
59
|
+
return resolve_apk(cfg)
|
|
60
|
+
if not cfg.build.command:
|
|
61
|
+
raise BuildError(
|
|
62
|
+
"no build.command configured. Add it to your config, or call "
|
|
63
|
+
"`install_app` with an explicit apk path."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
log("build", f"running: {cfg.build.command} (cwd={cfg.project_root})")
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
cfg.build.command,
|
|
69
|
+
shell=True,
|
|
70
|
+
cwd=str(cfg.project_root),
|
|
71
|
+
text=True,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
check=False,
|
|
74
|
+
timeout=cfg.build.timeout_s,
|
|
75
|
+
)
|
|
76
|
+
if result.returncode != 0:
|
|
77
|
+
# Build output is the whole diagnostic value here, so pass it through
|
|
78
|
+
# rather than making the agent go hunting for a log file.
|
|
79
|
+
tail = "\n".join((result.stdout + result.stderr).splitlines()[-60:])
|
|
80
|
+
raise BuildError(f"build failed (exit={result.returncode}):\n{tail}")
|
|
81
|
+
return find_apk(cfg)
|