feat(agents): add the run-meshtastic-android skill with desktop and emulator drivers (#6955)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 authored and GitHub committed 2026-08-30 01:21:41 +00:00
1 parent 80849ca503
commit 38d4fde160
3 files changed
+692

No files matched your search

@@ -0,0 +1,208 @@
---
name: run-meshtastic-android
description: Run, launch, drive, and screenshot the Meshtastic app — the Compose Desktop app via hot reload (semantic clicks, live reload, window screenshots) or the Android app on an emulator (scripted deeplink bring-up, uiautomator taps, screencap). Use when asked to run the app, verify a UI change in the real app, take a screenshot, or exercise a flow end to end against a simulated radio.
---
# Run Meshtastic (Desktop & Emulator)
Two binaries, two drivers, one simulated radio. All paths are relative to the repo
root. Both drivers are Python 3, stdlib only, and print `--- <cmd> done ---` per
step on stderr.
- **Desktop** (`:desktopApp`, Compose/JVM, runs on this machine): launch with the
hot-reload run task, drive through `.claude/skills/run-meshtastic-android/driver.py`,
which speaks MCP JSON-RPC to `:desktopApp:hotMcpServer` — semantic tree, clicks
by node id, `reload` (recompile + hot-swap), window screenshots.
- **Emulator** (`:androidApp` fdroid debug): drive through
`.claude/skills/run-meshtastic-android/driver_emulator.py` — scripted deeplink
bring-up, uiautomator-based taps, screencap.
- **Radio**: neither app does much without one. `mcp__meshtastic__replay_start`
(meshtastic MCP) serves a simulated Meshtastic TCP radio; the desktop app reaches
it at `127.0.0.1:<port>`, an AVD at `10.0.2.2:<port>`. One client per session —
run the desktop and emulator against **different ports** (e.g. 4403 and 4404).
## Prerequisites
- Gradle runs go through the machine-wide queue: `~/.claude/bin/gradle-queue`.
Everything after its `--` is **Gradle arguments** — it runs `./gradlew` itself
(`gradle-queue -- ./gradlew tasks` fails with `Task './gradlew' not found`).
- The JetBrains 25 JDK Gradle provisioned at
`~/.gradle/jdks/jetbrains_s_r_o_-25-*/…/Contents/Home` (the drivers find it themselves).
- Emulator leg: a running AVD (`adb devices`) with the fdroid debug build installed
(`./gradlew :androidApp:installFdroidDebug` via the queue if missing).
- A simulated radio, e.g. `replay_start(source="meshcon", sim_nodes=30, port=4403,
rate=2, loop=true, sim_profile={"traceroute_pairs_per_hour": 0})` — mute the
traceroutes or their modals bury whatever you are testing.
## Run: Desktop (agent path)
Kill stray instances first — two apps fight over the pid file and the MCP server
reports `connected:false` forever:
```bash
pgrep -fl "MainKt|devtools.Main" # kill any hits before launching
```
Launch (the Nix dev shell's Darwin stdenv breaks the MapLibre FFI — strip it):
```bash
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
~/.claude/bin/gradle-queue -- :desktopApp:hotRunAsync
```
`BUILD SUCCESSFUL` + `desktopApp/build/run/main/main.pid` on disk means the app is up.
Drive it. Each driver invocation spawns a fresh `hotMcpServer`, auto-waits for it to
attach (asynchronous — the driver polls `status` for you), runs the commands in
order, and exits:
```bash
python3 .claude/skills/run-meshtastic-android/driver.py tree # semantic tree (JSON, node ids)
python3 .claude/skills/run-meshtastic-android/driver.py click=170 sleep=1.5 tree
python3 .claude/skills/run-meshtastic-android/driver.py raise ss=/tmp/app.png
python3 .claude/skills/run-meshtastic-android/driver.py reload # recompile + hot-swap edits
```
Run `driver.py` with no arguments for the full command list (`type=NODEID:TEXT`,
`scroll_to=NODEID:IDX`, `restart`, `err`, `logs`, …). `tools` prints the server's
live tool schemas if they've drifted.
**Verified flow** (connect to a sim and see its mesh): nav-rail tabs are semantic
`Tab` nodes — `Connect` opened via `click=<its id from tree>`, then the `Network`
radio button, then the device row for `127.0.0.1` under Recent Network Devices.
The sim's `replay_status` flips to `connected:true` within seconds and the Nodes
tab fills with the sim's mesh (`RPLY Replay Observer`, …).
The connection card can sit on "Reconnecting…" while packets already flow — the
label lags the config download. Trust `replay_status` and the Nodes list, not the
card text.
**Screenshots capture the window's on-screen region**, so the window must be
frontmost: always `raise` before `ss`. If `ss` shows your terminal, that's why.
`hotMcpServer` and `reload` compile **outside** gradle-queue (a long-lived stdio
server can't hold a slot) — check `~/.claude/bin/gradle-queue --status` before a
`reload` if other sessions may be building, and keep those runs short.
### Desktop deeplink launch (no clicking — but no hot reload)
The desktop app parses the same Meshtastic deeplink URIs from its **program args**
(`Main.kt` accepts `meshtastic://` and `https://meshtastic.org/...`), so a connected
app is one command:
```bash
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
~/.claude/bin/gradle-queue -- :desktopApp:run --args="https://meshtastic.org/connections?address=t127.0.0.1:4403"
```
Verified against a sim the app had never connected to before, so it is the deeplink
acting, not last-device auto-reconnect. Caveats, all observed:
- `hotRunAsync` does **not** accept `--args` (its option list: --auto, --className,
--funName, --mainClass, --stdout/--stderr only) — deeplink launch means the plain
`run` task, which trades away hot reload. Long driving session → `hotRunAsync` +
the driver's click path; quick "get me a connected app" → `run --args=…`.
- `run` blocks, so it **holds a gradle-queue slot for the app's whole lifetime**.
Keep such runs short, or other sessions' builds will queue behind your app.
- The deeplink races last-device auto-reconnect: the app can connect to its
remembered device first, then switch to the deeplink's target a moment later —
if the remembered device is another sim, that sim briefly shows a client too.
- No trust dialog blocked the localhost connect in testing (unlike the Android
build, which pops one for a never-seen device).
## Run: Emulator (agent path)
Scripted bring-up only — never hand-walk onboarding or the manual-IP dialog:
```bash
python3 .claude/skills/run-meshtastic-android/driver_emulator.py -s emulator-5554 \
connect=t10.0.2.2:4404 wait_text=RPLY ss=/tmp/emu.png
```
`connect` force-stops the app, relaunches `org.meshtastic.app.MainActivity` with the
debug-only `skip_onboarding` extra and the `/connections?address=` deeplink
(`t` = TCP, `x` = BLE, `s` = serial, `n` = disconnect — full path list in
`docs/en/developer/navigation-and-deep-links.md`), then waits for the trust dialog
newer builds pop and taps its **Connect** button. Success looks like the Connection
screen showing `RPLY Replay Observer` with a **Disconnect** button, and
`replay_status` reporting `connected:true`.
Other commands: `dump`, `find=TEXT`, `tap_text=TEXT`, `tap=X,Y`, `text=`, `key=`,
`swipe=`, `launch`, `stop` — run with no arguments for the list. Default package is
`com.geeksville.mesh.fdroid.debug` (`-p` to override).
## Run (human path)
`./gradlew :desktopApp:run` (via the queue, same env hygiene) opens the window
without hot reload; Ctrl-C to stop. The emulator app is just the launcher icon —
but a debug build launched by icon lands on onboarding; the deeplink path above is
faster even for humans.
## Stopping
- Desktop: take the pid from the app's own pid file — it is a Java properties
file (not a bare pid) and self-deletes on clean exit:
```bash
kill $(sed -n 's/^pid=//p' desktopApp/build/run/main/main.pid)
```
If the pid file is gone but a process lingers, `pgrep -af "MainKt|devtools.Main"`,
check each match's path for **this** checkout, and kill that specific PID — a bare
`pkill` on the pattern can take down another checkout's or session's app.
- Emulator: `driver_emulator.py -s <serial> stop`.
- Sim: `replay_stop`. Sessions the sim created are real user data in the app's DB;
the app's last-selected device is now the sim — switch back on the Connect screen
if a real radio should reconnect.
## Gotchas
- **`gradle-queue -- ./gradlew …` fails**: args after `--` go to `./gradlew`,
which the wrapper runs itself. And piping its output (`| tail`) eats the exit
code — check for `BUILD SUCCESSFUL` in the text, not `$?`.
- **`tap_text` matches substrings**: bare `Connect` also matches "Stop
**Connect**ing" and "Re**connect**ing…". The driver tries exact text first;
wait on the trust dialog's title ("Connect to this device"), not its button.
- **The MCP server attaches asynchronously** — a `tree` fired immediately after
spawn returns "No application is currently connected". The driver auto-waits;
if it times out, the app isn't running (or a stray instance holds the pid file).
- **`take_screenshot` needs the window visible** — `raise` first (System Events
`AXRaise` targeting the window literally named "Meshtastic Desktop"; with two
java processes, pid-based frontmosting picks the wrong one).
- **One client per simulated node.** Two apps pointed at the same sim don't
queue — they fight, stealing the connection back and forth so both flap
between Connected and Reconnecting. The desktop app holding port 4403 means
the emulator needs its own `replay_start` on 4404.
- **`adb shell input text` can leave a trailing space**; dialogs' Add buttons
silently no-op on it. And don't press BACK to dismiss the keyboard — it closes
the dialog.
- **Swipe near x≈30** in lists; mid-screen swipes get eaten by embedded maps.
Never busy-loop adb — pace with `adb shell sleep 2` or the emulator drops offline.
- The desktop app auto-reconnects to its last device on launch — it may already
be connected to a real radio when you attach; check the Connect screen before
assuming the sim.
## Troubleshooting
- `Task './gradlew' not found in root project` → you passed `./gradlew` after
`gradle-queue --`; drop it.
- `BUILD FAILED in 1s` from `hotRunAsync` with slots free → read the full output;
the queue wrapper's exit code vanishes behind pipes.
- Screenshot is your terminal → `raise` before `ss` (window wasn't frontmost).
- `connected:false` forever from `status` → stray `MainKt` from another checkout
or worktree; `pgrep -fl MainKt`, kill, relaunch.
- Trust dialog never tapped, app stuck on dialog → older driver matched
"Reconnecting…"; re-run `tap_text=Connect` (exact match wins now).
- UI card stuck "Reconnecting…" but sim says `connected:true` → not stuck; config
download in progress. Check the Nodes tab for the sim's nodes.
- Connection flapping → `desktopApp/build/run/main/hotRun.stderr.txt` carries the
transport-level story ("Handshake stall detected at Stage 1 … requesting forced
transport restart" is the app self-recovering, not a crash). Also check that a
second app isn't fighting for the same sim (one client per simulated node).
- A bare `status` right after spawn can report `connected:false` while the app is
fine — the server attach is asynchronous; `wait` (or any UI command, which
auto-waits) is the truth.
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""Drive the running Meshtastic desktop app through the compose-hot-reload MCP server.
Speaks MCP JSON-RPC over the stdio of `./gradlew :desktopApp:hotMcpServer` (the same
server android/.mcp.json registers), so it works with no MCP client attached at all.
The app itself must already be running — launch it with :desktopApp:hotRunAsync first
(see SKILL.md). Each invocation spawns the server, waits for it to attach to the app,
executes the given commands in order, and exits.
Usage:
driver.py [--repo DIR] CMD [CMD ...]
Commands (executed left to right):
tools list the server's tools and their input schemas
status print connection status
wait poll status until "connected":true (120 s timeout)
windows list app windows
tree print the semantic tree (all windows)
tree=SUBSTR print only tree lines whose text matches SUBSTR (case-insensitive)
click=NODEID click a node by id from the tree
longclick=NODEID long-click a node
type=NODEID:TEXT set the text content of an editable node
scroll_to=NODEID:IDX scroll item IDX of scrollable container NODEID into view
ss=PATH.png screenshot the app window to PATH (absolute path)
reload recompile + hot-swap current sources into the running app
restart relaunch the app process (needed for singleton/init state)
reset_ui reset the UI to its entry point
raise bring the app window frontmost (required before ss —
the screenshot captures the on-screen region)
err print the current UI error, if any
logs print recent app logs
sleep=SECONDS pause between commands (animations, connection settling)
Example — poke the Connections screen and screenshot it:
driver.py wait tree=Connections click=42 sleep=1 ss=/tmp/conn.png
"""
import base64
import json
import os
import queue
import re
import subprocess
import sys
import threading
import time
NIX_POISON = ["DEVELOPER_DIR", "SDKROOT", "CC", "CXX", "LD", "AR", "NM", "RANLIB", "STRIP", "NIX_CC"]
JDK_GLOB = os.path.expanduser("~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home")
def clean_env():
"""The Nix dev shell's Darwin stdenv breaks the MapLibre FFI and Skiko; strip it."""
env = {k: v for k, v in os.environ.items() if k not in NIX_POISON}
env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin"
import glob
jdks = sorted(glob.glob(JDK_GLOB))
if jdks:
env["JAVA_HOME"] = jdks[-1]
return env
class HotMcp:
def __init__(self, repo):
self.proc = subprocess.Popen(
["./gradlew", "--no-daemon", "--quiet", "--console=plain", ":desktopApp:hotMcpServer"],
cwd=repo,
env=clean_env(),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
stdin, stdout = self.proc.stdin, self.proc.stdout
assert stdin is not None and stdout is not None
self.stdin, self.stdout = stdin, stdout
# readline() would block past any deadline if the server keeps stdout open without
# writing; a pump thread + queue makes the RPC timeout real.
self._lines: "queue.Queue[str | None]" = queue.Queue()
def _pump(out, q):
for line in out:
q.put(line)
q.put(None)
threading.Thread(target=_pump, args=(self.stdout, self._lines), daemon=True).start()
self.next_id = 1
self._rpc("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "run-meshtastic-android-driver", "version": "1"},
})
self._notify("notifications/initialized")
def _send(self, obj):
self.stdin.write(json.dumps(obj) + "\n")
self.stdin.flush()
def _notify(self, method):
self._send({"jsonrpc": "2.0", "method": method})
def _rpc(self, method, params, timeout=180):
rid = self.next_id
self.next_id += 1
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
deadline = time.time() + timeout
while True:
remaining = deadline - time.time()
if remaining <= 0:
break
try:
line = self._lines.get(timeout=remaining)
except queue.Empty:
break
if line is None:
raise RuntimeError("hotMcpServer closed its stdout (is another instance running?)")
line = line.strip()
if not line.startswith("{"):
continue # gradle noise
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("id") == rid:
if "error" in msg:
raise RuntimeError(f"{method}: {msg['error']}")
return msg.get("result")
raise TimeoutError(f"{method}: no response in {timeout}s")
def call(self, tool, args=None):
return self._rpc("tools/call", {"name": tool, "arguments": args or {}})
def ensure_connected(self, timeout=90):
"""The server attaches to the app asynchronously after initialize; poll before UI calls."""
deadline = time.time() + timeout
while time.time() < deadline:
s = "".join(c.get("text", "") for c in (self.call("status") or {}).get("content", []))
if '"connected":true' in s.replace(" ", ""):
return s
time.sleep(2)
raise TimeoutError(f"app not connected after {timeout}s — is :desktopApp:hotRunAsync running? status: {s[:300]}")
def close(self):
try:
self.stdin.close()
except OSError:
pass
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
RAISE_SCRIPT = """
tell application "System Events"
repeat with p in (every process whose name is "java")
repeat with w in (every window of p)
if name of w is "Meshtastic Desktop" then
set frontmost of p to true
perform action "AXRaise" of w
return "raised"
end if
end repeat
end repeat
end tell
return "not found"
"""
def raise_app():
r = subprocess.run(["osascript", "-e", RAISE_SCRIPT], capture_output=True, text=True, timeout=30)
print((r.stdout or r.stderr).strip())
def text_of(result):
out = []
for c in (result or {}).get("content", []):
if c.get("type") == "text":
out.append(c["text"])
return "\n".join(out)
def save_image(result, path):
for c in (result or {}).get("content", []):
if c.get("type") == "image":
with open(path, "wb") as f:
f.write(base64.b64decode(c["data"]))
return True
# some tools return the base64 inline in text
t = text_of(result)
m = re.search(r"[A-Za-z0-9+/=]{200,}", t or "")
if m:
with open(path, "wb") as f:
f.write(base64.b64decode(m.group(0)))
return True
return False
def main():
argv = sys.argv[1:]
repo = os.getcwd()
if argv and argv[0] == "--repo":
repo = argv[1]
argv = argv[2:]
if not argv:
print(__doc__)
return 2
mcp = HotMcp(repo)
UI_CMDS = {"windows", "tree", "click", "longclick", "type", "scroll_to", "ss", "reload", "restart", "reset_ui", "err", "logs"} # "raise" is local, no app connection needed
try:
for cmd in argv:
name, _, val = cmd.partition("=")
if name in UI_CMDS:
mcp.ensure_connected()
if name == "tools":
r = mcp._rpc("tools/list", {})
for t in r.get("tools", []):
print(f"{t['name']}: {json.dumps(t.get('inputSchema', {}).get('properties', {}))}")
elif name == "status":
print(text_of(mcp.call("status")))
elif name == "wait":
mcp.ensure_connected(timeout=120)
print("connected")
elif name == "windows":
print(text_of(mcp.call("list_windows")))
elif name == "tree":
t = text_of(mcp.call("get_semantic_tree"))
if val:
pat = re.compile(re.escape(val), re.I)
print("\n".join(ln for ln in t.splitlines() if pat.search(ln)))
else:
print(t)
elif name == "click":
print(text_of(mcp.call("click", {"nodeId": int(val)})))
elif name == "longclick":
print(text_of(mcp.call("long_click", {"nodeId": int(val)})))
elif name == "type":
nid, _, text = val.partition(":")
print(text_of(mcp.call("type_text", {"nodeId": int(nid), "text": text})))
elif name == "scroll_to":
nid, _, idx = val.partition(":")
print(text_of(mcp.call("scroll_to_index", {"nodeId": int(nid), "index": int(idx or 0)})))
elif name == "ss":
r = mcp.call("take_screenshot", {"save_to": os.path.abspath(val)})
print(text_of(r) or f"saved {val}")
elif name in ("reload", "restart", "reset_ui"):
print(text_of(mcp.call(name)))
elif name == "raise":
raise_app()
time.sleep(1)
elif name == "err":
print(text_of(mcp.call("get_ui_error")))
elif name == "logs":
print(text_of(mcp.call("get_logs")))
elif name == "sleep":
time.sleep(float(val))
else:
print(f"unknown command: {cmd}", file=sys.stderr)
return 2
print(f"--- {cmd} done ---", file=sys.stderr)
finally:
mcp.close()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Drive the Meshtastic Android app on an emulator/device over adb.
Scripted bring-up (never hand-walk onboarding): launches MainActivity with the
debug-only skip_onboarding extra and a /connections deeplink that auto-connects
to a TCP radio — pair it with a replay-sim radio (an AVD reaches the host at
10.0.2.2). Handles the trust dialog newer builds pop on first connect.
Usage:
driver_emulator.py [-s SERIAL] [-p PACKAGE] CMD [CMD ...]
Commands (executed left to right):
connect[=ADDR] force-stop, then deeplink-launch and auto-connect.
ADDR defaults to t10.0.2.2:4403 (t=TCP, x=BLE, s=serial,
n=disconnect). Waits for and accepts the trust dialog.
launch plain launch (skip_onboarding, no deeplink)
stop force-stop the app
dump print the uiautomator XML of the current screen
find=TEXT print nodes whose text/desc contains TEXT (with bounds)
tap_text=TEXT tap the center of the first clickable node matching TEXT
tap=X,Y tap raw coordinates
text=STRING type text into the focused field
key=KEYCODE send a keycode (e.g. 4 = BACK — careful, closes dialogs)
swipe=X1,Y1,X2,Y2 swipe (use x≈30 in lists; mid-screen swipes get eaten by maps)
ss=PATH.png screenshot to a local file
wait_text=TEXT poll up to 60 s until TEXT appears on screen
sleep=SECONDS pause
Example — bring the app up against a replay sim on host port 4404:
driver_emulator.py connect=t10.0.2.2:4404 wait_text=RPLY ss=/tmp/emu.png
"""
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
SERIAL = None
PKG = "com.geeksville.mesh.fdroid.debug"
ACTIVITY = "org.meshtastic.app.MainActivity"
def adb(*args):
cmd = ["adb"] + (["-s", SERIAL] if SERIAL else []) + list(args)
r = subprocess.run(cmd, capture_output=True, timeout=120)
if r.returncode != 0:
err = (r.stderr or b"").decode(errors="replace").strip()
raise RuntimeError(f"adb {' '.join(args)} failed ({r.returncode}): {err[:300]}")
return (r.stdout or b"").decode(errors="replace")
def ui_dump():
adb("shell", "uiautomator", "dump", "/sdcard/ui.xml")
return adb("shell", "cat", "/sdcard/ui.xml")
def nodes(xml):
try:
root = ET.fromstring(xml)
except ET.ParseError:
return []
out = []
for n in root.iter("node"):
out.append(n.attrib)
return out
def center(bounds):
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
if not m:
return None
x1, y1, x2, y2 = map(int, m.groups())
return (x1 + x2) // 2, (y1 + y2) // 2
def find(text, clickable_only=False, exact=False):
for n in nodes(ui_dump()):
t, d = n.get("text", ""), n.get("content-desc", "")
if exact:
hit = text.lower() in (t.lower(), d.lower())
else:
hit = text.lower() in (t + " " + d).lower()
if hit and (not clickable_only or n.get("clickable") == "true"):
yield n
def tap_text(text):
# exact text match first — substring matching taps "Stop Connecting" when you want "Connect"
for n in find(text, exact=True):
c = center(n.get("bounds", ""))
if c:
adb("shell", "input", "tap", str(c[0]), str(c[1]))
return f"tapped exact {text!r} at {c}"
for n in find(text, clickable_only=True):
c = center(n.get("bounds", ""))
if c:
adb("shell", "input", "tap", str(c[0]), str(c[1]))
return f"tapped {text!r} at {c}"
# fall back to any match (some rows are labels inside a clickable parent)
for n in find(text):
c = center(n.get("bounds", ""))
if c:
adb("shell", "input", "tap", str(c[0]), str(c[1]))
return f"tapped (non-clickable match) {text!r} at {c}"
return f"NOT FOUND: {text!r}"
def wait_text(text, timeout=60):
deadline = time.time() + timeout
while time.time() < deadline:
if any(True for _ in find(text)):
return f"found {text!r}"
time.sleep(3)
return f"TIMEOUT waiting for {text!r}"
def connect(addr):
adb("shell", "am", "force-stop", PKG)
time.sleep(1)
adb(
"shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}",
"--ez", "skip_onboarding", "true",
"-a", "android.intent.action.VIEW",
"-d", f"https://meshtastic.org/connections?address={addr}",
)
# Builds >2.8.1 pop a trust dialog on first connect to a new device. Match its
# title, not bare "Connect" — that substring also matches "Stop Connecting".
r = wait_text("Connect to this device", timeout=30)
if r.startswith("found"):
print(tap_text("Connect"))
else:
print("no trust dialog seen — verifying the connection directly")
# A missing dialog does not prove success (the launch or deeplink may have failed):
# require the Connection screen's Disconnect button before claiming victory.
v = wait_text("Disconnect", timeout=60)
if not v.startswith("found"):
return f"FAILED: launched with {addr}, but no connected state appeared ({v})"
return f"connected via {addr}"
def main():
global SERIAL, PKG
argv = sys.argv[1:]
while argv and argv[0] in ("-s", "-p"):
if len(argv) < 2:
print(__doc__)
return 2
if argv[0] == "-s":
SERIAL = argv[1]
else:
PKG = argv[1]
argv = argv[2:]
if not argv:
print(__doc__)
return 2
for cmd in argv:
name, _, val = cmd.partition("=")
if name == "connect":
res = connect(val or "t10.0.2.2:4403")
print(res)
if res.startswith("FAILED"):
return 1
elif name == "launch":
adb("shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}", "--ez", "skip_onboarding", "true")
print("launched")
elif name == "stop":
adb("shell", "am", "force-stop", PKG)
print("stopped")
elif name == "dump":
print(ui_dump())
elif name == "find":
for n in find(val):
print(f"{n.get('text') or n.get('content-desc')!r} clickable={n.get('clickable')} bounds={n.get('bounds')}")
elif name == "tap_text":
res = tap_text(val)
print(res)
if res.startswith("NOT FOUND"):
return 1
elif name == "tap":
x, y = val.split(",")
adb("shell", "input", "tap", x, y)
print(f"tapped {x},{y}")
elif name == "text":
adb("shell", "input", "text", val)
print("typed (beware: 'input text' can append a trailing space)")
elif name == "key":
adb("shell", "input", "keyevent", val)
print(f"key {val}")
elif name == "swipe":
adb("shell", "input", "swipe", *val.split(","))
print(f"swipe {val}")
elif name == "ss":
with open(val, "wb") as f:
subprocess.run(
["adb"] + (["-s", SERIAL] if SERIAL else []) + ["exec-out", "screencap", "-p"],
stdout=f, timeout=60, check=True,
)
print(f"saved {val}")
elif name == "wait_text":
res = wait_text(val)
print(res)
if res.startswith("TIMEOUT"):
return 1
elif name == "sleep":
time.sleep(float(val))
else:
print(f"unknown command: {cmd}", file=sys.stderr)
return 2
print(f"--- {cmd} done ---", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())