192 lines
5.3 KiB
Python
Executable file
192 lines
5.3 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Minimal adb + uiautomator helpers for Taler wallet GUI smoke (no root).
|
|
|
|
Does not replace Maestro/Appium; uses only adb shell + uiautomator dump.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
|
|
Bounds = Tuple[int, int, int, int]
|
|
|
|
|
|
def adb(serial: str, *args: str, check: bool = False, timeout: Optional[float] = 60) -> subprocess.CompletedProcess:
|
|
cmd = ["adb", "-s", serial, *args]
|
|
return subprocess.run(cmd, check=check, capture_output=True, text=True, timeout=timeout)
|
|
|
|
|
|
def dump_ui(serial: str, dest: Path) -> str:
|
|
adb(serial, "shell", "uiautomator", "dump", "/sdcard/ui.xml", timeout=90)
|
|
adb(serial, "pull", "/sdcard/ui.xml", str(dest), timeout=30)
|
|
return dest.read_text(errors="replace") if dest.is_file() else ""
|
|
|
|
|
|
def screencap(serial: str, dest: Path) -> None:
|
|
p = subprocess.run(
|
|
["adb", "-s", serial, "exec-out", "screencap", "-p"],
|
|
capture_output=True,
|
|
timeout=60,
|
|
)
|
|
if p.returncode == 0 and p.stdout:
|
|
dest.write_bytes(p.stdout)
|
|
|
|
|
|
def texts(xml: str) -> List[str]:
|
|
return [t for t in re.findall(r'text="([^"]*)"', xml) if t.strip()]
|
|
|
|
|
|
def find_bounds(xml: str, label: str) -> List[Bounds]:
|
|
"""Return list of (x1,y1,x2,y2) for nodes whose text or content-desc matches label (exact)."""
|
|
out: List[Bounds] = []
|
|
esc = re.escape(label)
|
|
patterns = [
|
|
rf'(?:text|content-desc)="{esc}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
|
|
rf'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*(?:text|content-desc)="{esc}"',
|
|
]
|
|
for pat in patterns:
|
|
for m in re.finditer(pat, xml, flags=re.I):
|
|
out.append(tuple(map(int, m.groups()))) # type: ignore[arg-type]
|
|
return out
|
|
|
|
|
|
def find_bounds_re(xml: str, pattern: str) -> List[Tuple[str, Bounds]]:
|
|
out: List[Tuple[str, Bounds]] = []
|
|
for m in re.finditer(
|
|
rf'(?:text|content-desc)="([^"]*{pattern}[^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
|
|
xml,
|
|
flags=re.I,
|
|
):
|
|
label = m.group(1)
|
|
b = tuple(map(int, m.group(2, 3, 4, 5)))
|
|
out.append((label, b)) # type: ignore[arg-type]
|
|
return out
|
|
|
|
|
|
def center(b: Bounds) -> Tuple[int, int]:
|
|
x1, y1, x2, y2 = b
|
|
return (x1 + x2) // 2, (y1 + y2) // 2
|
|
|
|
|
|
def tap(serial: str, x: int, y: int) -> None:
|
|
adb(serial, "shell", "input", "tap", str(x), str(y))
|
|
|
|
|
|
def tap_bounds(serial: str, b: Bounds) -> None:
|
|
x, y = center(b)
|
|
tap(serial, x, y)
|
|
|
|
|
|
def dismiss_anr(serial: str, xml: str) -> bool:
|
|
"""If System UI isn't responding, prefer Wait over Close."""
|
|
if "isn't responding" not in xml and "reagiert nicht" not in xml.lower():
|
|
return False
|
|
for label in ("Wait", "Warten", "OK"):
|
|
bs = find_bounds(xml, label)
|
|
if bs:
|
|
tap_bounds(serial, bs[0])
|
|
return True
|
|
# fallback: right-ish button often Wait
|
|
adb(serial, "shell", "input", "keyevent", "22")
|
|
adb(serial, "shell", "input", "keyevent", "66")
|
|
return True
|
|
|
|
|
|
# Labels seen on wallet UI (EN/DE/FR) — expand as needed
|
|
CONFIRM_LABELS = [
|
|
"Confirm",
|
|
"Confirm withdrawal",
|
|
"Withdraw",
|
|
"Accept",
|
|
"I accept",
|
|
"Agree",
|
|
"Continue",
|
|
"Next",
|
|
"OK",
|
|
"Pay",
|
|
"Pay now",
|
|
"Bestätigen",
|
|
"Abheben",
|
|
"Akzeptieren",
|
|
"Zustimmen",
|
|
"Weiter",
|
|
"Bezahlen",
|
|
"Confirmer",
|
|
"Retirer",
|
|
"Accepter",
|
|
"Payer",
|
|
]
|
|
|
|
TOS_LABELS = [
|
|
"I accept",
|
|
"Accept",
|
|
"Agree",
|
|
"Akzeptieren",
|
|
"Zustimmen",
|
|
"Accepter",
|
|
]
|
|
|
|
|
|
def try_tap_any(serial: str, xml: str, labels: Sequence[str]) -> Optional[str]:
|
|
for label in labels:
|
|
bs = find_bounds(xml, label)
|
|
if bs:
|
|
tap_bounds(serial, bs[0])
|
|
return label
|
|
return None
|
|
|
|
|
|
def gui_drive(
|
|
serial: str,
|
|
out_dir: Path,
|
|
*,
|
|
rounds: int = 8,
|
|
sleep_s: float = 3.0,
|
|
phase: str = "gui",
|
|
) -> dict:
|
|
"""Poll UI, dismiss ANR, tap confirm/ToS-like buttons. Returns status dict."""
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
seen_texts: List[str] = []
|
|
taps: List[str] = []
|
|
anr = 0
|
|
for i in range(1, rounds + 1):
|
|
xml_path = out_dir / f"{phase}-{i:02d}-ui.xml"
|
|
png_path = out_dir / f"{phase}-{i:02d}.png"
|
|
try:
|
|
xml = dump_ui(serial, xml_path)
|
|
except Exception as e:
|
|
taps.append(f"dump-fail:{e}")
|
|
time.sleep(sleep_s)
|
|
continue
|
|
try:
|
|
screencap(serial, png_path)
|
|
except Exception:
|
|
pass
|
|
ts = texts(xml)
|
|
seen_texts.extend(ts[:20])
|
|
if dismiss_anr(serial, xml):
|
|
anr += 1
|
|
time.sleep(sleep_s)
|
|
continue
|
|
hit = try_tap_any(serial, xml, CONFIRM_LABELS)
|
|
if hit:
|
|
taps.append(hit)
|
|
time.sleep(sleep_s)
|
|
continue
|
|
# partial regex for amount confirm rows
|
|
for label, b in find_bounds_re(xml, r"Confirm|Withdraw|Pay|Accept|Bestätig|Abheben|Payer"):
|
|
tap_bounds(serial, b)
|
|
taps.append(f"re:{label}")
|
|
time.sleep(sleep_s)
|
|
break
|
|
else:
|
|
time.sleep(sleep_s)
|
|
return {
|
|
"taps": taps,
|
|
"anr_dismissals": anr,
|
|
"sample_texts": seen_texts[-40:],
|
|
"rounds": rounds,
|
|
}
|