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,3 @@
1
+ """android_driver — an MCP server that makes an Android emulator a deterministic test harness."""
2
+
3
+ __version__ = "0.1.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
@@ -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)