agentdodont 0.1.0__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.
- agentdodont/__init__.py +3 -0
- agentdodont/__main__.py +297 -0
- agentdodont/adb.py +189 -0
- agentdodont/agent.py +434 -0
- agentdodont/audit.py +69 -0
- agentdodont/mcp_proxy.py +238 -0
- agentdodont/pairing.py +200 -0
- agentdodont/policy.py +180 -0
- agentdodont/relay.py +309 -0
- agentdodont/rules.py +236 -0
- agentdodont/shared.py +198 -0
- agentdodont/suite.py +275 -0
- agentdodont/ui.py +228 -0
- agentdodont-0.1.0.dist-info/METADATA +104 -0
- agentdodont-0.1.0.dist-info/RECORD +19 -0
- agentdodont-0.1.0.dist-info/WHEEL +4 -0
- agentdodont-0.1.0.dist-info/entry_points.txt +2 -0
- agentdodont-0.1.0.dist-info/licenses/LICENSE +202 -0
- agentdodont-0.1.0.dist-info/licenses/NOTICE +4 -0
agentdodont/__init__.py
ADDED
agentdodont/__main__.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""CLI: python -m agentdodont run "turn on dark mode"
|
|
2
|
+
python -m agentdodont doctor
|
|
3
|
+
python -m agentdodont log logs/<session>
|
|
4
|
+
python -m agentdodont relay # API key stays here; the phone app talks to this
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from . import ui
|
|
15
|
+
from .adb import AdbError, Device
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_doctor(args) -> int:
|
|
19
|
+
dev = Device(args.serial)
|
|
20
|
+
ok = True
|
|
21
|
+
|
|
22
|
+
def check(label, fn):
|
|
23
|
+
nonlocal ok
|
|
24
|
+
try:
|
|
25
|
+
val = fn()
|
|
26
|
+
print(f" ok {label}: {val}")
|
|
27
|
+
return val
|
|
28
|
+
except Exception as e: # noqa: BLE001 - diagnostic output
|
|
29
|
+
ok = False
|
|
30
|
+
print(f" FAIL {label}: {e}")
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
print(f"adb: {dev.adb}")
|
|
34
|
+
devices = check("devices", dev.list_devices)
|
|
35
|
+
if devices is not None:
|
|
36
|
+
ready = [s for s, st in devices if st == "device"]
|
|
37
|
+
for s, st in devices:
|
|
38
|
+
if st == "unauthorized":
|
|
39
|
+
print(f" !! {s} is unauthorized: unlock the phone and accept the USB debugging prompt")
|
|
40
|
+
if not ready:
|
|
41
|
+
print(" FAIL no authorized device. Enable Developer options + USB debugging and plug in via USB.")
|
|
42
|
+
return 1
|
|
43
|
+
if len(ready) > 1 and not args.serial:
|
|
44
|
+
print(f" FAIL several devices connected ({', '.join(ready)}); pass --serial")
|
|
45
|
+
return 1
|
|
46
|
+
check("model", lambda: f"{dev.getprop('ro.product.manufacturer')} {dev.getprop('ro.product.model')}")
|
|
47
|
+
check("android", lambda: f"{dev.getprop('ro.build.version.release')} (SDK {dev.getprop('ro.build.version.sdk')}), One UI {dev.getprop('ro.build.version.oneui') or '?'}")
|
|
48
|
+
check("screen size", dev.screen_size)
|
|
49
|
+
check("packages", lambda: f"{len(dev.packages())} installed")
|
|
50
|
+
t0 = time.time()
|
|
51
|
+
xml = check("uiautomator dump", lambda: dev.dump_ui())
|
|
52
|
+
if xml:
|
|
53
|
+
s = ui.parse(xml, dev.screen_size())
|
|
54
|
+
print(f" {len(s.elements)} elements in {time.time() - t0:.1f}s, foreground {s.package}")
|
|
55
|
+
if args.verbose:
|
|
56
|
+
print(s.render())
|
|
57
|
+
check("screencap", lambda: f"{len(dev.screencap()) // 1024} KB PNG")
|
|
58
|
+
has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"))
|
|
59
|
+
print(f" {'ok ' if has_key else 'warn'} Anthropic credentials in env: {has_key}")
|
|
60
|
+
try:
|
|
61
|
+
import PIL # noqa: F401
|
|
62
|
+
print(" ok Pillow installed (screenshots get downscaled)")
|
|
63
|
+
except ImportError:
|
|
64
|
+
print(" warn Pillow not installed: screenshots are sent full size (pip install pillow)")
|
|
65
|
+
print("all good" if ok else "some checks failed")
|
|
66
|
+
return 0 if ok else 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_screen(args) -> int:
|
|
70
|
+
dev = Device(args.serial)
|
|
71
|
+
print(ui.parse(dev.dump_ui(), dev.screen_size()).render())
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_run(args) -> int:
|
|
76
|
+
from .agent import DEFAULT_MODEL, Agent
|
|
77
|
+
from .audit import AuditLog
|
|
78
|
+
from .policy import Policy
|
|
79
|
+
|
|
80
|
+
dev = Device(args.serial)
|
|
81
|
+
if not dev.serial: # record which phone the session ran on
|
|
82
|
+
ready = [s for s, st in dev.list_devices() if st == "device"]
|
|
83
|
+
if len(ready) == 1:
|
|
84
|
+
dev.serial = ready[0]
|
|
85
|
+
policy = Policy.load(args.policy, ask_all=args.ask_all)
|
|
86
|
+
log = AuditLog(args.log_dir, save_screens=args.save_screens)
|
|
87
|
+
agent = Agent(dev, policy, log, model=args.model or DEFAULT_MODEL, effort=args.effort,
|
|
88
|
+
max_steps=args.max_steps, dry_run=args.dry_run, fallbacks=not args.no_fallbacks)
|
|
89
|
+
print(f"task: {args.task}\nlog: {log.dir}\n")
|
|
90
|
+
try:
|
|
91
|
+
result = agent.run(args.task)
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
log.event("stop", step=agent.step, reason="interrupted by user")
|
|
94
|
+
print("\ninterrupted")
|
|
95
|
+
return 130
|
|
96
|
+
except AdbError as e:
|
|
97
|
+
log.event("stop", step=agent.step, reason=f"adb error: {e}")
|
|
98
|
+
print(f"adb error: {e}")
|
|
99
|
+
return 2
|
|
100
|
+
finally:
|
|
101
|
+
log.close()
|
|
102
|
+
u = agent.usage
|
|
103
|
+
print(f"\nsteps: {result.steps} tokens in {u['input']} (cache read {u['cache_read']}, write {u['cache_write']}) / out {u['output']}")
|
|
104
|
+
print(f"log: {result.log_dir} (python -m agentdodont log {result.log_dir})")
|
|
105
|
+
return 0 if result.success else 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_suite(args) -> int:
|
|
109
|
+
from .agent import DEFAULT_MODEL
|
|
110
|
+
from .suite import TASKS, format_report, run_suite
|
|
111
|
+
|
|
112
|
+
if args.list:
|
|
113
|
+
for t in TASKS:
|
|
114
|
+
print(f"{t.name:<24} {t.prompt if isinstance(t.prompt, str) else '(generated prompt)'}")
|
|
115
|
+
return 0
|
|
116
|
+
dev = Device(args.serial)
|
|
117
|
+
if not dev.serial:
|
|
118
|
+
ready = [s for s, st in dev.list_devices() if st == "device"]
|
|
119
|
+
if len(ready) == 1:
|
|
120
|
+
dev.serial = ready[0]
|
|
121
|
+
names = [n.strip() for n in args.tasks.split(",")] if args.tasks else None
|
|
122
|
+
report = run_suite(dev, args.model or DEFAULT_MODEL, args.effort, names, args.repeat)
|
|
123
|
+
print(format_report(report))
|
|
124
|
+
return 0 if all(r["passed"] is not False for r in report["rows"]) else 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_log(args) -> int:
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
|
|
130
|
+
from .audit import show
|
|
131
|
+
|
|
132
|
+
root = Path(args.log_dir)
|
|
133
|
+
sessions = sorted(p for p in root.glob("*") if (p / "actions.jsonl").exists()) if root.exists() else []
|
|
134
|
+
if args.list:
|
|
135
|
+
for p in sessions:
|
|
136
|
+
print(p)
|
|
137
|
+
return 0
|
|
138
|
+
if args.session in (None, "latest"):
|
|
139
|
+
if not sessions:
|
|
140
|
+
print(f"no sessions found in {root}")
|
|
141
|
+
return 1
|
|
142
|
+
target = sessions[-1]
|
|
143
|
+
else:
|
|
144
|
+
target = Path(args.session)
|
|
145
|
+
if not target.exists() and (root / args.session).exists():
|
|
146
|
+
target = root / args.session
|
|
147
|
+
if not target.exists():
|
|
148
|
+
print(f"no such session: {args.session}")
|
|
149
|
+
if sessions:
|
|
150
|
+
print("available sessions:\n " + "\n ".join(str(p) for p in sessions[-10:]))
|
|
151
|
+
return 1
|
|
152
|
+
print(f"session: {target}\n")
|
|
153
|
+
print(show(target))
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_relay(args) -> int:
|
|
158
|
+
import threading
|
|
159
|
+
from pathlib import Path
|
|
160
|
+
|
|
161
|
+
from .pairing import DeviceRegistry, PairingSession, ensure_cert, lan_ip, pair_link, qr_ascii
|
|
162
|
+
from .relay import Relay, ensure_token, keep_phone_linked, make_server, make_tls_server, read_properties
|
|
163
|
+
|
|
164
|
+
state = Path(args.state_dir)
|
|
165
|
+
devices = DeviceRegistry(state / "devices.json")
|
|
166
|
+
if args.devices:
|
|
167
|
+
for d in devices.devices or [{"id": "-", "name": "no paired devices", "paired": "", "last_seen": ""}]:
|
|
168
|
+
print(f"{d['id']:<4} {d['name']:<24} paired {d['paired']} last seen {d['last_seen'] or 'never'}")
|
|
169
|
+
return 0
|
|
170
|
+
if args.revoke:
|
|
171
|
+
ok = devices.revoke(args.revoke)
|
|
172
|
+
print(f"revoked {args.revoke}" if ok else f"no device {args.revoke}")
|
|
173
|
+
return 0 if ok else 1
|
|
174
|
+
|
|
175
|
+
props_path = Path(args.properties)
|
|
176
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY") or read_properties(props_path).get("anthropic.apiKey")
|
|
177
|
+
if not api_key:
|
|
178
|
+
print(f"no API key: set ANTHROPIC_API_KEY or put anthropic.apiKey=... in {props_path}")
|
|
179
|
+
return 1
|
|
180
|
+
token, created = ensure_token(props_path, rotate=args.new_token)
|
|
181
|
+
relay = Relay(api_key, token, daily_budget_usd=args.budget, state_dir=Path(args.log_dir), devices=devices)
|
|
182
|
+
server = make_server(relay, port=args.port)
|
|
183
|
+
tls = None
|
|
184
|
+
if args.lan or args.pair:
|
|
185
|
+
cert, key, fp = ensure_cert(state)
|
|
186
|
+
host = args.pair_host or f"{lan_ip()}:{args.lan_port}"
|
|
187
|
+
tls = make_tls_server(relay, "0.0.0.0", args.lan_port, cert, key)
|
|
188
|
+
threading.Thread(target=tls.serve_forever, daemon=True).start()
|
|
189
|
+
print(f"Wi-Fi relay (TLS) on {host} certificate {fp[:16]}… paired devices: {len(devices.devices)}")
|
|
190
|
+
if args.pair:
|
|
191
|
+
relay.pairing = PairingSession(fp)
|
|
192
|
+
link = pair_link(host, relay.pairing.code, fp)
|
|
193
|
+
print("\nScan with the phone's camera, or type in Agent Do/Don't: pair " + host + " " + relay.pairing.code)
|
|
194
|
+
print(qr_ascii(link))
|
|
195
|
+
print(f"code {relay.pairing.code} (works once, for 5 minutes) fingerprint {fp[:8]}-{fp[8:16]}\n")
|
|
196
|
+
print(f"relay on 127.0.0.1:{args.port} budget ${args.budget:.2f}/day (spent today ${relay.spent_today():.2f})")
|
|
197
|
+
print(f"log: {relay.log_path} (metadata only)")
|
|
198
|
+
if created:
|
|
199
|
+
print(f" new relay token generated (saved in {props_path}); the old one no longer works")
|
|
200
|
+
stop = threading.Event()
|
|
201
|
+
threading.Thread(target=keep_phone_linked, args=(Device(args.serial).adb, args.serial, args.port, token, stop),
|
|
202
|
+
daemon=True).start()
|
|
203
|
+
try:
|
|
204
|
+
server.serve_forever()
|
|
205
|
+
except KeyboardInterrupt:
|
|
206
|
+
print("\nrelay stopped")
|
|
207
|
+
finally:
|
|
208
|
+
stop.set()
|
|
209
|
+
server.server_close()
|
|
210
|
+
if tls:
|
|
211
|
+
tls.server_close()
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_mcp_proxy(args) -> int:
|
|
216
|
+
from .mcp_proxy import main as proxy_main # imported here: the other commands don't need it
|
|
217
|
+
return proxy_main(args)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main(argv: list[str] | None = None) -> int:
|
|
221
|
+
from . import __version__
|
|
222
|
+
p = argparse.ArgumentParser(prog="agentdodont", description="Agent Do/Don't: a rules engine between an AI agent "
|
|
223
|
+
"and its tools. mcp-proxy guards any MCP server; the other commands drive an Android phone.")
|
|
224
|
+
p.add_argument("--version", action="version", version=f"agentdodont {__version__}")
|
|
225
|
+
p.add_argument("--serial", help="adb device serial (when several are connected)")
|
|
226
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
227
|
+
|
|
228
|
+
r = sub.add_parser("run", help="run a task on the phone")
|
|
229
|
+
r.add_argument("task")
|
|
230
|
+
r.add_argument("--model", help="Claude model id (default claude-opus-5-5)")
|
|
231
|
+
r.add_argument("--effort", default="medium", choices=["low", "medium", "high", "xhigh", "max"])
|
|
232
|
+
r.add_argument("--max-steps", type=int, default=40)
|
|
233
|
+
r.add_argument("--policy", help="JSON file overriding policy defaults (levels, word lists, packages)")
|
|
234
|
+
r.add_argument("--ask-all", action="store_true", help="ask before every action")
|
|
235
|
+
r.add_argument("--dry-run", action="store_true", help="observe and decide, but never touch the phone")
|
|
236
|
+
r.add_argument("--save-screens", action="store_true", help="save every UI dump into the log dir")
|
|
237
|
+
r.add_argument("--log-dir", default="logs")
|
|
238
|
+
r.add_argument("--no-fallbacks", action="store_true", help="disable server-side refusal fallback")
|
|
239
|
+
r.set_defaults(fn=cmd_run)
|
|
240
|
+
|
|
241
|
+
d = sub.add_parser("doctor", help="check adb, device, and credentials")
|
|
242
|
+
d.add_argument("-v", "--verbose", action="store_true", help="also print the current screen")
|
|
243
|
+
d.set_defaults(fn=cmd_doctor)
|
|
244
|
+
|
|
245
|
+
st = sub.add_parser("suite", help="run the repeatable task suite with ground-truth checks")
|
|
246
|
+
st.add_argument("--model", help="Claude model id (default claude-opus-5-5)")
|
|
247
|
+
st.add_argument("--effort", default="medium", choices=["low", "medium", "high", "xhigh", "max"])
|
|
248
|
+
st.add_argument("--tasks", help="comma-separated task names (default: all)")
|
|
249
|
+
st.add_argument("--repeat", type=int, default=1)
|
|
250
|
+
st.add_argument("--list", action="store_true", help="list tasks and exit")
|
|
251
|
+
st.set_defaults(fn=cmd_suite)
|
|
252
|
+
|
|
253
|
+
rl = sub.add_parser("relay", help="hold the API key for the phone app (tunnelled over USB)")
|
|
254
|
+
rl.add_argument("--port", type=int, default=8787)
|
|
255
|
+
rl.add_argument("--budget", type=float, default=2.0, help="daily spend cap in USD (default 2)")
|
|
256
|
+
rl.add_argument("--properties", default="android/local.properties", help="where the key and token live")
|
|
257
|
+
rl.add_argument("--log-dir", default="logs")
|
|
258
|
+
rl.add_argument("--new-token", action="store_true", help="replace the phone token (no rebuild needed)")
|
|
259
|
+
rl.add_argument("--lan", action="store_true", help="also serve paired phones over Wi-Fi (TLS)")
|
|
260
|
+
rl.add_argument("--lan-port", type=int, default=8788)
|
|
261
|
+
rl.add_argument("--pair", action="store_true", help="show a QR code to pair a phone (implies --lan)")
|
|
262
|
+
rl.add_argument("--pair-host", help="address to put in the QR code (default: this laptop's LAN IP)")
|
|
263
|
+
rl.add_argument("--devices", action="store_true", help="list paired phones and exit")
|
|
264
|
+
rl.add_argument("--revoke", metavar="ID", help="unpair a phone (see --devices) and exit")
|
|
265
|
+
rl.add_argument("--state-dir", default=".relay", help="where the certificate and paired devices live")
|
|
266
|
+
rl.set_defaults(fn=cmd_relay)
|
|
267
|
+
|
|
268
|
+
s = sub.add_parser("screen",help="print the current screen as the model sees it")
|
|
269
|
+
s.set_defaults(fn=cmd_screen)
|
|
270
|
+
|
|
271
|
+
lg = sub.add_parser("log", help="show a session log as a 'why did you do that?' trace")
|
|
272
|
+
lg.add_argument("session", nargs="?", help="session folder, its name, or 'latest' (default: latest)")
|
|
273
|
+
lg.add_argument("--list", action="store_true", help="list all sessions")
|
|
274
|
+
lg.add_argument("--log-dir", default="logs")
|
|
275
|
+
lg.set_defaults(fn=cmd_log)
|
|
276
|
+
|
|
277
|
+
mp = sub.add_parser("mcp-proxy", help="put the rules engine between an MCP client and an MCP server")
|
|
278
|
+
mp.add_argument("--rules", help="JSON rules file (levels, allowed_sites, known_recipients, tools...)")
|
|
279
|
+
mp.add_argument("--name", help="the server's name in questions and the log (default: the command)")
|
|
280
|
+
mp.add_argument("--log", default=str(__import__("pathlib").Path.home() / ".agentdodont" / "mcp-audit.jsonl"),
|
|
281
|
+
help="audit log (JSONL)")
|
|
282
|
+
mp.add_argument("--session", help="share taint with other proxies in this session: default = every proxy started "
|
|
283
|
+
"by the same client; a name = every proxy given that name; none = don't share")
|
|
284
|
+
mp.add_argument("server", nargs=argparse.REMAINDER, help="-- then the MCP server command and its arguments")
|
|
285
|
+
mp.set_defaults(fn=cmd_mcp_proxy)
|
|
286
|
+
|
|
287
|
+
args = p.parse_args(argv)
|
|
288
|
+
for stream in (sys.stdout, sys.stderr): # Windows consoles default to cp1252; UI text is Unicode
|
|
289
|
+
try:
|
|
290
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
291
|
+
except (AttributeError, ValueError):
|
|
292
|
+
pass
|
|
293
|
+
return args.fn(args)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
sys.exit(main())
|
agentdodont/adb.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Thin wrapper around the adb binary: observe the screen and inject input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
# Common app names -> candidate packages (Samsung first, then AOSP/Google).
|
|
11
|
+
APP_ALIASES: dict[str, list[str]] = {
|
|
12
|
+
"settings": ["com.android.settings"],
|
|
13
|
+
"chrome": ["com.android.chrome"],
|
|
14
|
+
"browser": ["com.sec.android.app.sbrowser", "com.android.chrome"],
|
|
15
|
+
"internet": ["com.sec.android.app.sbrowser"],
|
|
16
|
+
"messages": ["com.samsung.android.messaging", "com.google.android.apps.messaging"],
|
|
17
|
+
"sms": ["com.samsung.android.messaging", "com.google.android.apps.messaging"],
|
|
18
|
+
"phone": ["com.samsung.android.dialer", "com.google.android.dialer"],
|
|
19
|
+
"dialer": ["com.samsung.android.dialer", "com.google.android.dialer"],
|
|
20
|
+
"contacts": ["com.samsung.android.app.contacts", "com.google.android.contacts"],
|
|
21
|
+
"camera": ["com.sec.android.app.camera"],
|
|
22
|
+
"gallery": ["com.sec.android.gallery3d", "com.google.android.apps.photos"],
|
|
23
|
+
"photos": ["com.google.android.apps.photos", "com.sec.android.gallery3d"],
|
|
24
|
+
"clock": ["com.sec.android.app.clockpackage", "com.google.android.deskclock"],
|
|
25
|
+
"alarm": ["com.sec.android.app.clockpackage", "com.google.android.deskclock"],
|
|
26
|
+
"calendar": ["com.samsung.android.calendar", "com.google.android.calendar"],
|
|
27
|
+
"calculator": ["com.sec.android.app.popupcalculator", "com.google.android.calculator"],
|
|
28
|
+
"notes": ["com.samsung.android.app.notes", "com.google.android.keep"],
|
|
29
|
+
"my files": ["com.sec.android.app.myfiles"],
|
|
30
|
+
"files": ["com.sec.android.app.myfiles", "com.google.android.apps.nbu.files"],
|
|
31
|
+
"play store": ["com.android.vending"],
|
|
32
|
+
"gmail": ["com.google.android.gm"],
|
|
33
|
+
"email": ["com.samsung.android.email.provider", "com.google.android.gm"],
|
|
34
|
+
"maps": ["com.google.android.apps.maps"],
|
|
35
|
+
"youtube": ["com.google.android.youtube"],
|
|
36
|
+
"google": ["com.google.android.googlequicksearchbox"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Characters the device shell would interpret inside `input text`.
|
|
40
|
+
_SHELL_SPECIAL = set("\\'\"`$&|;<>()*~#!?[]{}^")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AdbError(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def escape_input_text(text: str) -> str:
|
|
48
|
+
"""Escape text for `adb shell input text`. Only printable ASCII is supported."""
|
|
49
|
+
out = []
|
|
50
|
+
for ch in text:
|
|
51
|
+
if ord(ch) > 126 or (ord(ch) < 32):
|
|
52
|
+
raise AdbError(
|
|
53
|
+
f"`input text` cannot type {ch!r}; only printable ASCII on one line is supported "
|
|
54
|
+
"(install ADBKeyboard for Unicode — a later improvement)."
|
|
55
|
+
)
|
|
56
|
+
if ch == " ":
|
|
57
|
+
out.append("%s")
|
|
58
|
+
elif ch in _SHELL_SPECIAL:
|
|
59
|
+
out.append("\\" + ch)
|
|
60
|
+
else:
|
|
61
|
+
out.append(ch)
|
|
62
|
+
return "".join(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_app(name: str, installed: list[str]) -> tuple[str | None, list[str]]:
|
|
66
|
+
"""Map a human app name to an installed package. Returns (package, candidates)."""
|
|
67
|
+
key = name.strip().lower()
|
|
68
|
+
if key in installed: # already a package name
|
|
69
|
+
return key, []
|
|
70
|
+
for pkg in APP_ALIASES.get(key, []):
|
|
71
|
+
if pkg in installed:
|
|
72
|
+
return pkg, []
|
|
73
|
+
tokens = [t for t in re.split(r"[^a-z0-9]+", key) if t]
|
|
74
|
+
if not tokens:
|
|
75
|
+
return None, []
|
|
76
|
+
hits = [p for p in installed if all(t in p.lower() for t in tokens)]
|
|
77
|
+
if len(hits) == 1:
|
|
78
|
+
return hits[0], []
|
|
79
|
+
return None, sorted(hits)[:15]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Device:
|
|
83
|
+
def __init__(self, serial: str | None = None, adb_path: str | None = None):
|
|
84
|
+
self.adb = adb_path or shutil.which("adb") or "adb"
|
|
85
|
+
self.serial = serial
|
|
86
|
+
self._size: tuple[int, int] | None = None
|
|
87
|
+
self._packages: list[str] | None = None
|
|
88
|
+
|
|
89
|
+
# --- plumbing -----------------------------------------------------------
|
|
90
|
+
def _cmd(self, *args: str) -> list[str]:
|
|
91
|
+
base = [self.adb]
|
|
92
|
+
if self.serial:
|
|
93
|
+
base += ["-s", self.serial]
|
|
94
|
+
return base + list(args)
|
|
95
|
+
|
|
96
|
+
def run(self, *args: str, timeout: float = 30, binary: bool = False):
|
|
97
|
+
try:
|
|
98
|
+
proc = subprocess.run(self._cmd(*args), capture_output=True, timeout=timeout)
|
|
99
|
+
except FileNotFoundError as e:
|
|
100
|
+
raise AdbError(f"adb not found at {self.adb!r}") from e
|
|
101
|
+
except subprocess.TimeoutExpired as e:
|
|
102
|
+
raise AdbError(f"adb {' '.join(args)} timed out") from e
|
|
103
|
+
if proc.returncode != 0:
|
|
104
|
+
err = proc.stderr.decode(errors="replace").strip() or proc.stdout.decode(errors="replace").strip()
|
|
105
|
+
raise AdbError(f"adb {' '.join(args)} failed: {err}")
|
|
106
|
+
return proc.stdout if binary else proc.stdout.decode(errors="replace")
|
|
107
|
+
|
|
108
|
+
def shell(self, command: str, timeout: float = 30) -> str:
|
|
109
|
+
return self.run("shell", command, timeout=timeout)
|
|
110
|
+
|
|
111
|
+
# --- info ---------------------------------------------------------------
|
|
112
|
+
def list_devices(self) -> list[tuple[str, str]]:
|
|
113
|
+
out = self.run("devices")
|
|
114
|
+
rows = []
|
|
115
|
+
for line in out.splitlines()[1:]:
|
|
116
|
+
parts = line.split()
|
|
117
|
+
if len(parts) >= 2:
|
|
118
|
+
rows.append((parts[0], parts[1]))
|
|
119
|
+
return rows
|
|
120
|
+
|
|
121
|
+
def getprop(self, name: str) -> str:
|
|
122
|
+
return self.shell(f"getprop {name}").strip()
|
|
123
|
+
|
|
124
|
+
def screen_size(self) -> tuple[int, int]:
|
|
125
|
+
if self._size is None:
|
|
126
|
+
out = self.shell("wm size")
|
|
127
|
+
# "Physical size: 1080x2340" possibly followed by "Override size: ..."
|
|
128
|
+
matches = re.findall(r"(\d+)x(\d+)", out)
|
|
129
|
+
if not matches:
|
|
130
|
+
raise AdbError(f"cannot parse screen size from {out!r}")
|
|
131
|
+
w, h = matches[-1]
|
|
132
|
+
self._size = (int(w), int(h))
|
|
133
|
+
return self._size
|
|
134
|
+
|
|
135
|
+
def packages(self, refresh: bool = False) -> list[str]:
|
|
136
|
+
if self._packages is None or refresh:
|
|
137
|
+
out = self.shell("pm list packages")
|
|
138
|
+
self._packages = [l.split(":", 1)[1].strip() for l in out.splitlines() if l.startswith("package:")]
|
|
139
|
+
return self._packages
|
|
140
|
+
|
|
141
|
+
# --- observe ------------------------------------------------------------
|
|
142
|
+
def dump_ui(self, retries: int = 3) -> str:
|
|
143
|
+
last = ""
|
|
144
|
+
for attempt in range(retries):
|
|
145
|
+
out = self.shell("uiautomator dump /sdcard/aos_dump.xml", timeout=30)
|
|
146
|
+
if "dumped to" in out:
|
|
147
|
+
xml = self.run("exec-out", "cat /sdcard/aos_dump.xml")
|
|
148
|
+
if "<hierarchy" in xml:
|
|
149
|
+
return xml
|
|
150
|
+
last = out.strip()
|
|
151
|
+
time.sleep(0.7 * (attempt + 1)) # usually "could not get idle state" during animations
|
|
152
|
+
raise AdbError(f"uiautomator dump failed: {last}")
|
|
153
|
+
|
|
154
|
+
def screencap(self) -> bytes:
|
|
155
|
+
data = self.run("exec-out", "screencap -p", binary=True, timeout=30)
|
|
156
|
+
if not data.startswith(b"\x89PNG"):
|
|
157
|
+
raise AdbError("screencap did not return a PNG")
|
|
158
|
+
return data
|
|
159
|
+
|
|
160
|
+
# --- act ----------------------------------------------------------------
|
|
161
|
+
def tap(self, x: int, y: int) -> None:
|
|
162
|
+
self.shell(f"input tap {int(x)} {int(y)}")
|
|
163
|
+
|
|
164
|
+
def long_press(self, x: int, y: int, ms: int = 800) -> None:
|
|
165
|
+
self.shell(f"input swipe {int(x)} {int(y)} {int(x)} {int(y)} {ms}")
|
|
166
|
+
|
|
167
|
+
def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 350) -> None:
|
|
168
|
+
self.shell(f"input swipe {int(x1)} {int(y1)} {int(x2)} {int(y2)} {ms}")
|
|
169
|
+
|
|
170
|
+
def keyevent(self, code: int | str) -> None:
|
|
171
|
+
self.shell(f"input keyevent {code}")
|
|
172
|
+
|
|
173
|
+
def type_text(self, text: str) -> None:
|
|
174
|
+
if text:
|
|
175
|
+
self.shell(f"input text {escape_input_text(text)}")
|
|
176
|
+
|
|
177
|
+
def clear_field(self) -> None:
|
|
178
|
+
# Ctrl+A then Delete (Android 13+ supports keycombination); fall back to many DELs.
|
|
179
|
+
try:
|
|
180
|
+
self.shell("input keycombination 113 29")
|
|
181
|
+
self.keyevent(67)
|
|
182
|
+
except AdbError:
|
|
183
|
+
self.keyevent("KEYCODE_MOVE_END")
|
|
184
|
+
self.shell("input keyevent " + " ".join(["67"] * 60))
|
|
185
|
+
|
|
186
|
+
def launch(self, package: str) -> None:
|
|
187
|
+
out = self.shell(f"monkey -p {package} -c android.intent.category.LAUNCHER 1")
|
|
188
|
+
if "No activities found" in out or "aborted" in out.lower():
|
|
189
|
+
raise AdbError(f"could not launch {package}: {out.strip()}")
|