424 lines
14 KiB
Python
424 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import shutil
|
|
import xml.etree.ElementTree as ET
|
|
from typing import Optional
|
|
from urllib.parse import quote
|
|
|
|
|
|
def adb_available() -> bool:
|
|
return shutil.which("adb") is not None
|
|
|
|
|
|
async def _run_cmd(
|
|
command: list[str],
|
|
*,
|
|
timeout: float = 20.0,
|
|
capture_binary: bool = False,
|
|
) -> tuple[int, str | bytes, str]:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
return 124, b"" if capture_binary else "", "Command timed out"
|
|
|
|
if capture_binary:
|
|
return proc.returncode or 0, stdout or b"", (stderr or b"").decode("utf-8", "replace").strip()
|
|
return (
|
|
proc.returncode or 0,
|
|
(stdout or b"").decode("utf-8", "replace").strip(),
|
|
(stderr or b"").decode("utf-8", "replace").strip(),
|
|
)
|
|
|
|
|
|
def _adb_prefix(serial: Optional[str]) -> list[str]:
|
|
command = ["adb"]
|
|
if serial:
|
|
command.extend(["-s", serial])
|
|
return command
|
|
|
|
|
|
async def _adb_text(serial: Optional[str], *args: str, timeout: float = 20.0) -> tuple[int, str, str]:
|
|
code, out, err = await _run_cmd(_adb_prefix(serial) + list(args), timeout=timeout)
|
|
assert isinstance(out, str)
|
|
return code, out, err
|
|
|
|
|
|
async def _adb_binary(serial: Optional[str], *args: str, timeout: float = 20.0) -> tuple[int, bytes, str]:
|
|
code, out, err = await _run_cmd(
|
|
_adb_prefix(serial) + list(args),
|
|
timeout=timeout,
|
|
capture_binary=True,
|
|
)
|
|
assert isinstance(out, bytes)
|
|
return code, out, err
|
|
|
|
|
|
def _parse_device_line(line: str) -> Optional[dict]:
|
|
line = line.strip()
|
|
if not line or line.startswith("List of devices attached"):
|
|
return None
|
|
parts = line.split()
|
|
if len(parts) < 2:
|
|
return None
|
|
|
|
serial = parts[0]
|
|
state = parts[1]
|
|
extras: dict[str, str] = {}
|
|
for token in parts[2:]:
|
|
if ":" not in token:
|
|
continue
|
|
key, value = token.split(":", 1)
|
|
extras[key] = value
|
|
|
|
return {
|
|
"serial": serial,
|
|
"state": state,
|
|
"usb": extras.get("usb", ""),
|
|
"product": extras.get("product", ""),
|
|
"model": extras.get("model", ""),
|
|
"device": extras.get("device", ""),
|
|
"transport_id": extras.get("transport_id", ""),
|
|
"raw": line,
|
|
}
|
|
|
|
|
|
async def list_android_devices() -> list[dict]:
|
|
if not adb_available():
|
|
return []
|
|
code, out, _ = await _adb_text(None, "devices", "-l")
|
|
if code != 0:
|
|
return []
|
|
devices: list[dict] = []
|
|
for raw in out.splitlines():
|
|
parsed = _parse_device_line(raw)
|
|
if parsed:
|
|
devices.append(parsed)
|
|
return devices
|
|
|
|
|
|
async def _shell(serial: str, command: str, timeout: float = 20.0) -> str:
|
|
code, out, err = await _adb_text(serial, "shell", command, timeout=timeout)
|
|
if code != 0:
|
|
raise RuntimeError(err or out or "ADB shell command failed")
|
|
return out or err
|
|
|
|
|
|
async def _get_display_size(serial: str) -> tuple[int, int]:
|
|
output = await _shell(serial, "wm size | tail -n 1")
|
|
match = re.search(r"(\d+)x(\d+)", output)
|
|
if not match:
|
|
return 1080, 2400
|
|
return int(match.group(1)), int(match.group(2))
|
|
|
|
|
|
async def android_device_overview(serial: str) -> dict:
|
|
if not serial:
|
|
raise ValueError("Missing Android device serial")
|
|
|
|
props = {
|
|
"model": "getprop ro.product.model",
|
|
"brand": "getprop ro.product.brand",
|
|
"android_version": "getprop ro.build.version.release",
|
|
"sdk": "getprop ro.build.version.sdk",
|
|
}
|
|
|
|
async def grab(name: str, cmd: str) -> tuple[str, str]:
|
|
try:
|
|
return name, (await _shell(serial, cmd, timeout=10.0)).strip()
|
|
except RuntimeError as exc:
|
|
return name, str(exc)
|
|
|
|
results = dict(await asyncio.gather(*(grab(key, cmd) for key, cmd in props.items())))
|
|
|
|
battery = await _shell(
|
|
serial,
|
|
"dumpsys battery | grep -E 'level|status|powered' | sed 's/^[[:space:]]*//'",
|
|
timeout=10.0,
|
|
)
|
|
focus = await _shell(
|
|
serial,
|
|
"(dumpsys window windows | grep -E 'mCurrentFocus' | tail -n 1) || "
|
|
"(dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1)",
|
|
timeout=10.0,
|
|
)
|
|
resolution = await _shell(serial, "wm size | tail -n 1", timeout=10.0)
|
|
rotation = await _shell(serial, "settings get system user_rotation", timeout=10.0)
|
|
screen_on = await _shell(serial, "dumpsys power | grep -E 'Display Power|mHoldingDisplaySuspendBlocker'", timeout=10.0)
|
|
|
|
return {
|
|
"serial": serial,
|
|
"model": results.get("model", ""),
|
|
"brand": results.get("brand", ""),
|
|
"android_version": results.get("android_version", ""),
|
|
"sdk": results.get("sdk", ""),
|
|
"battery": battery,
|
|
"focus": focus,
|
|
"resolution": resolution,
|
|
"rotation": rotation,
|
|
"screen_state": screen_on,
|
|
}
|
|
|
|
|
|
def _bounds_to_rect(bounds: str) -> Optional[tuple[int, int, int, int]]:
|
|
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds.strip())
|
|
if not match:
|
|
return None
|
|
left, top, right, bottom = (int(match.group(i)) for i in range(1, 5))
|
|
if right <= left or bottom <= top:
|
|
return None
|
|
return left, top, right, bottom
|
|
|
|
|
|
def _node_label(node: ET.Element) -> str:
|
|
text = (node.attrib.get("text") or "").strip()
|
|
desc = (node.attrib.get("content-desc") or "").strip()
|
|
resource_id = (node.attrib.get("resource-id") or "").strip()
|
|
class_name = (node.attrib.get("class") or "").strip()
|
|
if text:
|
|
return text
|
|
if desc:
|
|
return desc
|
|
if resource_id:
|
|
return resource_id.split("/")[-1]
|
|
return class_name.split(".")[-1] if class_name else "node"
|
|
|
|
|
|
async def android_packages(serial: str, query: str = "", limit: int = 60) -> list[dict]:
|
|
if not serial:
|
|
raise ValueError("Missing Android device serial")
|
|
|
|
output = await _shell(serial, "pm list packages", timeout=25.0)
|
|
needle = query.strip().lower()
|
|
results: list[dict] = []
|
|
for raw in output.splitlines():
|
|
package = raw.strip()
|
|
if package.startswith("package:"):
|
|
package = package[len("package:") :]
|
|
if not package:
|
|
continue
|
|
if needle and needle not in package.lower():
|
|
continue
|
|
results.append(
|
|
{
|
|
"package": package,
|
|
"label": package.split(".")[-1],
|
|
}
|
|
)
|
|
if len(results) >= max(1, min(limit, 200)):
|
|
break
|
|
return results
|
|
|
|
|
|
async def android_ui_nodes(serial: str, limit: int = 80) -> list[dict]:
|
|
if not serial:
|
|
raise ValueError("Missing Android device serial")
|
|
|
|
xml_text = ""
|
|
for dump_path in ("/data/local/tmp/live-ui.xml", "/sdcard/live-ui.xml"):
|
|
try:
|
|
xml_text = await _shell(
|
|
serial,
|
|
f"uiautomator dump {dump_path} >/dev/null 2>&1 && cat {dump_path}",
|
|
timeout=25.0,
|
|
)
|
|
except RuntimeError:
|
|
xml_text = ""
|
|
if xml_text.strip().startswith("<?xml"):
|
|
break
|
|
xml_text = xml_text.strip()
|
|
if not xml_text.startswith("<?xml"):
|
|
raise RuntimeError("Failed to dump Android UI hierarchy")
|
|
|
|
try:
|
|
root = ET.fromstring(xml_text)
|
|
except ET.ParseError as exc:
|
|
raise RuntimeError(f"Unable to parse Android UI dump: {exc}") from exc
|
|
|
|
nodes: list[dict] = []
|
|
for node in root.iter("node"):
|
|
bounds = (node.attrib.get("bounds") or "").strip()
|
|
rect = _bounds_to_rect(bounds)
|
|
if not rect:
|
|
continue
|
|
|
|
text = (node.attrib.get("text") or "").strip()
|
|
desc = (node.attrib.get("content-desc") or "").strip()
|
|
resource_id = (node.attrib.get("resource-id") or "").strip()
|
|
clickable = node.attrib.get("clickable") == "true"
|
|
focusable = node.attrib.get("focusable") == "true"
|
|
scrollable = node.attrib.get("scrollable") == "true"
|
|
enabled = node.attrib.get("enabled") != "false"
|
|
if not (text or desc or resource_id or clickable or focusable or scrollable):
|
|
continue
|
|
|
|
left, top, right, bottom = rect
|
|
center_x = left + (right - left) // 2
|
|
center_y = top + (bottom - top) // 2
|
|
nodes.append(
|
|
{
|
|
"label": _node_label(node),
|
|
"text": text,
|
|
"content_desc": desc,
|
|
"resource_id": resource_id,
|
|
"class_name": (node.attrib.get("class") or "").strip(),
|
|
"package": (node.attrib.get("package") or "").strip(),
|
|
"bounds": bounds,
|
|
"center_x": center_x,
|
|
"center_y": center_y,
|
|
"clickable": clickable,
|
|
"focusable": focusable,
|
|
"scrollable": scrollable,
|
|
"enabled": enabled,
|
|
}
|
|
)
|
|
|
|
def sort_key(item: dict) -> tuple[int, int, int]:
|
|
return (
|
|
0 if item["clickable"] else 1,
|
|
0 if item["text"] or item["content_desc"] else 1,
|
|
len(item["label"]),
|
|
)
|
|
|
|
nodes.sort(key=sort_key)
|
|
return nodes[: max(1, min(limit, 200))]
|
|
|
|
|
|
async def android_screenshot(serial: str) -> bytes:
|
|
if not serial:
|
|
raise ValueError("Missing Android device serial")
|
|
code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=20.0)
|
|
if code != 0 or not data:
|
|
raise RuntimeError(err or "Failed to capture screenshot")
|
|
return data.replace(b"\r\n", b"\n")
|
|
|
|
|
|
def _normalize_text(text: str) -> str:
|
|
normalized = text.replace(" ", "%s")
|
|
normalized = normalized.replace("&", r"\&")
|
|
normalized = normalized.replace("(", r"\(")
|
|
normalized = normalized.replace(")", r"\)")
|
|
return normalized
|
|
|
|
|
|
async def android_action(action: str, serial: str, payload: Optional[dict] = None) -> dict:
|
|
if not serial:
|
|
raise ValueError("Missing Android device serial")
|
|
if not adb_available():
|
|
raise RuntimeError("adb is not installed")
|
|
payload = payload or {}
|
|
|
|
if action == "key":
|
|
keycode = str(payload.get("keycode", "")).strip()
|
|
if not keycode:
|
|
raise ValueError("Missing keycode")
|
|
await _shell(serial, f"input keyevent {keycode}")
|
|
return {"message": f"Sent keycode {keycode}"}
|
|
|
|
if action == "text":
|
|
text = str(payload.get("text", ""))
|
|
if not text:
|
|
raise ValueError("Missing text")
|
|
await _shell(serial, f"input text '{_normalize_text(text)}'")
|
|
return {"message": "Text sent to device"}
|
|
|
|
if action == "open_url":
|
|
url = str(payload.get("url", "")).strip()
|
|
if not url:
|
|
raise ValueError("Missing url")
|
|
await _shell(
|
|
serial,
|
|
f"am start -a android.intent.action.VIEW -d '{quote(url, safe=':/?&=%#.-_')}'",
|
|
)
|
|
return {"message": f"Opened URL: {url}"}
|
|
|
|
if action == "launch_package":
|
|
package = str(payload.get("package", "")).strip()
|
|
if not package:
|
|
raise ValueError("Missing package")
|
|
await _shell(serial, f"monkey -p '{package}' -c android.intent.category.LAUNCHER 1")
|
|
return {"message": f"Launched package: {package}"}
|
|
|
|
if action == "shell":
|
|
command = str(payload.get("command", "")).strip()
|
|
if not command:
|
|
raise ValueError("Missing shell command")
|
|
output = await _shell(serial, command, timeout=30.0)
|
|
return {"message": "Shell command finished", "output": output}
|
|
|
|
if action == "tap":
|
|
x = int(payload.get("x", 0))
|
|
y = int(payload.get("y", 0))
|
|
await _shell(serial, f"input tap {x} {y}")
|
|
return {"message": f"Tapped at {x}, {y}"}
|
|
|
|
if action == "double_tap":
|
|
x = int(payload.get("x", 0))
|
|
y = int(payload.get("y", 0))
|
|
await _shell(serial, f"input tap {x} {y} && sleep 0.08 && input tap {x} {y}")
|
|
return {"message": f"Double tapped at {x}, {y}"}
|
|
|
|
if action == "long_press":
|
|
x = int(payload.get("x", 0))
|
|
y = int(payload.get("y", 0))
|
|
duration = int(payload.get("duration", 700))
|
|
await _shell(serial, f"input swipe {x} {y} {x} {y} {duration}")
|
|
return {"message": f"Long pressed at {x}, {y} for {duration}ms"}
|
|
|
|
if action == "swipe":
|
|
direction = str(payload.get("direction", "")).strip().lower()
|
|
width, height = await _get_display_size(serial)
|
|
mid_x = max(width // 2, 1)
|
|
mid_y = max(height // 2, 1)
|
|
mapping = {
|
|
"up": (mid_x, int(height * 0.75), mid_x, int(height * 0.28)),
|
|
"down": (mid_x, int(height * 0.28), mid_x, int(height * 0.75)),
|
|
"left": (int(width * 0.75), mid_y, int(width * 0.25), mid_y),
|
|
"right": (int(width * 0.25), mid_y, int(width * 0.75), mid_y),
|
|
}
|
|
if direction not in mapping:
|
|
raise ValueError("Unsupported swipe direction")
|
|
start_x, start_y, end_x, end_y = mapping[direction]
|
|
await _shell(serial, f"input swipe {start_x} {start_y} {end_x} {end_y} 240")
|
|
return {"message": f"Swiped {direction}"}
|
|
|
|
if action == "wake":
|
|
await _shell(serial, "input keyevent KEYCODE_WAKEUP")
|
|
return {"message": "Wake command sent"}
|
|
|
|
if action == "rotate":
|
|
target = str(payload.get("rotation", "0")).strip()
|
|
if target not in {"0", "1", "2", "3"}:
|
|
raise ValueError("Rotation must be one of 0,1,2,3")
|
|
await _shell(
|
|
serial,
|
|
"settings put system accelerometer_rotation 0 && "
|
|
f"settings put system user_rotation {target}",
|
|
)
|
|
return {"message": f"Rotation set to {target}"}
|
|
|
|
if action == "force_stop":
|
|
package = str(payload.get("package", "")).strip()
|
|
if not package:
|
|
raise ValueError("Missing package")
|
|
await _shell(serial, f"am force-stop '{package}'")
|
|
return {"message": f"Force-stopped: {package}"}
|
|
|
|
if action == "clear_app":
|
|
package = str(payload.get("package", "")).strip()
|
|
if not package:
|
|
raise ValueError("Missing package")
|
|
output = await _shell(serial, f"pm clear '{package}'", timeout=25.0)
|
|
return {"message": f"Cleared app data: {package}", "output": output}
|
|
|
|
raise ValueError(f"Unsupported Android action: {action}")
|