Files
apk-reverse/skills/apk-reverse/scripts/snap.py
T
inliver233 7176b5e1c9 Restructure to the skills/<name>/ layout the skills CLI resolves
The skill lived at the repository root, which the CLI reads as a single-skill repository and which makes a second skill impossible to add. SKILL.md, references/ and scripts/ move under skills/apk-reverse/ with git mv, so history follows and no in-skill reference needs changing (they are relative to SKILL.md).

check_repo.py now discovers every skills/*/SKILL.md, validates frontmatter by parsing it instead of pattern-matching it, and requires the README to name skills through explicit skills/<name>/... paths.

check_refs.py is new: it audits file.md section cross-references against the real headings in the target file. It found six dangling ones, all fixed here.

SKILL.md: the description is now quoted. Unquoted, it contained a colon-space, which is invalid YAML, so the CLI skipped the skill and reported No skills found. Adds a Coverage section stating what is out of scope and what to do when nothing matches. toolchain.md: IDA is not GUI-only (idalib-mcp is a headless MCP server), plus a section on MCP servers as external dependencies and the deobfuscate-before-LLM boundary. README.md: install via npx skills add, the new layout, coverage and maintenance tools.

Verified: check_repo.py and check_refs.py both clean, build_scripts.py audit 0 findings, npx skills add . --list reports apk-reverse, and an install into a scratch directory lands SKILL.md plus 28 references and 38 scripts.
2026-09-20 03:37:46 +08:00

204 lines
8.8 KiB
Python

#!/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 <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"<node", data))
texts = len([t for t in re.findall(r'text="([^"]*)"', data) if t.strip()])
if nodes == 0:
return "EMPTY", 0, 0
# A tree with nodes but almost no text is usually a shell with no real widgets.
return ("OK" if texts else "SPARSE"), nodes, texts
def capture_image(dev: Dev, workdir: str, name: str) -> 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())