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.
204 lines
8.8 KiB
Python
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())
|