mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Android runs one instrumentation per package: a second `am instrument`
force-stops the first ("stop com.kuhy.a11ydump due to start instr"), and
so does `install -r`. The live two-session run on the Pixel caught two
sessions' dumps killing each other ("Process crashed"). dump_display now
runs install + instrument under a per-serial flock; a dump takes well
under a second, so queueing costs nothing noticeable.
After the fix the same 300 s run had 0 failures across 659 actions, and
the real screen and both apps stayed where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
246 lines
7.8 KiB
Python
246 lines
7.8 KiB
Python
"""Accessibility-tree dumps of any display, via a tiny instrumentation APK.
|
|
|
|
``uiautomator dump`` only ever sees display 0, so an app on a session's
|
|
virtual display (``phone_vd.sh``) could only be tapped by coordinates. The
|
|
APK built from ``A11yDump.java`` runs under ``am instrument``, where a UiAutomation can
|
|
read ``getWindowsOnAllDisplays()`` and print one display's tree in the same
|
|
XML shape ``uiautomator`` writes.
|
|
|
|
It is built from source here (javac → d8 → aapt2 → apksigner, no Gradle),
|
|
cached by source hash under ``~/.cache/android_ui/``, and (re)installed with
|
|
``adb install -r -t`` whenever the phone holds a different build. The APK is
|
|
never committed: the repo's binary gate forbids it, and the source is enough.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Iterator
|
|
import contextlib
|
|
import fcntl
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
|
|
from python_pkg.android_ui._elements import UiAutomationError
|
|
|
|
PACKAGE = "com.kuhy.a11ydump"
|
|
_RUNNER = f"{PACKAGE}/.A11yDump"
|
|
# Flat beside this module: the repo caps paths at two directories, and javac
|
|
# does not need the package's directory layout for an explicitly named file.
|
|
_JAVA = Path(__file__).with_name("A11yDump.java")
|
|
_MANIFEST = Path(__file__).with_name("a11y_dump_manifest.xml")
|
|
_MIN_API = "30" # getWindowsOnAllDisplays()
|
|
|
|
# (adb args...) -> stdout, raising UiAutomationError on failure.
|
|
AdbRun = Callable[..., str]
|
|
|
|
|
|
def source_hash() -> str:
|
|
"""Hash of every source file: the build's identity and versionName."""
|
|
digest = hashlib.sha256()
|
|
for path in (_JAVA, _MANIFEST):
|
|
digest.update(path.name.encode())
|
|
digest.update(path.read_bytes())
|
|
return digest.hexdigest()[:16]
|
|
|
|
|
|
def cache_dir() -> Path:
|
|
"""Where built APKs are kept."""
|
|
return Path(
|
|
os.environ.get("ANDROID_UI_CACHE", Path.home() / ".cache" / "android_ui")
|
|
)
|
|
|
|
|
|
def sdk_root() -> Path:
|
|
"""The Android SDK, from $ANDROID_HOME / $ANDROID_SDK_ROOT."""
|
|
for var in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
|
|
value = os.environ.get(var)
|
|
if value and Path(value).is_dir():
|
|
return Path(value)
|
|
fallback = Path.home() / "sdk" / "Android" / "Sdk"
|
|
if fallback.is_dir():
|
|
return fallback
|
|
msg = "no Android SDK: set ANDROID_HOME (needs build-tools and a platform)"
|
|
raise UiAutomationError(msg)
|
|
|
|
|
|
def _newest(parent: Path, prefix: str = "") -> Path:
|
|
candidates = sorted(
|
|
(p for p in parent.iterdir() if p.name.startswith(prefix) and p.is_dir()),
|
|
key=lambda p: [
|
|
int(x) if x.isdigit() else 0 for x in p.name.split("-")[-1].split(".")
|
|
],
|
|
)
|
|
if not candidates:
|
|
msg = f"nothing matching {prefix!r} under {parent}"
|
|
raise UiAutomationError(msg)
|
|
return candidates[-1]
|
|
|
|
|
|
def build_tool(name: str) -> Path:
|
|
"""Absolute path of an SDK build tool (``aapt2``, ``d8``, ...), newest version."""
|
|
return _newest(sdk_root() / "build-tools") / name
|
|
|
|
|
|
def _tool(*cmd: str | Path) -> None:
|
|
done = subprocess.run(
|
|
[str(c) for c in cmd], capture_output=True, text=True, check=False
|
|
)
|
|
if done.returncode != 0:
|
|
msg = f"building the a11y helper failed: {cmd[0]}\n{done.stderr.strip()}"
|
|
raise UiAutomationError(msg)
|
|
|
|
|
|
def build() -> Path:
|
|
"""Return the helper APK for the current source, building it if needed."""
|
|
version = source_hash()
|
|
apk = cache_dir() / f"a11ydump-{version}.apk"
|
|
if apk.is_file():
|
|
return apk
|
|
sdk = sdk_root()
|
|
tools = _newest(sdk / "build-tools")
|
|
android_jar = _newest(sdk / "platforms", "android-") / "android.jar"
|
|
keystore = Path.home() / ".android" / "debug.keystore"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
work = Path(tmp)
|
|
classes, dex = work / "classes", work / "dex"
|
|
classes.mkdir()
|
|
dex.mkdir()
|
|
_tool(
|
|
"javac",
|
|
"--release",
|
|
"11",
|
|
"-classpath",
|
|
android_jar,
|
|
"-d",
|
|
classes,
|
|
_JAVA,
|
|
)
|
|
class_files = sorted(str(p) for p in classes.rglob("*.class"))
|
|
_tool(
|
|
tools / "d8",
|
|
"--release",
|
|
"--min-api",
|
|
_MIN_API,
|
|
"--lib",
|
|
android_jar,
|
|
"--output",
|
|
dex,
|
|
*class_files,
|
|
)
|
|
unsigned = work / "unsigned.apk"
|
|
_tool(
|
|
tools / "aapt2",
|
|
"link",
|
|
"--manifest",
|
|
_MANIFEST,
|
|
"-I",
|
|
android_jar,
|
|
"--version-code",
|
|
"1",
|
|
"--version-name",
|
|
version,
|
|
"-o",
|
|
unsigned,
|
|
)
|
|
with zipfile.ZipFile(unsigned, "a") as zf:
|
|
zf.write(dex / "classes.dex", "classes.dex")
|
|
aligned = work / "aligned.apk"
|
|
_tool(tools / "zipalign", "-f", "4", unsigned, aligned)
|
|
if not keystore.is_file():
|
|
_tool(
|
|
"keytool",
|
|
"-genkeypair",
|
|
"-keystore",
|
|
keystore,
|
|
"-storepass",
|
|
"android",
|
|
"-alias",
|
|
"androiddebugkey",
|
|
"-keypass",
|
|
"android",
|
|
"-keyalg",
|
|
"RSA",
|
|
"-validity",
|
|
"10000",
|
|
"-dname",
|
|
"CN=Android Debug,O=Android,C=US",
|
|
)
|
|
_tool(
|
|
tools / "apksigner",
|
|
"sign",
|
|
"--ks",
|
|
keystore,
|
|
"--ks-pass",
|
|
"pass:android",
|
|
"--key-pass",
|
|
"pass:android",
|
|
"--out",
|
|
work / "signed.apk",
|
|
aligned,
|
|
)
|
|
apk.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(work / "signed.apk", apk)
|
|
return apk
|
|
|
|
|
|
def ensure_installed(run: AdbRun) -> None:
|
|
"""Install the helper unless the phone already holds this exact build."""
|
|
version = source_hash()
|
|
info = run("shell", "dumpsys", "package", PACKAGE)
|
|
if f"versionName={version}" in info:
|
|
return
|
|
run("install", "-r", "-t", str(build()), timeout=120.0)
|
|
|
|
|
|
def parse_result(output: str) -> str:
|
|
"""Pull the XML out of ``am instrument -w`` output, or raise its error."""
|
|
for line in output.splitlines():
|
|
if line.startswith("INSTRUMENTATION_RESULT: xml="):
|
|
return line.split("=", 1)[1]
|
|
for line in output.splitlines():
|
|
if line.startswith("INSTRUMENTATION_RESULT: error="):
|
|
msg = f"a11y helper failed: {line.split('=', 1)[1]}"
|
|
raise UiAutomationError(msg)
|
|
msg = f"a11y helper returned no tree:\n{output.strip()[-500:]}"
|
|
raise UiAutomationError(msg)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _one_at_a_time(phone: str) -> Iterator[None]:
|
|
"""Hold this host's lock on the helper for one phone.
|
|
|
|
Android runs one instrumentation per package: a second ``am instrument``
|
|
force-stops the first ("stop ... due to start instr"), and so does an
|
|
``install -r``. Two sessions dumping at once killed each other's dump in
|
|
the 2026-09-25 live test. A dump takes well under a second, so queueing
|
|
costs nothing noticeable; closing the file releases the lock.
|
|
"""
|
|
path = cache_dir() / f"a11ydump-{phone}.lock"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("a") as handle:
|
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
yield
|
|
|
|
|
|
def dump_display(run: AdbRun, display: int, phone: str = "any") -> str:
|
|
"""Return ``display``'s accessibility tree as uiautomator-style XML."""
|
|
with _one_at_a_time(phone):
|
|
ensure_installed(run)
|
|
output = run(
|
|
"shell",
|
|
"am",
|
|
"instrument",
|
|
"-w",
|
|
"-e",
|
|
"display",
|
|
str(display),
|
|
_RUNNER,
|
|
timeout=45.0,
|
|
)
|
|
return parse_result(output)
|