#!/usr/bin/env python3 """Capture what is actually on screen -- and say whether the control tree is usable. Why this exists --------------- Driving a UI blind (tap a coordinate, wait, tap again) is the single most expensive habit in device work. A look at the screen resolves in one step what coordinate guessing cannot resolve in five: the layout moved, a different dialog is up, a countdown is frozen, a button is disabled, text says why. Two independent kinds of evidence, and they are not interchangeable: * the **image** -- always available, always truthful about what is rendered. It is the only evidence for UI drawn by a runtime or a web view, where no real controls exist. * the **control tree** (`uiautomator dump`) -- precise, diffable, gives exact bounds and text. Frequently EMPTY for canvas/webview/cross-platform UI. This script grabs both, reports the size and whether the tree had content, so you know which one is actually informative before you rely on it. It never touches app state. Usage ----- # one look python snap.py --out shots --tag before # watch a wait: 6 samples, 3 s apart (bounded: hard cap of 20 samples) python snap.py --out shots --tag waiting --count 6 --interval 3 # tree only, no images python snap.py --out shots --tag tree --tree-only # when nothing is on screen, capture and it will say so rather than guess python snap.py --out shots --tag check --serial Reading the output ------------------ * `bytes=0` after both capture paths -> the device refused; see the note in the output rather than concluding the screen is blank. * `tree=EMPTY` -> do not plan around accessibility bounds. Use the image and reason from pixels, or drive the app through a non-UI path. * Identical hashes across samples -> nothing is changing. Stop waiting and go find out why (this is the stall detector). """ from __future__ import annotations import argparse import hashlib import os import re import subprocess import sys import time MAX_SAMPLES = 20 # hard cap so a typo cannot produce an unbounded loop def run(cmd: list[str], timeout: int) -> tuple[int, str, str]: try: p = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout) return p.returncode, p.stdout or "", p.stderr or "" except subprocess.TimeoutExpired: return 124, "", "TIMEOUT after %ss: %s" % (timeout, " ".join(cmd[:4])) except FileNotFoundError: return 127, "", "not found: %s" % cmd[0] class Dev: def __init__(self, adb: str, serial: str | None): self.adb = adb self.base = [adb] + (["-s", serial] if serial else []) self.serial = serial def sh(self, cmd: str, timeout: int = 60) -> str: return run(self.base + ["shell", cmd], timeout)[1] def su(self, cmd: str, timeout: int = 90) -> str: return run(self.base + ["shell", 'su -c "%s"' % cmd], timeout)[1] def pull(self, remote: str, local: str, timeout: int = 120) -> bool: rc, _, err = run(self.base + ["pull", remote, local], timeout) return rc == 0 and os.path.exists(local) and os.path.getsize(local) > 0 def tree_stats(dev: Dev, workdir: str) -> tuple[str, int, int]: """Return (status, node_count, text_count). status in {OK, EMPTY, FAILED}.""" xml = "/data/local/tmp/_snap_ui.xml" dev.sh("rm -f " + xml) out = dev.su("uiautomator dump %s" % xml, timeout=90) if "dumped to" not in out and "UI hierchary" not in out and os.sep not in out: # some builds print nothing useful; fall through to the file check anyway pass local = os.path.join(workdir, "_snap_ui.xml") if not dev.pull(xml, local, timeout=120): return "FAILED", 0, 0 try: data = open(local, encoding="utf-8", errors="replace").read() except Exception: return "FAILED", 0, 0 nodes = len(re.findall(r" tuple[int, str]: remote = "/data/local/tmp/%s.png" % name dev.su("rm -f " + remote) # on-device write then pull: the streaming form returns 0 bytes on some ROMs dev.su("screencap -p %s" % remote, timeout=90) local = os.path.join(workdir, name + ".png") if dev.pull(remote, local, timeout=120): n = os.path.getsize(local) if n > 0: return n, hashlib.sha256(open(local, "rb").read()).hexdigest()[:12] # fallback: stream to host rc, out, err = run(dev.base + ["exec-out", "screencap -p"], timeout=120) if rc == 0: data = out.encode("latin1", "ignore") if isinstance(out, str) else b"" if data: open(local, "wb").write(data) return len(data), hashlib.sha256(data).hexdigest()[:12] return 0, "" def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--adb", default=os.environ.get("ADB", "adb")) ap.add_argument("--serial", default=None, help="device serial; required when several are attached") ap.add_argument("--out", default="snapshots", help="output directory (created if missing)") ap.add_argument("--tag", default="snap", help="label used in filenames") ap.add_argument("--count", type=int, default=1, help="samples to take (capped at %d)" % MAX_SAMPLES) ap.add_argument("--interval", type=float, default=2.0, help="seconds between samples") ap.add_argument("--tree-only", action="store_true", help="skip images; only read the control tree") ap.add_argument("--image-only", action="store_true", help="skip the control tree") a = ap.parse_args() if a.count > MAX_SAMPLES: print("count capped at %d (asked for %d)" % (MAX_SAMPLES, a.count), file=sys.stderr) a.count = MAX_SAMPLES workdir = os.path.abspath(a.out) os.makedirs(workdir, exist_ok=True) dev = Dev(a.adb, a.serial) # cheap reachability gate with a timeout, so we fail fast and informatively rc, out, err = run(dev.base + ["shell", "echo __ok__"], timeout=30) if "__ok__" not in out: print("device not answering: %s%s" % (out.strip()[:120], (" / " + err.strip()[:120]) if err else ""), file=sys.stderr) print("check `adb devices`; if the emulator process is alive but nothing is listening on the adb port,", file=sys.stderr) print("restart the instance through the vendor console (see references/environment.md).", file=sys.stderr) return 2 tree_status = "skipped" nodes = texts = 0 if not a.image_only: tree_status, nodes, texts = tree_stats(dev, workdir) print("control tree: %s (nodes=%d, non-empty text=%d)" % (tree_status, nodes, texts)) if tree_status in ("EMPTY", "FAILED"): print(" -> the accessibility tree is not usable here. Do NOT plan around bounds from it.") print(" The image is the primary evidence for this UI. Cross-platform runtimes, web views") print(" and canvas-drawn UI frequently expose no real controls at all.") elif tree_status == "SPARSE": print(" -> tree exists but carries almost no text; treat it as unreliable and read the image.") prev_hash = None same_run = 0 for i in range(a.count): tag = a.tag if a.count == 1 else "%s_%02d" % (a.tag, i) if a.tree_only: print("%-14s tree-only sample" % tag) else: size, digest = capture_image(dev, workdir, tag) if size == 0: print("%-14s bytes=0 -> device refused the capture. Not evidence that the screen is blank." % tag) else: note = "" if digest == prev_hash: same_run += 1 note = " (identical to previous -> nothing changed)" else: same_run = 0 prev_hash = digest print("%-14s bytes=%-8d sha=%s%s" % (tag, size, digest, note)) print("%-14s %s" % ("", os.path.join(workdir, tag + ".png"))) if same_run >= 2: print(" -> %d identical samples in a row. This is a stall, not a slow operation." % (same_run + 1)) print(" Stop waiting: inspect the image, or check the app is still alive and in the foreground.") if i < a.count - 1: time.sleep(max(0.0, a.interval)) print("\nNow LOOK at the image(s) before deciding the next action.") print("Driving blind and waiting is how rounds get wasted; the screen usually states the reason.") return 0 if __name__ == "__main__": sys.exit(main())