#!/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, }