phone_guard: lease or block raw adb; android_ui drives a session's own virtual display

A raw `adb shell input tap` took no lease, so one session's tap landed in
another session's app (2026-09-25). python_pkg.phone_guard classifies every
adb call in a Bash command and, as the Claude Code PreToolUse hook, takes
the matching phone_lease (one app / real screen / whole phone) or exits 2:
key/text input without -d, input on another session's virtual display, or
a lease still held elsewhere after 40 s.

android_ui gains --display self|<id>: it drives only this session's
phone_vd display under that app's lease, and every input carries -d.
uiautomator sees display 0 only, so virtual-display trees come from a tiny
instrumentation APK (A11yDump.java) built from source without Gradle.

Prunes the oldest evidence artifact to stay under the 60-file cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
This commit is contained in:
Krzysztof Rudnicki 2026-09-25 16:09:45 +02:00
parent bb9389864e
commit aa37852296
30 changed files with 1858 additions and 156 deletions

View File

@ -0,0 +1,14 @@
{
"title": "phone_guard: lease or block raw adb; android_ui drives a session's own virtual display",
"objective": "No Claude session's adb call can land in another session's app: raw adb in a Bash call takes the matching phone_lease or is blocked, and android_ui can tap by element on the session's own virtual display.",
"acceptance_criteria": [
"Reads (logcat, dumpsys, pull, screencap) pass with no lease.",
"Input or launches on display 0 take the pkg:__screen__ lease; install/force-stop take that app's lease; reboot, Wi-Fi/airplane/Doze and settings put take the whole-phone lease.",
"input text/keyevent without -d, and input aimed at another session's virtual display, exit 2 with a reason.",
"A lease held by another session is waited on for 40 s, then the call exits 2 naming the holder.",
"android_ui --display self|<id> drives only this session's display under that app's lease; every input call carries -d.",
"Virtual-display trees come from the com.kuhy.a11ydump instrumentation helper, built from source and installed with adb install -r -t."
],
"out_of_scope": ["phone_lease scopes themselves (c0c51036)", "the ~/.claude hook wrapper, phone_deploy.sh and the phone-focus-mode whitelist (separate repos)"],
"verifier": "pytest python_pkg/android_ui python_pkg/phone_guard at 100% branch coverage; pre-commit on the changed files; a real helper APK build; the live two-session run on the Pixel follows this commit."
}

View File

@ -0,0 +1,18 @@
{
"intent": "Several Claude sessions act on one phone at once without interrupting each other; a raw `adb shell input tap` (no lease) had landed in another session's app on 2026-09-25.",
"scope": ["python_pkg/phone_guard (new: classify, _remote, _need, vd_state, hook, CLI, tests)", "python_pkg/android_ui (--display, _a11y_helper + A11yDump.java, -d on every input, tests split under the 250-line cap)"],
"changes": [
"phone_guard.classify maps every adb call in a shell command to a Need: nothing, screen, one app, whole phone, or a block",
"phone_guard.hook applies it as the Claude Code PreToolUse hook: acquires as this session, waits 40 s, exits 2 on refusal",
"android_ui --display self|<id> resolves the session's phone_vd display and takes its package lease; a foreign display is refused (exit 5)",
"_a11y_helper builds a tiny instrumentation APK (javac, d8, aapt2, apksigner; no Gradle), cached by source hash, reading getWindowsOnAllDisplays()",
"The helper source sits flat beside the module (A11yDump.java, a11y_dump_manifest.xml) to respect the two-directory path cap"
],
"verification": [
{"command": "pytest python_pkg/android_ui python_pkg/phone_guard --cov --cov-branch", "result": "pass", "evidence": "152 passed; every android_ui and phone_guard module 100% line and branch"},
{"command": "python3 -c 'from python_pkg.android_ui import _a11y_helper as h; print(h.build())'", "result": "pass", "evidence": "~/.cache/android_ui/a11ydump-fb2baa8888080a3c.apk built with the real SDK"},
{"command": "pre-commit run --files <changed-files>", "result": "pass", "evidence": "all hooks passed"}
],
"risks": ["The hook passes unknown commands: it guards against interrupting other sessions, it is not a sandbox.", "The helper must stay in phone-focus-mode's whitelist or the focus-owner sweep hides it."],
"rollback": ["git revert the commit", "Remove the phone_guard_pretool.sh entry from ~/.claude/settings.json PreToolUse"]
}

View File

@ -1,57 +0,0 @@
{
"intent": "Test ubuntu_perf_fixes.sh to 100% line coverage and take it off the shell coverage allowlist, leaving one untested lib in fixes/lib.",
"scope": [
"linux_configuration/scripts/single_use/fixes/lib/ubuntu_perf_fixes.sh (four test seams)",
"linux_configuration/scripts/single_use/fixes/lib/tests/test_ubuntu_perf_fixes.sh and test_ubuntu_perf_fixes_oom.sh (new)",
"linux_configuration/scripts/single_use/fixes/lib/tests/ubuntu_perf_harness.sh (extended, not created -- it already existed)",
"meta/shell-coverage-allowlist.txt (one line removed)"
],
"changes": [
"Added SYSCTL_DROPIN_DIR, SYSTEMD_UNIT_DIR (twice) and EARLYOOM_CONF_FILE seams, each defaulting to the real path so production behaviour is unchanged",
"Deliberately left the add_undo lines with their literal /etc paths: the undo script runs on the real system, not in the sandbox, so seaming them would have written a broken undo",
"Extended the existing ubuntu_perf_harness.sh with the three new overrides, three more default stubs (nvidia-smi, nvidia-persistenced, dpkg) and the _t_unit / _t_sysctl_file readers",
"Added 59 assertions over 14 cases across two files covering all four functions",
"Removed ubuntu_perf_fixes.sh from meta/shell-coverage-allowlist.txt, taking it from 86 to 85 entries"
],
"verification": [
{
"command": "shell_coverage_jail.sh --timeout 600s --measure ubuntu_perf_fixes.sh --min 100 --fail-on-case-error",
"result": "pass",
"evidence": "ubuntu_perf_fixes.sh: 81/81 lines = 100.00%, exit 0, no case timeout."
},
{
"command": "shell_coverage_jail.sh --timeout 600s --measure bt_audio.sh --min 100 (canary, with the two new test files present)",
"result": "pass",
"evidence": "bt_audio.sh: 127/127 lines = 100.00%. This is the lib a previous test file silently broke, so it is re-measured after every addition to this suite."
},
{
"command": "bash meta/scripts/check_shell_coverage.sh .../ubuntu_perf_fixes.sh, AFTER deleting its allowlist line",
"result": "pass",
"evidence": "'every checked shell library meets the coverage bar', EXIT 0."
},
{
"command": "ls -la /etc/sysctl.d/99-performance-tuning.conf /etc/default/earlyoom",
"result": "pass",
"evidence": "Both 'No such file or directory' after the suite ran, confirming the seams kept every write inside the throwaway tmpdir."
},
{
"command": "bash linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh",
"result": "pass",
"evidence": "All fixes lib test files passed; 31 + 28 = 59 new assertions, 0 failed."
},
{
"command": "shellcheck + shfmt on the lib, the harness and both test files",
"result": "pass",
"evidence": "No findings, zero suppressions. Lib is 219 lines, under the 250-line cap."
}
],
"risks": [
"fix_earlyoom writes a config on a machine that may already have one; the test asserts the .bak backup is taken before the overwrite, which is the behaviour that makes that safe.",
"The undo lines are asserted to contain the REAL /etc paths, so a future seam applied to them would fail these tests rather than silently producing an undo script that removes nothing.",
"fix_failed_sssd's detection loop breaks at the first failed unit while its masking loop re-checks each one; case 7 covers the single-failure arm specifically, since a naive implementation would mask all five."
],
"rollback": [
"git revert the commit, then re-add the ubuntu_perf_fixes.sh line to meta/shell-coverage-allowlist.txt",
"Re-run check_shell_coverage.sh to confirm the gate passes again with the exemption restored"
]
}

View File

@ -0,0 +1,133 @@
package com.kuhy.a11ydump;
import android.accessibilityservice.AccessibilityServiceInfo;
import android.app.Activity;
import android.app.Instrumentation;
import android.app.UiAutomation;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.SparseArray;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
import java.util.List;
/**
* Dumps one display's accessibility tree as uiautomator-style XML.
*
* <p>Run: {@code am instrument -w -e display <id> com.kuhy.a11ydump/.A11yDump}. The
* XML comes back on one line as the {@code xml} result key, in the same shape
* {@code uiautomator dump} writes, so the Python side parses both with one
* parser. Coordinates are the display's own, which is what {@code input -d}
* expects.
*/
public final class A11yDump extends Instrumentation {
private static final int ATTEMPTS = 15;
private static final long RETRY_MS = 200;
private Bundle arguments;
@Override
public void onCreate(Bundle args) {
arguments = args;
start();
}
@Override
public void onStart() {
Bundle out = new Bundle();
try {
int display = Integer.parseInt(arguments.getString("display", "0"));
UiAutomation automation = getUiAutomation(
UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES);
AccessibilityServiceInfo info = automation.getServiceInfo();
info.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
| AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
| AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS;
automation.setServiceInfo(info);
out.putString("xml", dump(automation, display));
finish(Activity.RESULT_OK, out);
} catch (Throwable t) {
out.putString("error", String.valueOf(t));
finish(Activity.RESULT_CANCELED, out);
}
}
private static String dump(UiAutomation automation, int display) {
// Windows appear a beat after the service info changes; an empty
// list on the first read means "not yet", not "nothing there".
List<AccessibilityWindowInfo> windows = null;
for (int i = 0; i < ATTEMPTS; i++) {
SparseArray<List<AccessibilityWindowInfo>> all =
automation.getWindowsOnAllDisplays();
windows = all.get(display);
if (windows != null && !windows.isEmpty()) break;
SystemClock.sleep(RETRY_MS);
}
StringBuilder xml = new StringBuilder(
"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>"
+ "<hierarchy rotation=\"0\" display=\"" + display + "\">");
if (windows != null) {
// Bottom layer first, like uiautomator, so a dialog's nodes come
// after (and so win over) the screen it covers.
for (int w = windows.size() - 1; w >= 0; w--) {
AccessibilityNodeInfo root = windows.get(w).getRoot();
if (root != null) node(root, 0, xml);
}
}
return xml.append("</hierarchy>").toString();
}
private static void node(AccessibilityNodeInfo n, int index, StringBuilder xml) {
Rect r = new Rect();
n.getBoundsInScreen(r);
xml.append("<node index=\"").append(index).append('"');
attr(xml, "text", n.getText());
attr(xml, "resource-id", n.getViewIdResourceName());
attr(xml, "class", n.getClassName());
attr(xml, "package", n.getPackageName());
attr(xml, "content-desc", n.getContentDescription());
attr(xml, "hint", n.getHintText());
flag(xml, "checkable", n.isCheckable());
flag(xml, "checked", n.isChecked());
flag(xml, "clickable", n.isClickable());
flag(xml, "enabled", n.isEnabled());
flag(xml, "focusable", n.isFocusable());
flag(xml, "focused", n.isFocused());
flag(xml, "scrollable", n.isScrollable());
flag(xml, "password", n.isPassword());
flag(xml, "selected", n.isSelected());
xml.append(" bounds=\"[").append(r.left).append(',').append(r.top)
.append("][").append(r.right).append(',').append(r.bottom)
.append("]\">");
for (int i = 0; i < n.getChildCount(); i++) {
AccessibilityNodeInfo child = n.getChild(i);
if (child != null) node(child, i, xml);
}
xml.append("</node>");
}
private static void flag(StringBuilder xml, String name, boolean value) {
xml.append(' ').append(name).append("=\"").append(value).append('"');
}
private static void attr(StringBuilder xml, String name, CharSequence value) {
xml.append(' ').append(name).append("=\"");
if (value != null) {
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '&': xml.append("&amp;"); break;
case '<': xml.append("&lt;"); break;
case '>': xml.append("&gt;"); break;
case '"': xml.append("&quot;"); break;
// Newlines would split the one-line result `am` prints.
case '\n': xml.append("&#10;"); break;
case '\r': xml.append("&#13;"); break;
default: xml.append(c);
}
}
}
xml.append('"');
}
}

View File

@ -90,9 +90,30 @@ ui.wait_for("Connected.", timeout=30)
## One phone, many sessions ## One phone, many sessions
Every command first takes this session's lease on the phone Sessions share the phone by working on separate displays. By default every
(`python_pkg.phone_lease`): the owner is the nearest ancestor `claude` command drives the real screen (display 0) and takes this session's
process, the lease lasts 180 s past the last call, and a call from another `pkg:__screen__` lease (`python_pkg.phone_lease`: owner = the nearest
ancestor `claude` process, 180 s past the last call). A call from another
session waits up to 60 s, then exits 3 naming the holder. Do not retry in a session waits up to 60 s, then exits 3 naming the holder. Do not retry in a
loop — use the phone's virtual display (`~/.claude/scripts/phone_vd.sh`) or loop.
the headless emulator (`~/.claude/scripts/phone_emu.sh`), or ask the user.
`--display self` drives this session's virtual display instead
(`~/.claude/scripts/phone_vd.sh start <package>` creates it) and takes only
that app's lease, so several sessions can act at once:
android-ui --display self dump
android-ui --display self tap "Save"
`uiautomator dump` sees display 0 only, so on a virtual display the tree
comes from the `com.kuhy.a11ydump` instrumentation helper (`A11yDump.java`,
`_a11y_helper.py`): built once into `~/.cache/android_ui/` and installed
with `adb install -r -t` on first use. Its package must stay in
phone-focus-mode's whitelist, or the focus-owner sweep hides it. Every
`input` call carries `-d` (0 for the real screen), because input without it
goes to whichever display last took focus. A numeric `--display` that is
another session's virtual display is refused.
Raw `adb` from a Claude Bash call is covered too: the
`~/.claude/hooks/phone_guard_pretool.sh` hook (`python_pkg.phone_guard`)
takes the matching lease or blocks the call. See the `phone-deploy` skill,
section 3, for the full rules.

View File

@ -0,0 +1,225 @@
"""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
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)
def dump_display(run: AdbRun, display: int) -> str:
"""Return ``display``'s accessibility tree as uiautomator-style XML."""
ensure_installed(run)
output = run(
"shell",
"am",
"instrument",
"-w",
"-e",
"display",
str(display),
_RUNNER,
timeout=45.0,
)
return parse_result(output)

View File

@ -33,6 +33,10 @@ class TextEntryMixin(ABC):
def _run(self, *args: str, timeout: float = 30.0) -> str: def _run(self, *args: str, timeout: float = 30.0) -> str:
"""Supplied by the concrete driver: run one adb command.""" """Supplied by the concrete driver: run one adb command."""
@abstractmethod
def _input(self, *args: str) -> str:
"""Supplied by the concrete driver: ``input`` on the driven display."""
@abstractmethod @abstractmethod
def dump(self, *, retries: int = 4) -> list[UiElement]: def dump(self, *, retries: int = 4) -> list[UiElement]:
"""Supplied by the concrete driver: read the current UI tree.""" """Supplied by the concrete driver: read the current UI tree."""
@ -49,9 +53,9 @@ class TextEntryMixin(ABC):
return return
# KEYCODE_MOVE_END (123) then a run of deletes is more reliable across # KEYCODE_MOVE_END (123) then a run of deletes is more reliable across
# IMEs than CTRL+A, which not every keyboard honours. # IMEs than CTRL+A, which not every keyboard honours.
self._run("shell", "input", "keyevent", "123") self._input("keyevent", "123")
for _ in range(length + 2): for _ in range(length + 2):
self._run("shell", "input", "keyevent", "67") self._input("keyevent", "67")
time.sleep(self._settle) time.sleep(self._settle)
def editable_fields(self) -> list[UiElement]: def editable_fields(self) -> list[UiElement]:
@ -78,14 +82,14 @@ class TextEntryMixin(ABC):
target = fields[index] target = fields[index]
before = target.text before = target.text
x, y = target.center x, y = target.center
self._run("shell", "input", "tap", str(x), str(y)) self._input("tap", str(x), str(y))
time.sleep(max(self._settle, 0.8)) time.sleep(max(self._settle, 0.8))
# REPLACE, don't append. `input text` inserts at the cursor, so typing # REPLACE, don't append. `input text` inserts at the cursor, so typing
# into a field that already holds something silently concatenates -- # into a field that already holds something silently concatenates --
# producing e.g. "old@example.comnew@example.com", which is accepted by # producing e.g. "old@example.comnew@example.com", which is accepted by
# the widget, passes a "did the text change?" check, and is wrong. # the widget, passes a "did the text change?" check, and is wrong.
self._clear_focused_field(len(before)) self._clear_focused_field(len(before))
self._run("shell", "input", "text", _escape(text)) self._input("text", _escape(text))
time.sleep(self._settle) time.sleep(self._settle)
after = self.editable_fields() after = self.editable_fields()
changed = any( changed = any(
@ -118,7 +122,7 @@ class TextEntryMixin(ABC):
for keyevent in ("111", "4"): for keyevent in ("111", "4"):
if not self.keyboard_is_up(): if not self.keyboard_is_up():
return return
self._run("shell", "input", "keyevent", keyevent) self._input("keyevent", keyevent)
time.sleep(max(self._settle, 0.6)) time.sleep(max(self._settle, 0.6))
if self.keyboard_is_up(): if self.keyboard_is_up():
msg = ( msg = (

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A test-only APK with no app code of its own: `am instrument` runs A11yDump
with a UiAutomation, which can read the accessibility tree of every
display, virtual ones included (`uiautomator dump` sees display 0 only). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.kuhy.a11ydump">
<uses-sdk android:minSdkVersion="30" android:targetSdkVersion="36" />
<application android:label="a11ydump" android:hasCode="true" />
<instrumentation
android:name="com.kuhy.a11ydump.A11yDump"
android:targetPackage="com.kuhy.a11ydump" />
</manifest>

View File

@ -9,6 +9,7 @@ successful no-op instead of guessing from a screenshot.
android-ui tap "Connect Firebase" android-ui tap "Connect Firebase"
android-ui type "Sync account email" kuhy@example.com android-ui type "Sync account email" kuhy@example.com
android-ui wait "Connected to Firebase." --timeout 30 android-ui wait "Connected to Firebase." --timeout 30
android-ui --display self tap "Save" # this session's phone_vd display
""" """
from __future__ import annotations from __future__ import annotations
@ -17,7 +18,24 @@ import argparse
import sys import sys
from python_pkg.android_ui.driver import AndroidUi, UiAutomationError from python_pkg.android_ui.driver import AndroidUi, UiAutomationError
from python_pkg.phone_lease import PhoneBusyError, acquire from python_pkg.phone_guard.vd_state import display_owner, own_display
from python_pkg.phone_lease import (
NoPhoneError,
PhoneBusyError,
acquire,
owner_id,
resolve_serial,
scope_of,
)
# Exit codes shared with phone_lease: 3 held elsewhere, 4 no phone.
_EXIT_BUSY = 3
_EXIT_NO_PHONE = 4
_EXIT_BAD_DISPLAY = 5
class _TargetError(RuntimeError):
"""The requested display cannot be driven by this session."""
def _build_parser() -> argparse.ArgumentParser: def _build_parser() -> argparse.ArgumentParser:
@ -27,6 +45,11 @@ def _build_parser() -> argparse.ArgumentParser:
description="Drive an Android app by element, never by coordinates.", description="Drive an Android app by element, never by coordinates.",
) )
parser.add_argument("-s", "--serial", help="target device serial") parser.add_argument("-s", "--serial", help="target device serial")
parser.add_argument(
"--display",
help="drive a virtual display instead of the real screen: its logical "
"id, or 'self' for the one phone_vd.sh started for this session",
)
parser.add_argument( parser.add_argument(
"--exact", "--exact",
action="store_true", action="store_true",
@ -56,16 +79,43 @@ def _build_parser() -> argparse.ArgumentParser:
return parser return parser
def _lease_phone(serial: str | None, command: str) -> bool: def _target(serial: str, requested: str | None) -> tuple[int | None, str]:
"""Refresh this session's lease on the phone; False when another holds it. """Return (display, lease scope) for ``--display``.
Every command goes through here first, so a second session's taps wait The real screen is shared, so it takes the screen lease. A virtual display
instead of landing in whatever this one has in front (2026-09-20: a must be this session's own (another session's app would be driven blind)
sandbox tap opened another session's manga reader). Serial-less calls and takes the lease on its app instead, which is what lets two sessions
share one key: adb picks one device anyway. work at the same time.
"""
if requested is None or requested == "0":
return None, scope_of() # the real screen: pkg:__screen__
mine = own_display(serial, owner_id())
if requested == "self":
if mine is None:
msg = "no virtual display for this session: run phone_vd.sh start <package>"
raise _TargetError(msg)
return mine.display, scope_of(mine.package)
if not requested.isdigit():
msg = f"--display takes a display id or 'self', not {requested!r}"
raise _TargetError(msg)
display = int(requested)
if mine is None or mine.display != display:
other = display_owner(serial, display)
whose = f"owned by {other.owner_dir}" if other else "not this session's"
msg = f"display {display} is {whose}; drive only your own (--display self)"
raise _TargetError(msg)
return display, scope_of(mine.package)
def _lease_phone(serial: str, command: str, scope: str) -> bool:
"""Refresh this session's lease; False when another session holds it.
Keyed by the resolved serial, never a placeholder: serial-less calls used
to lease "default" while phone_deploy leased the real serial, so neither
saw the other and one session's tap landed in another's app (2026-09-25).
""" """
try: try:
acquire(serial or "default", f"android_ui {command}") acquire(serial, f"android_ui {command}", scope=scope)
except PhoneBusyError as exc: except PhoneBusyError as exc:
sys.stderr.write(f"android-ui: {exc}\n") sys.stderr.write(f"android-ui: {exc}\n")
return False return False
@ -97,10 +147,19 @@ def _run(ui: AndroidUi, args: argparse.Namespace) -> None:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
"""Run the CLI. Returns a process exit code.""" """Run the CLI. Returns a process exit code."""
args = _build_parser().parse_args(argv) args = _build_parser().parse_args(argv)
if not _lease_phone(args.serial, args.command):
return 3
try: try:
_run(AndroidUi(serial=args.serial), args) serial = resolve_serial(args.serial)
display, scope = _target(serial, args.display)
except NoPhoneError as exc:
sys.stderr.write(f"android-ui: {exc}\n")
return _EXIT_NO_PHONE
except _TargetError as exc:
sys.stderr.write(f"android-ui: {exc}\n")
return _EXIT_BAD_DISPLAY
if not _lease_phone(serial, args.command, scope):
return _EXIT_BUSY
try:
_run(AndroidUi(serial=serial, display=display), args)
except UiAutomationError as exc: except UiAutomationError as exc:
# Loud and non-zero on purpose: a silent no-op here is exactly the # Loud and non-zero on purpose: a silent no-op here is exactly the
# failure this package exists to eliminate. The message already names # failure this package exists to eliminate. The message already names

View File

@ -8,6 +8,7 @@ import subprocess
import tempfile import tempfile
import time import time
from python_pkg.android_ui import _a11y_helper
from python_pkg.android_ui._elements import ( from python_pkg.android_ui._elements import (
AmbiguousElementError, AmbiguousElementError,
ElementNotFoundError, ElementNotFoundError,
@ -38,11 +39,19 @@ class AndroidUi(TextEntryMixin):
*, *,
adb: str = "adb", adb: str = "adb",
settle_seconds: float = 0.4, settle_seconds: float = 0.4,
display: int | None = None,
) -> None: ) -> None:
"""Create a driver. ``serial`` targets one of several devices.""" """Create a driver. ``serial`` targets one of several devices.
``display`` drives one display (a session's virtual display from
``phone_vd.sh``) instead of the real screen: input goes to it with
``input -d`` and its tree comes from the a11y helper, because
``uiautomator dump`` cannot see any display but 0.
"""
self._adb = adb self._adb = adb
self._serial = serial self._serial = serial
self._settle = settle_seconds self._settle = settle_seconds
self._display = display
def _run(self, *args: str, timeout: float = 30.0) -> str: def _run(self, *args: str, timeout: float = 30.0) -> str:
"""Run an adb command, returning stdout.""" """Run an adb command, returning stdout."""
@ -69,6 +78,17 @@ class AndroidUi(TextEntryMixin):
raise UiAutomationError(msg) raise UiAutomationError(msg)
return done.stdout return done.stdout
def _input(self, *args: str) -> str:
"""Run ``input`` on the display this driver targets.
Always with ``-d``, 0 meaning the real screen: without it, key and
text events go to whichever display last had focus, and a session's
virtual display takes focus the moment its app launches -- so another
session's typing lands in that app.
"""
display = 0 if self._display is None else self._display
return self._run("shell", "input", "-d", str(display), *args)
# ── Reading the screen ──────────────────────────────────────────────── # ── Reading the screen ────────────────────────────────────────────────
def dump(self, *, retries: int = 4) -> list[UiElement]: def dump(self, *, retries: int = 4) -> list[UiElement]:
@ -96,6 +116,8 @@ class AndroidUi(TextEntryMixin):
def _dump_once(self) -> list[UiElement]: def _dump_once(self) -> list[UiElement]:
"""Pull one accessibility-tree snapshot.""" """Pull one accessibility-tree snapshot."""
if self._display is not None:
return _parse_tree(_a11y_helper.dump_display(self._run, self._display))
self._run("shell", "uiautomator", "dump", _REMOTE_DUMP, timeout=45.0) self._run("shell", "uiautomator", "dump", _REMOTE_DUMP, timeout=45.0)
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
local = Path(tmp) / "ui.xml" local = Path(tmp) / "ui.xml"
@ -161,7 +183,7 @@ class AndroidUi(TextEntryMixin):
self.dismiss_keyboard() self.dismiss_keyboard()
element = self.wait_for(query, timeout=timeout, exact=exact) element = self.wait_for(query, timeout=timeout, exact=exact)
x, y = element.center x, y = element.center
self._run("shell", "input", "tap", str(x), str(y)) self._input("tap", str(x), str(y))
time.sleep(self._settle) time.sleep(self._settle)
return element return element
@ -182,10 +204,10 @@ class AndroidUi(TextEntryMixin):
element = self.wait_for(query, timeout=timeout, exact=exact) element = self.wait_for(query, timeout=timeout, exact=exact)
before = element.text before = element.text
x, y = element.center x, y = element.center
self._run("shell", "input", "tap", str(x), str(y)) self._input("tap", str(x), str(y))
# The field has to actually take focus before keystrokes mean anything. # The field has to actually take focus before keystrokes mean anything.
time.sleep(max(self._settle, 0.8)) time.sleep(max(self._settle, 0.8))
self._run("shell", "input", "text", _escape(text)) self._input("text", _escape(text))
time.sleep(self._settle) time.sleep(self._settle)
for candidate in self.dump(): for candidate in self.dump():

View File

@ -0,0 +1,85 @@
"""Shared fixtures and a scripted adb stand-in for the android_ui tests."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
MOD = "python_pkg.android_ui.cli"
@pytest.fixture
def phone() -> Iterator[None]:
"""One resolved phone, and no virtual display unless a test adds one."""
with (
patch(f"{MOD}.resolve_serial", side_effect=lambda s=None: s or "SER1"),
patch(f"{MOD}.owner_id", return_value="claude:1"),
patch(f"{MOD}.own_display", return_value=None),
patch(f"{MOD}.display_owner", return_value=None),
):
yield
def node(text: str = "", **attrs: str) -> str:
"""Render one ``<node>`` element for a fake dump.
``attrs`` accepts ``desc``, ``hint``, ``res``, ``cls``, ``bounds``,
``enabled`` and ``focused``; each falls back to a representative default.
"""
return (
f'<node text="{text}" content-desc="{attrs.get("desc", "")}" '
f'hint="{attrs.get("hint", "")}" '
f'resource-id="{attrs.get("res", "")}" '
f'class="{attrs.get("cls", "android.widget.TextView")}" '
f'bounds="{attrs.get("bounds", "[0,0][100,50]")}" '
f'enabled="{attrs.get("enabled", "true")}" '
f'focused="{attrs.get("focused", "false")}"/>'
)
def tree(*nodes: str) -> str:
"""Wrap ``nodes`` in a hierarchy document."""
return f"<?xml version='1.0'?><hierarchy rotation='0'>{''.join(nodes)}</hierarchy>"
class FakeDevice:
"""Scripted stand-in for adb, recording every command it is given."""
def __init__(self, trees: list[str] | None = None) -> None:
self.trees = trees or [tree(node("Connect"))]
self.calls: list[tuple[str, ...]] = []
self.keyboard_shown = False
self.pulled = 0
def run(self, *args: str, timeout: float = 30.0) -> str:
"""Answer an adb invocation."""
del timeout
self.calls.append(args)
if args[:2] == ("shell", "dumpsys") and args[2] == "input_method":
return f"mInputShown={'true' if self.keyboard_shown else 'false'}"
if args[:2] == ("shell", "dumpsys") and args[2] == "window":
return "mCurrentFocus=Window{ab12 u0 com.example/com.example.Main}"
if args[0] == "pull":
index = min(self.pulled, len(self.trees) - 1)
self.pulled += 1
Path(args[2]).write_text(self.trees[index], encoding="utf-8")
if args[:5] == ("shell", "input", "-d", "0", "keyevent") and args[5] in {
"111",
"4",
}:
self.keyboard_shown = False
return ""
def taps(self) -> list[tuple[int, int]]:
"""Return every tap coordinate, in order."""
return [
(int(c[5]), int(c[6]))
for c in self.calls
if c[:5] == ("shell", "input", "-d", "0", "tap")
]

View File

@ -0,0 +1,205 @@
"""Tests for the a11y dump helper: build caching, install, result parsing.
The SDK tools are faked: each fake writes the file the real tool would, so the
build pipeline's wiring (what feeds what, what gets cached) is what is tested.
The real build and a real dump were verified on the Pixel 6a on 2026-09-25.
"""
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import zipfile
import pytest
from python_pkg.android_ui import _a11y_helper as helper
from python_pkg.android_ui._elements import UiAutomationError
if TYPE_CHECKING:
from collections.abc import Iterator
MOD = "python_pkg.android_ui._a11y_helper"
@pytest.fixture
def source(tmp_path: Path) -> Iterator[Path]:
"""A tiny helper source: one Java file and its manifest."""
java = tmp_path / "A11yDump.java"
java.write_text("class A11yDump {}")
manifest = tmp_path / "a11y_dump_manifest.xml"
manifest.write_text("<manifest/>")
with patch(f"{MOD}._JAVA", java), patch(f"{MOD}._MANIFEST", manifest):
yield tmp_path
@pytest.fixture
def sdk(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A fake SDK layout, with the cache and HOME inside tmp_path."""
root = tmp_path / "sdk"
for d in (
"build-tools/35.0.0",
"build-tools/36.0.0",
"platforms/android-9",
"platforms/android-36",
"platforms/android-37.2",
):
(root / d).mkdir(parents=True)
monkeypatch.setenv("ANDROID_HOME", str(root))
monkeypatch.setenv("ANDROID_UI_CACHE", str(tmp_path / "cache"))
monkeypatch.setenv("HOME", str(tmp_path / "home"))
return root
def fake_tool(calls: list[list[str]]) -> MagicMock:
"""subprocess.run stand-in that produces each tool's output file."""
def run(cmd: list[str], **_: object) -> subprocess.CompletedProcess[str]:
calls.append(cmd)
name = Path(cmd[0]).name
if name == "d8":
out = Path(cmd[cmd.index("--output") + 1])
(out / "classes.dex").write_bytes(b"dex")
elif name == "aapt2":
with zipfile.ZipFile(cmd[cmd.index("-o") + 1], "w") as zf:
zf.writestr("AndroidManifest.xml", "m")
elif name == "zipalign":
shutil.copy(cmd[-2], cmd[-1])
elif name == "apksigner":
shutil.copy(cmd[-1], cmd[cmd.index("--out") + 1])
elif name == "keytool":
Path(cmd[cmd.index("-keystore") + 1]).parent.mkdir(parents=True)
Path(cmd[cmd.index("-keystore") + 1]).write_bytes(b"ks")
return subprocess.CompletedProcess(cmd, 0, "", "")
return MagicMock(side_effect=run)
class TestIdentity:
def test_hash_follows_the_source(self, source: Path) -> None:
first = helper.source_hash()
assert first == helper.source_hash()
(source / "a11y_dump_manifest.xml").write_text("<manifest x=''/>")
assert helper.source_hash() != first
def test_cache_dir_defaults_under_home(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("ANDROID_UI_CACHE", raising=False)
assert helper.cache_dir() == Path.home() / ".cache" / "android_ui"
class TestSdk:
def test_env_wins(self, sdk: Path) -> None:
assert helper.sdk_root() == sdk
def test_falls_back_to_home_sdk(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("ANDROID_HOME", raising=False)
monkeypatch.setenv("ANDROID_SDK_ROOT", str(tmp_path / "missing"))
fallback = tmp_path / "sdk" / "Android" / "Sdk"
fallback.mkdir(parents=True)
with patch(f"{MOD}.Path.home", return_value=tmp_path):
assert helper.sdk_root() == fallback
def test_no_sdk_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("ANDROID_HOME", raising=False)
monkeypatch.delenv("ANDROID_SDK_ROOT", raising=False)
with (
patch(f"{MOD}.Path.home", return_value=tmp_path),
pytest.raises(UiAutomationError, match="no Android SDK"),
):
helper.sdk_root()
def test_build_tool_is_in_the_newest_build_tools(self, sdk: Path) -> None:
assert helper.build_tool("aapt2") == sdk / "build-tools" / "36.0.0" / "aapt2"
def test_newest_version_wins_and_empty_raises(self, sdk: Path) -> None:
assert helper._newest(sdk / "build-tools").name == "36.0.0"
assert helper._newest(sdk / "platforms", "android-").name == "android-37.2"
with pytest.raises(UiAutomationError, match="nothing matching"):
helper._newest(sdk / "platforms", "nope-")
class TestBuild:
def test_builds_signs_and_caches(self, source: Path, sdk: Path) -> None:
del source, sdk
calls: list[list[str]] = []
with patch(f"{MOD}.subprocess.run", fake_tool(calls)):
apk = helper.build()
assert apk.name == f"a11ydump-{helper.source_hash()}.apk"
with zipfile.ZipFile(apk) as zf:
assert "classes.dex" in zf.namelist()
tools = [Path(c[0]).name for c in calls]
assert tools == ["javac", "d8", "aapt2", "zipalign", "keytool", "apksigner"]
assert f"{Path.home()}/.android/debug.keystore" in calls[4]
calls.clear()
assert helper.build() == apk
assert calls == [], "a cached build runs no tool"
def test_an_existing_keystore_is_reused(self, source: Path, sdk: Path) -> None:
del source, sdk
keystore = Path.home() / ".android" / "debug.keystore"
keystore.parent.mkdir(parents=True)
keystore.write_bytes(b"ks")
calls: list[list[str]] = []
with patch(f"{MOD}.subprocess.run", fake_tool(calls)):
helper.build()
assert "keytool" not in [Path(c[0]).name for c in calls]
def test_a_failing_tool_is_reported(self, source: Path, sdk: Path) -> None:
del source, sdk
failed = subprocess.CompletedProcess(["javac"], 1, "", "boom")
with (
patch(f"{MOD}.subprocess.run", return_value=failed),
pytest.raises(UiAutomationError, match=r"javac\nboom"),
):
helper.build()
class TestDevice:
def test_skips_install_when_the_phone_has_this_build(self, source: Path) -> None:
del source
run = MagicMock(return_value=f"versionName={helper.source_hash()}\n")
helper.ensure_installed(run)
run.assert_called_once_with("shell", "dumpsys", "package", helper.PACKAGE)
def test_installs_when_missing_or_stale(self, source: Path, tmp_path: Path) -> None:
del source
run = MagicMock(return_value="versionName=old\n")
with patch(f"{MOD}.build", return_value=tmp_path / "a.apk"):
helper.ensure_installed(run)
run.assert_called_with(
"install", "-r", "-t", str(tmp_path / "a.apk"), timeout=120.0
)
def test_parse_result(self) -> None:
out = "INSTRUMENTATION_RESULT: xml=<hierarchy/>\nINSTRUMENTATION_CODE: -1\n"
assert helper.parse_result(out) == "<hierarchy/>"
with pytest.raises(UiAutomationError, match=r"failed: java\.lang\.X"):
helper.parse_result("INSTRUMENTATION_RESULT: error=java.lang.X\n")
with pytest.raises(UiAutomationError, match="no tree"):
helper.parse_result("INSTRUMENTATION_STATUS: nothing\n")
def test_dump_display(self) -> None:
run = MagicMock(return_value="INSTRUMENTATION_RESULT: xml=<h/>\n")
with patch(f"{MOD}.ensure_installed") as ensure:
assert helper.dump_display(run, 9) == "<h/>"
ensure.assert_called_once_with(run)
run.assert_called_once_with(
"shell",
"am",
"instrument",
"-w",
"-e",
"display",
"9",
"com.kuhy.a11ydump/.A11yDump",
timeout=45.0,
)

View File

@ -17,12 +17,15 @@ from python_pkg.android_ui.driver import (
ElementNotFoundError, ElementNotFoundError,
UiElement, UiElement,
) )
from python_pkg.phone_lease import PhoneBusyError from python_pkg.phone_lease import NoPhoneError, PhoneBusyError
SCREEN_SCOPE = "pkg:__screen__"
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
MOD = "python_pkg.android_ui.cli" MOD = "python_pkg.android_ui.cli"
pytestmark = pytest.mark.usefixtures("phone")
def _element(label: str = "Connect Firebase") -> UiElement: def _element(label: str = "Connect Firebase") -> UiElement:
@ -48,15 +51,22 @@ def ui() -> Iterator[MagicMock]:
class TestLease: class TestLease:
def test_every_command_refreshes_the_lease_first(self) -> None: def test_every_command_refreshes_the_screen_lease_first(self) -> None:
with patch(f"{MOD}.AndroidUi"), patch(f"{MOD}.acquire") as acquire: with patch(f"{MOD}.AndroidUi") as factory, patch(f"{MOD}.acquire") as acquire:
assert cli.main(["-s", "SER1", "focus"]) == 0 assert cli.main(["-s", "SER2", "focus"]) == 0
acquire.assert_called_once_with("SER1", "android_ui focus") acquire.assert_called_once_with("SER2", "android_ui focus", scope=SCREEN_SCOPE)
factory.assert_called_once_with(serial="SER2", display=None)
def test_serial_less_calls_share_one_key(self) -> None: def test_serial_less_calls_lease_the_resolved_serial(self) -> None:
# Leasing a "default" key let two sessions drive one phone blind.
with patch(f"{MOD}.AndroidUi"), patch(f"{MOD}.acquire") as acquire: with patch(f"{MOD}.AndroidUi"), patch(f"{MOD}.acquire") as acquire:
cli.main(["focus"]) cli.main(["focus"])
assert acquire.call_args.args[0] == "default" assert acquire.call_args.args[0] == "SER1"
def test_no_phone_exits_4(self, capsys: pytest.CaptureFixture[str]) -> None:
with patch(f"{MOD}.resolve_serial", side_effect=NoPhoneError("none")):
assert cli.main(["focus"]) == 4
assert "none" in capsys.readouterr().err
def test_a_foreign_lease_stops_the_command( def test_a_foreign_lease_stops_the_command(
self, capsys: pytest.CaptureFixture[str] self, capsys: pytest.CaptureFixture[str]

View File

@ -0,0 +1,83 @@
"""Tests for ``android_ui --display``: which display a call may drive.
The real screen is shared, so it takes the screen lease; a session's own
virtual display takes only its app's lease; anyone else's display is refused
before the driver or a lease is touched.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from python_pkg.android_ui import cli
from python_pkg.phone_guard.vd_state import SessionDisplay
MOD = "python_pkg.android_ui.cli"
SCREEN_SCOPE = "pkg:__screen__"
pytestmark = pytest.mark.usefixtures("phone")
def _mine(display: int = 9, package: str = "dev.kuhy.todo") -> SessionDisplay:
return SessionDisplay("claude_1", display, "sf", package)
class TestDisplay:
def test_self_drives_this_sessions_display_under_its_app_lease(self) -> None:
with (
patch(f"{MOD}.own_display", return_value=_mine()),
patch(f"{MOD}.AndroidUi") as factory,
patch(f"{MOD}.acquire") as acquire,
):
assert cli.main(["--display", "self", "focus"]) == 0
factory.assert_called_once_with(serial="SER1", display=9)
assert acquire.call_args.kwargs["scope"] == "pkg:dev.kuhy.todo"
def test_an_explicit_id_of_ones_own_display_works(self) -> None:
with (
patch(f"{MOD}.own_display", return_value=_mine()),
patch(f"{MOD}.AndroidUi") as factory,
patch(f"{MOD}.acquire"),
):
assert cli.main(["--display", "9", "focus"]) == 0
assert factory.call_args.kwargs["display"] == 9
def test_display_0_is_the_real_screen(self) -> None:
with patch(f"{MOD}.AndroidUi") as factory, patch(f"{MOD}.acquire") as acquire:
assert cli.main(["--display", "0", "focus"]) == 0
assert factory.call_args.kwargs["display"] is None
assert acquire.call_args.kwargs["scope"] == SCREEN_SCOPE
@pytest.mark.parametrize(
("requested", "mine", "other", "needle"),
[
("self", None, None, "phone_vd.sh start"),
("abc", None, None, "display id or 'self'"),
("7", None, None, "not this session's"),
(
"7",
_mine(9),
SessionDisplay("claude_2", 7, "sf", "x"),
"owned by claude_2",
),
],
)
def test_refuses_what_is_not_this_sessions(
self,
capsys: pytest.CaptureFixture[str],
requested: str,
mine: SessionDisplay | None,
other: SessionDisplay | None,
needle: str,
) -> None:
with (
patch(f"{MOD}.own_display", return_value=mine),
patch(f"{MOD}.display_owner", return_value=other),
patch(f"{MOD}.AndroidUi") as factory,
patch(f"{MOD}.acquire") as acquire,
):
assert cli.main(["--display", requested, "focus"]) == 5
factory.assert_not_called()
acquire.assert_not_called()
assert needle in capsys.readouterr().err

View File

@ -11,72 +11,18 @@ because each of them looked like success at the time:
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import NamedTuple from typing import NamedTuple
import pytest import pytest
from python_pkg.android_ui import driver as drv from python_pkg.android_ui import driver as drv
from python_pkg.android_ui.tests.conftest import FakeDevice
from python_pkg.android_ui.tests.conftest import node as _node
from python_pkg.android_ui.tests.conftest import tree as _tree
MOD = "python_pkg.android_ui.driver" MOD = "python_pkg.android_ui.driver"
def _node(text: str = "", **attrs: str) -> str:
"""Render one ``<node>`` element for a fake dump.
``attrs`` accepts ``desc``, ``hint``, ``res``, ``cls``, ``bounds``,
``enabled`` and ``focused``; each falls back to a representative default.
"""
return (
f'<node text="{text}" content-desc="{attrs.get("desc", "")}" '
f'hint="{attrs.get("hint", "")}" '
f'resource-id="{attrs.get("res", "")}" '
f'class="{attrs.get("cls", "android.widget.TextView")}" '
f'bounds="{attrs.get("bounds", "[0,0][100,50]")}" '
f'enabled="{attrs.get("enabled", "true")}" '
f'focused="{attrs.get("focused", "false")}"/>'
)
def _tree(*nodes: str) -> str:
"""Wrap ``nodes`` in a hierarchy document."""
return f"<?xml version='1.0'?><hierarchy rotation='0'>{''.join(nodes)}</hierarchy>"
class FakeDevice:
"""Scripted stand-in for adb, recording every command it is given."""
def __init__(self, trees: list[str] | None = None) -> None:
self.trees = trees or [_tree(_node("Connect"))]
self.calls: list[tuple[str, ...]] = []
self.keyboard_shown = False
self.pulled = 0
def run(self, *args: str, timeout: float = 30.0) -> str:
"""Answer an adb invocation."""
del timeout
self.calls.append(args)
if args[:2] == ("shell", "dumpsys") and args[2] == "input_method":
return f"mInputShown={'true' if self.keyboard_shown else 'false'}"
if args[:2] == ("shell", "dumpsys") and args[2] == "window":
return "mCurrentFocus=Window{ab12 u0 com.example/com.example.Main}"
if args[0] == "pull":
index = min(self.pulled, len(self.trees) - 1)
self.pulled += 1
Path(args[2]).write_text(self.trees[index], encoding="utf-8")
if args[:3] == ("shell", "input", "keyevent") and args[3] in {"111", "4"}:
self.keyboard_shown = False
return ""
def taps(self) -> list[tuple[int, int]]:
"""Return every tap coordinate, in order."""
return [
(int(c[3]), int(c[4]))
for c in self.calls
if c[:3] == ("shell", "input", "tap")
]
class Harness(NamedTuple): class Harness(NamedTuple):
"""A driver plus the fake device backing it. """A driver plus the fake device backing it.
@ -207,7 +153,9 @@ class TestTap:
ui.device.trees = [_tree(_node("Go", bounds="[0,0][100,100]"))] ui.device.trees = [_tree(_node("Go", bounds="[0,0][100,100]"))]
ui.ui.tap("Go") ui.ui.tap("Go")
keyevents = [ keyevents = [
c for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") c
for c in ui.device.calls
if c[:5] == ("shell", "input", "-d", "0", "keyevent")
] ]
assert keyevents, "expected the keyboard to be dismissed first" assert keyevents, "expected the keyboard to be dismissed first"
@ -236,3 +184,26 @@ def test_real_text_outranks_the_hint_once_typed() -> None:
(element,) = drv._parse_tree(_tree(dump)) (element,) = drv._parse_tree(_tree(dump))
assert element.label == "17:30" assert element.label == "17:30"
class TestVirtualDisplay:
def test_reads_the_tree_through_the_helper_and_taps_with_d(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
device = FakeDevice()
instance = drv.AndroidUi(serial="S", display=9, settle_seconds=0.0)
monkeypatch.setattr(instance, "_run", device.run)
monkeypatch.setattr(f"{MOD}.time.sleep", lambda _s: None)
xml = _tree(_node("Go", bounds="[0,0][100,100]"))
calls: list[int] = []
def dump(run: object, display: int) -> str:
del run
calls.append(display)
return xml
monkeypatch.setattr(f"{MOD}._a11y_helper.dump_display", dump)
instance.tap("Go")
assert calls
assert set(calls) == {9}
assert ("shell", "input", "-d", "9", "tap", "50", "50") in device.calls

View File

@ -54,16 +54,19 @@ class FakeDevice:
index = min(self.pulled, len(self.trees) - 1) index = min(self.pulled, len(self.trees) - 1)
self.pulled += 1 self.pulled += 1
Path(args[2]).write_text(self.trees[index], encoding="utf-8") Path(args[2]).write_text(self.trees[index], encoding="utf-8")
if args[:3] == ("shell", "input", "keyevent") and args[3] in {"111", "4"}: if args[:5] == ("shell", "input", "-d", "0", "keyevent") and args[5] in {
"111",
"4",
}:
self.keyboard_shown = False self.keyboard_shown = False
return "" return ""
def taps(self) -> list[tuple[int, int]]: def taps(self) -> list[tuple[int, int]]:
"""Return every tap coordinate, in order.""" """Return every tap coordinate, in order."""
return [ return [
(int(c[3]), int(c[4])) (int(c[5]), int(c[6]))
for c in self.calls for c in self.calls
if c[:3] == ("shell", "input", "tap") if c[:5] == ("shell", "input", "-d", "0", "tap")
] ]

View File

@ -54,16 +54,19 @@ class FakeDevice:
index = min(self.pulled, len(self.trees) - 1) index = min(self.pulled, len(self.trees) - 1)
self.pulled += 1 self.pulled += 1
Path(args[2]).write_text(self.trees[index], encoding="utf-8") Path(args[2]).write_text(self.trees[index], encoding="utf-8")
if args[:3] == ("shell", "input", "keyevent") and args[3] in {"111", "4"}: if args[:5] == ("shell", "input", "-d", "0", "keyevent") and args[5] in {
"111",
"4",
}:
self.keyboard_shown = False self.keyboard_shown = False
return "" return ""
def taps(self) -> list[tuple[int, int]]: def taps(self) -> list[tuple[int, int]]:
"""Return every tap coordinate, in order.""" """Return every tap coordinate, in order."""
return [ return [
(int(c[3]), int(c[4])) (int(c[5]), int(c[6]))
for c in self.calls for c in self.calls
if c[:3] == ("shell", "input", "tap") if c[:5] == ("shell", "input", "-d", "0", "tap")
] ]
@ -98,7 +101,9 @@ class TestKeyboard:
def test_dismiss_is_a_no_op_when_already_closed(self, ui: Harness) -> None: def test_dismiss_is_a_no_op_when_already_closed(self, ui: Harness) -> None:
ui.ui.dismiss_keyboard() ui.ui.dismiss_keyboard()
assert not [ assert not [
c for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") c
for c in ui.device.calls
if c[:5] == ("shell", "input", "-d", "0", "keyevent")
] ]
def test_closes_on_the_second_keyevent_when_escape_is_ignored( def test_closes_on_the_second_keyevent_when_escape_is_ignored(
@ -110,7 +115,9 @@ class TestKeyboard:
monkeypatch.setattr(ui.ui, "keyboard_is_up", lambda: next(states, False)) monkeypatch.setattr(ui.ui, "keyboard_is_up", lambda: next(states, False))
ui.ui.dismiss_keyboard() ui.ui.dismiss_keyboard()
sent = [ sent = [
c[3] for c in ui.device.calls if c[:3] == ("shell", "input", "keyevent") c[5]
for c in ui.device.calls
if c[:5] == ("shell", "input", "-d", "0", "keyevent")
] ]
assert sent == ["111", "4"] assert sent == ["111", "4"]
@ -152,7 +159,7 @@ class TestTyping:
deletes = [ deletes = [
c c
for c in ui.device.calls for c in ui.device.calls
if c[:3] == ("shell", "input", "keyevent") and c[3] == "67" if c[:5] == ("shell", "input", "-d", "0", "keyevent") and c[5] == "67"
] ]
assert deletes, "expected the field to be cleared first" assert deletes, "expected the field to be cleared first"

View File

@ -0,0 +1,9 @@
"""Keep several Claude sessions from interrupting each other on one phone.
Two parts: :mod:`vd_state` answers "which virtual display is whose" from the
state ``phone_vd.sh`` keeps, and :mod:`hook` is the PreToolUse hook that stops
a raw ``adb`` command from touching the real screen (or another session's app)
without the matching :mod:`python_pkg.phone_lease` lease.
"""
from __future__ import annotations

View File

@ -0,0 +1,10 @@
"""``python3 -m python_pkg.phone_guard``: the PreToolUse hook entry point."""
from __future__ import annotations
import sys
from python_pkg.phone_guard.hook import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,18 @@
"""The answer :mod:`classify` gives for one adb call."""
from __future__ import annotations
from dataclasses import dataclass
SCREEN = "screen"
WHOLE_PHONE = "whole-phone"
@dataclass(frozen=True)
class Need:
"""What one adb call needs: a lease scope, a block, or nothing."""
scope: str | None = None # SCREEN, WHOLE_PHONE, or "pkg:<name>"
block: str | None = None # why it may not run at all
display: int | None = None # the virtual display it targets, if any
serial: str | None = None # from ``-s``

View File

@ -0,0 +1,119 @@
"""Per-tool rules for ``adb shell`` commands, for :mod:`classify`.
Split out of ``classify.py`` for the 250-line cap. Each handler takes the
remote command's arguments (after the tool name) and returns the
:class:`~python_pkg.phone_guard.classify.Need` without a serial; the caller
stamps the serial on.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from python_pkg.phone_guard._need import SCREEN, WHOLE_PHONE, Need
if TYPE_CHECKING:
from collections.abc import Callable
_PM_PACKAGE = {
"clear",
"uninstall",
"disable",
"disable-user",
"enable",
"hide",
"unhide",
"suspend",
"unsuspend",
"grant",
"revoke",
}
_IDLE_WRITE = {"force-idle", "step", "unforce", "enable", "disable", "force-inactive"}
# `cmd connectivity set-package-networking-enabled <bool> <package>`
_NETWORKING_PKG_INDEX = 3
def _display_flag(args: list[str], flag: str) -> int | None:
if flag in args:
i = args.index(flag)
if i + 1 < len(args) and args[i + 1].isdigit():
return int(args[i + 1])
return None
# `pm` options that take a value, so the value is not mistaken for the package.
_PM_VALUED = {"--user", "-u"}
def _package_after(args: list[str], verb: str) -> str | None:
rest = args[args.index(verb) + 1 :]
i = 0
while i < len(rest):
if rest[i] in _PM_VALUED:
i += 2
elif rest[i].startswith("-"):
i += 1
else:
return rest[i]
return None
def _am(rest: list[str]) -> Need:
first = rest[0] if rest else ""
if first in {"force-stop", "kill", "stop-app"} and len(rest) > 1:
return Need(scope=f"pkg:{rest[-1]}")
if first in {"start", "start-activity", "startservice", "start-foreground-service"}:
display = _display_flag(rest, "--display")
return Need(display=display) if display else Need(scope=SCREEN)
return Need()
def _pm(rest: list[str]) -> Need:
if not rest or rest[0] not in _PM_PACKAGE:
return Need()
package = _package_after(rest, rest[0])
return Need(scope=f"pkg:{package}" if package else SCREEN)
def _cmd(rest: list[str]) -> Need:
if rest[:2] != ["connectivity", "set-package-networking-enabled"]:
is_set = (
rest[:1] == ["connectivity"]
and rest[1:2] != []
and rest[1].startswith(("set-", "airplane-mode"))
)
return Need(scope=WHOLE_PHONE) if is_set else Need()
package = rest[3] if len(rest) > _NETWORKING_PKG_INDEX else None
return Need(scope=f"pkg:{package}") if package else Need(scope=WHOLE_PHONE)
def _dumpsys(rest: list[str]) -> Need:
writes = (rest[:1] == ["deviceidle"] and rest[1:2] and rest[1] in _IDLE_WRITE) or (
rest[:1] == ["battery"] and rest[1:2] and rest[1] in {"set", "unplug", "reset"}
)
return Need(scope=WHOLE_PHONE) if writes else Need()
def _settings(rest: list[str]) -> Need:
return Need(scope=WHOLE_PHONE) if rest[:1] == ["put"] else Need()
def _wm(rest: list[str]) -> Need:
changes = rest[:1] in (["size"], ["density"]) and len(rest) > 1
return Need(scope=SCREEN) if changes else Need()
REMOTE: dict[str, Callable[[list[str]], Need]] = {
"am": _am,
"pm": _pm,
"cmd": _cmd,
"dumpsys": _dumpsys,
"settings": _settings,
"wm": _wm,
"monkey": lambda _rest: Need(scope=SCREEN),
"svc": lambda _rest: Need(scope=WHOLE_PHONE),
"reboot": lambda _rest: Need(scope=WHOLE_PHONE),
"setprop": lambda _rest: Need(scope=WHOLE_PHONE),
"stop": lambda _rest: Need(scope=WHOLE_PHONE),
"start": lambda _rest: Need(scope=WHOLE_PHONE),
}

View File

@ -0,0 +1,165 @@
"""Decide what a shell command's ``adb`` calls need before they may run.
Pure: no adb, no filesystem. The hook feeds it the Bash tool's command and
applies the answer. Every ``adb`` invocation in the command gets one
:class:`Need`:
* nothing -- reads (``logcat``, ``dumpsys``, ``getprop``, ``pull``,
``screencap``), file transfer, and anything aimed at the caller's own
virtual display;
* a lease on the real screen -- input or launches on display 0;
* a lease on one app -- ``force-stop``, ``install``, per-app network cuts;
* the whole-phone lease -- reboot, Wi-Fi/airplane/Doze/battery tricks,
``settings put``: everything that changes the phone for every session;
* a block -- key/text input without ``-d`` (it goes to whichever display last
took focus, i.e. possibly another session's app), or input aimed at another
session's virtual display.
Unknown commands pass: this guards against interrupting other sessions, it is
not a sandbox.
"""
from __future__ import annotations
import re
import shlex
from python_pkg.phone_guard._need import SCREEN, WHOLE_PHONE, Need
from python_pkg.phone_guard._remote import REMOTE, _display_flag
_SEPARATORS = {";", "&&", "||", "|", "&", "(", ")", "\n"}
_GLOBAL_WITH_ARG = {"-s", "-t", "-H", "-P", "-L"}
_HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n\s*\2\s*(?=\n|$)", re.DOTALL)
_WHOLE_PHONE_ADB = {
"reboot",
"root",
"unroot",
"remount",
"disable-verity",
"enable-verity",
"tcpip",
"usb",
"kill-server",
"sideload",
}
_READ_ADB = {
"devices",
"get-serialno",
"get-state",
"version",
"help",
"logcat",
"pull",
"push",
"wait-for-device",
"forward",
"reverse",
"bugreport",
"start-server",
"sync",
}
_KEYS = {"text", "keyevent", "keycombination"}
_POINTER = {"tap", "swipe", "draganddrop", "press", "roll", "motionevent", "scroll"}
def _tokens(command: str) -> list[str]:
"""Shell tokens, heredoc bodies removed (a script being WRITTEN is not run)."""
lexer = shlex.shlex(
_HEREDOC.sub("\n", command), posix=True, punctuation_chars=";&|()"
)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
try:
return list(lexer)
except ValueError:
return []
def _split(tokens: list[str]) -> list[list[str]]:
"""Split a token stream into simple commands."""
groups: list[list[str]] = [[]]
for word in tokens:
if word in _SEPARATORS or set(word) <= set(";&|"):
groups.append([])
else:
groups[-1].append(word)
return [g for g in groups if g]
def adb_calls(command: str) -> list[list[str]]:
"""Every ``adb ...`` simple command, as its argument list (no ``adb``)."""
calls = []
for simple in _split(_tokens(command)):
for i, word in enumerate(simple):
if word == "adb" or word.endswith("/adb"):
calls.append(simple[i + 1 :])
break
return calls
def _input(args: list[str], serial: str | None) -> Need:
display = _display_flag(args, "-d")
verb = next((a for a in args if a in _KEYS | _POINTER), "")
if display is not None and display != 0:
return Need(display=display, serial=serial)
if display is None and verb in _KEYS:
return Need(
block=f"`input {verb}` without -d goes to whichever display last took "
"focus -- possibly another session's app. Add `-d 0` for the real "
"screen or `-d <your display>` (phone_vd.sh id).",
serial=serial,
)
return Need(scope=SCREEN, serial=serial)
def _remote(args: list[str], serial: str | None) -> list[Need]:
"""Needs of one ``adb shell`` command line."""
found = []
for simple in _split(_tokens(" ".join(args))):
tool, rest = simple[0], simple[1:]
found.append(_remote_one(tool, rest, serial))
return found
def _remote_one(tool: str, rest: list[str], serial: str | None) -> Need:
"""The need of one remote command, stamped with the call's serial."""
if tool == "input":
return _input(rest, serial)
need = REMOTE.get(tool, lambda _rest: Need())(rest)
return Need(scope=need.scope, block=need.block, display=need.display, serial=serial)
def needs(command: str, apk_package: dict[str, str] | None = None) -> list[Need]:
"""Every adb call's :class:`Need` in ``command``.
``apk_package`` maps an APK path to its package, resolved by the caller
(reading an APK is I/O); an unknown APK's install needs the screen.
"""
known = apk_package or {}
result: list[Need] = []
for call in adb_calls(command):
serial = None
i = 0
while i < len(call) and call[i].startswith("-"):
if call[i] in _GLOBAL_WITH_ARG and i + 1 < len(call):
serial = call[i + 1] if call[i] == "-s" else serial
i += 2
else:
i += 1
sub, args = (call[i], call[i + 1 :]) if i < len(call) else ("", [])
if sub in {"shell", "exec-out"}:
result.extend(_remote(args, serial))
elif sub in _WHOLE_PHONE_ADB:
result.append(Need(scope=WHOLE_PHONE, serial=serial))
elif sub in {"install", "install-multiple", "uninstall"}:
target = next((a for a in reversed(args) if not a.startswith("-")), "")
package = known.get(target) or (target if sub == "uninstall" else None)
result.append(
Need(scope=f"pkg:{package}" if package else SCREEN, serial=serial)
)
elif sub in _READ_ADB or not sub:
result.append(Need(serial=serial))
else:
result.append(Need(serial=serial))
return result

View File

@ -0,0 +1,138 @@
"""PreToolUse hook: lease -- or refuse -- raw ``adb`` before the Bash tool runs it.
``android_ui``, ``phone_deploy.sh`` and ``phone_vd.sh`` take their own leases,
but a bare ``adb shell input tap`` in a Bash call took none, which is how one
session's tap landed in another session's app (2026-09-25). Claude Code pipes
the tool call here as JSON; exit 2 blocks it and shows stderr to the model.
Leases are taken as this session (``phone_lease.owner_id`` finds the
``claude`` ancestor), so a later ``android_ui`` call from the same session
simply refreshes them.
"""
from __future__ import annotations
import json
import subprocess
import sys
from typing import TYPE_CHECKING
from python_pkg.android_ui._a11y_helper import build_tool
from python_pkg.android_ui._elements import UiAutomationError
from python_pkg.phone_guard._need import SCREEN, WHOLE_PHONE
from python_pkg.phone_guard.classify import adb_calls, needs
from python_pkg.phone_guard.vd_state import display_owner, own_display
from python_pkg.phone_lease import (
NoPhoneError,
PhoneBusyError,
Terms,
acquire,
owner_id,
resolve_serial,
scope_of,
)
if TYPE_CHECKING:
from collections.abc import Iterable
from typing import TextIO
from python_pkg.phone_guard._need import Need
BLOCK = 2
# Under Claude Code's 60 s hook timeout, with room to report.
_WAIT_SECONDS = 40.0
def _apk_packages(command: str) -> dict[str, str]:
"""Map each APK an ``adb install`` names to its package (via aapt2)."""
found: dict[str, str] = {}
try:
aapt2 = str(build_tool("aapt2"))
except UiAutomationError:
return found # no SDK: an unknown APK's install takes the screen lease
for call in adb_calls(command):
for word in call:
if not word.endswith(".apk"):
continue
try:
done = subprocess.run(
[aapt2, "dump", "packagename", word],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except OSError, subprocess.TimeoutExpired:
continue
if done.returncode == 0 and done.stdout.strip():
found[word] = done.stdout.strip()
return found
def _scope(need: Need, serial: str, owner: str) -> str:
"""The lease scope a need maps to; raises PermissionError for a foreign display."""
if need.display is not None:
mine = own_display(serial, owner)
if mine is not None and mine.display == need.display:
return scope_of(mine.package)
other = display_owner(serial, need.display)
whose = f"session {other.owner_dir}'s" if other else "not your"
msg = (
f"display {need.display} is {whose} virtual display; drive only "
"your own (`phone_vd.sh id`)"
)
raise PermissionError(msg)
if need.scope == SCREEN:
return scope_of()
if need.scope == WHOLE_PHONE:
return scope_of(whole_phone=True)
return str(need.scope)
def decide(command: str, note: str = "") -> str | None:
"""Take every lease ``command`` needs; return why it must not run, if so."""
found: Iterable[Need] = needs(command, _apk_packages(command))
owner = owner_id()
for need in found:
if need.block:
return need.block
if need.scope is None and need.display is None:
continue
try:
serial = resolve_serial(need.serial)
except NoPhoneError:
continue # no phone or several without -s: adb itself will refuse
try:
scope = _scope(need, serial, owner)
acquire(
serial,
note or f"hook: {command[:60]}",
scope=scope,
terms=Terms(wait=_WAIT_SECONDS),
owner=owner,
)
except PermissionError as exc:
return str(exc)
except PhoneBusyError as exc:
return (
f"{exc} -- wait for it, use your own virtual display, or ask the user"
)
return None
def main(stdin: TextIO | None = None) -> int:
"""Hook entry point: 0 lets the Bash call run, 2 blocks it."""
try:
payload = json.load(stdin or sys.stdin)
except json.JSONDecodeError:
return 0
if payload.get("tool_name") != "Bash":
return 0
command = str((payload.get("tool_input") or {}).get("command") or "")
if "adb" not in command:
return 0
reason = decide(command)
if reason is None:
return 0
sys.stderr.write(f"phone_guard: {reason}\n")
return BLOCK

View File

@ -0,0 +1 @@
"""Tests for :mod:`python_pkg.phone_guard`."""

View File

@ -0,0 +1,33 @@
"""Keep every phone_guard test off the live lease and virtual-display state."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from python_pkg.phone_lease import lease as lease_mod
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture(autouse=True)
def isolated_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
"""Redirect ~/.cache/phone-lease and /tmp/phone_vd into tmp_path."""
monkeypatch.setenv("PHONE_VD_STATE_DIR", str(tmp_path / "phone_vd"))
with patch.object(lease_mod, "LEASE_DIR", tmp_path / "leases"):
yield tmp_path
def make_display(
root: Path, serial: str, owner_dir: str, display: str, package: str
) -> None:
"""Write the state phone_vd.sh leaves for one running display."""
state = root / "phone_vd" / serial / owner_dir
state.mkdir(parents=True)
(state / "display").write_text(display)
(state / "sf").write_text("123")
(state / "package").write_text(package)

View File

@ -0,0 +1,121 @@
"""What each adb call needs, from the Bash command that runs it."""
from __future__ import annotations
import pytest
from python_pkg.phone_guard._need import SCREEN, WHOLE_PHONE, Need
from python_pkg.phone_guard.classify import adb_calls, needs
def only(command: str) -> Need:
found = needs(command)
assert len(found) == 1, found
return found[0]
@pytest.mark.parametrize(
("command", "scope"),
[
# reads and file transfer never need a lease
("adb devices", None),
("adb logcat -d", None),
("adb pull /sdcard/a.png /tmp", None),
("adb push a.jpg /sdcard/", None),
("adb shell getprop ro.x", None),
("adb exec-out screencap -p", None),
("adb shell dumpsys window", None),
("adb shell settings get global x", None),
("adb shell cmd connectivity get-chain3-enabled", None),
("adb shell cmd package list", None),
("adb shell pm list packages", None),
("adb shell am broadcast -a X", None),
("adb shell am instrument -w a/.B", None),
("adb shell wm size", None),
("adb shell uiautomator dump", None),
("adb", None),
("adb frobnicate", None),
# the real screen
("adb shell input tap 1 2", SCREEN),
("adb shell input -d 0 text hi", SCREEN),
("adb shell input swipe 1 2 3 4", SCREEN),
("adb shell am start -n a/.B", SCREEN),
("adb shell monkey -p a 1", SCREEN),
("adb shell wm size 1080x2400", SCREEN),
("adb shell pm clear --user 0", SCREEN),
("adb install -r unknown.apk", SCREEN),
# one app
("adb shell am force-stop dev.kuhy.todo", "pkg:dev.kuhy.todo"),
("adb shell pm grant a.b android.permission.X", "pkg:a.b"),
("adb shell pm clear --user 0 -f a.b", "pkg:a.b"),
(
"adb shell cmd connectivity set-package-networking-enabled false a.b",
"pkg:a.b",
),
("adb uninstall a.b", "pkg:a.b"),
# the whole phone
("adb reboot", WHOLE_PHONE),
("adb kill-server", WHOLE_PHONE),
("adb shell svc wifi disable", WHOLE_PHONE),
("adb shell settings put global airplane_mode_on 1", WHOLE_PHONE),
("adb shell cmd connectivity airplane-mode enable", WHOLE_PHONE),
("adb shell cmd connectivity set-chain3-enabled true", WHOLE_PHONE),
(
"adb shell cmd connectivity set-package-networking-enabled false",
WHOLE_PHONE,
),
("adb shell dumpsys deviceidle force-idle", WHOLE_PHONE),
("adb shell dumpsys battery unplug", WHOLE_PHONE),
("adb shell reboot", WHOLE_PHONE),
("adb shell setprop a b", WHOLE_PHONE),
],
)
def test_scopes(command: str, scope: str | None) -> None:
need = only(command)
assert need.scope == scope
assert need.block is None
def test_text_without_display_is_blocked() -> None:
for verb in ("text hi", "keyevent 4", "keycombination 113 29"):
assert "without -d" in (only(f"adb shell input {verb}").block or "")
def test_a_virtual_display_is_reported_not_leased() -> None:
need = only("adb shell input -d 9 text hi")
assert (need.display, need.scope) == (9, None)
assert only("adb shell am start --display 9 -n a/.B").display == 9
def test_a_display_flag_without_a_number_is_not_a_display() -> None:
assert only("adb shell am start --display main -n a/.B").display is None
def test_serial_and_global_options() -> None:
need = only("adb -s R58M1 -t 3 shell am force-stop a.b")
assert (need.serial, need.scope) == ("R58M1", "pkg:a.b")
assert only("adb -e -H host shell input tap 1 1").scope == SCREEN
def test_installs_use_the_resolved_package() -> None:
found = needs("adb install -r -t app.apk", {"app.apk": "a.b"})
assert found[0].scope == "pkg:a.b"
def test_every_call_in_a_compound_command() -> None:
found = needs(
"adb logcat -d | grep x && "
"/opt/sdk/adb -s S shell 'svc data disable; input tap 1 1'"
)
assert [n.scope for n in found] == [None, WHOLE_PHONE, SCREEN]
def test_text_that_only_mentions_adb_is_ignored() -> None:
assert needs('echo "adb shell input tap 1 1"') == []
assert needs("cat <<'EOF' > s.sh\nadb shell input tap 1 1\nEOF\nls") == []
assert needs("grep adbd log") == []
def test_unparseable_quoting_is_left_alone() -> None:
assert needs("adb shell 'input tap") == []
assert adb_calls("") == []

View File

@ -0,0 +1,128 @@
"""The PreToolUse hook: which Bash calls run, which lease first, which stop."""
from __future__ import annotations
import io
import json
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.android_ui._elements import UiAutomationError
from python_pkg.phone_guard import hook
from python_pkg.phone_guard.tests.conftest import make_display
from python_pkg.phone_lease import NoPhoneError, PhoneBusyError
MOD = "python_pkg.phone_guard.hook"
def run(command: str, tool: str = "Bash") -> int:
return hook.main(
io.StringIO(json.dumps({"tool_name": tool, "tool_input": {"command": command}}))
)
@pytest.fixture
def acquire() -> MagicMock:
with (
patch(f"{MOD}.acquire") as mock,
patch(f"{MOD}.owner_id", return_value="claude:1"),
patch(f"{MOD}.resolve_serial", side_effect=lambda s=None: s or "S"),
patch(f"{MOD}.build_tool", side_effect=UiAutomationError("no sdk")),
):
yield mock
class TestMain:
def test_ignores_what_it_cannot_or_need_not_judge(self, acquire: MagicMock) -> None:
assert hook.main(io.StringIO("not json")) == 0
assert run("adb shell input tap 1 1", tool="Read") == 0
assert run("ls -la") == 0
assert hook.main(io.StringIO(json.dumps({"tool_name": "Bash"}))) == 0
acquire.assert_not_called()
def test_reads_pass_without_a_lease(self, acquire: MagicMock) -> None:
assert run("adb logcat -d") == 0
acquire.assert_not_called()
def test_a_block_exits_2_with_the_reason(
self, acquire: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
assert run("adb shell input text hi") == hook.BLOCK
assert "without -d" in capsys.readouterr().err
acquire.assert_not_called()
class TestLeases:
def test_the_real_screen(self, acquire: MagicMock) -> None:
assert run("adb shell input tap 1 1") == 0
assert acquire.call_args.kwargs["scope"] == "pkg:__screen__"
assert acquire.call_args.kwargs["owner"] == "claude:1"
assert acquire.call_args.args[0] == "S"
def test_the_whole_phone_and_an_app(self, acquire: MagicMock) -> None:
assert run("adb -s X reboot && adb shell am force-stop a.b") == 0
scopes = [c.kwargs["scope"] for c in acquire.call_args_list]
assert scopes == ["whole-phone", "pkg:a.b"]
assert acquire.call_args_list[0].args[0] == "X"
def test_own_display_takes_its_app_lease(
self, acquire: MagicMock, isolated_state: Path
) -> None:
make_display(isolated_state, "S", "claude_1", "9", "dev.kuhy.todo")
assert run("adb shell input -d 9 tap 5 5") == 0
assert acquire.call_args.kwargs["scope"] == "pkg:dev.kuhy.todo"
def test_another_sessions_display_is_refused(
self,
acquire: MagicMock,
isolated_state: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
make_display(isolated_state, "S", "claude_2", "9", "x.y")
assert run("adb shell input -d 9 tap 5 5") == hook.BLOCK
assert "session claude_2's" in capsys.readouterr().err
assert run("adb shell input -d 4 tap 5 5") == hook.BLOCK
assert "not your" in capsys.readouterr().err
acquire.assert_not_called()
def test_busy_phone_blocks_naming_the_holder(
self, acquire: MagicMock, capsys: pytest.CaptureFixture[str]
) -> None:
acquire.side_effect = PhoneBusyError("held by claude:2")
assert run("adb shell input tap 1 1") == hook.BLOCK
err = capsys.readouterr().err
assert "held by claude:2" in err
assert "own virtual display" in err
def test_no_resolvable_phone_is_left_to_adb(self, acquire: MagicMock) -> None:
with patch(f"{MOD}.resolve_serial", side_effect=NoPhoneError("several")):
assert run("adb shell input tap 1 1") == 0
acquire.assert_not_called()
class TestApkPackages:
def test_reads_the_package_with_aapt2(self, tmp_path: Path) -> None:
done = subprocess.CompletedProcess([], 0, "a.b\n", "")
with (
patch(f"{MOD}.build_tool", return_value=tmp_path / "aapt2"),
patch(f"{MOD}.subprocess.run", return_value=done) as runner,
):
assert hook._apk_packages("adb install -r app.apk other.txt") == {
"app.apk": "a.b"
}
assert runner.call_args.args[0][0] == str(tmp_path / "aapt2")
def test_failures_leave_the_apk_unknown(self, tmp_path: Path) -> None:
with patch(f"{MOD}.build_tool", return_value=tmp_path / "aapt2"):
with patch(f"{MOD}.subprocess.run", side_effect=OSError("gone")):
assert hook._apk_packages("adb install a.apk") == {}
failed = subprocess.CompletedProcess([], 1, "", "bad apk")
with patch(f"{MOD}.subprocess.run", return_value=failed):
assert hook._apk_packages("adb install a.apk") == {}
def test_no_sdk_means_no_lookup(self) -> None:
with patch(f"{MOD}.build_tool", side_effect=UiAutomationError("no sdk")):
assert hook._apk_packages("adb install a.apk") == {}

View File

@ -0,0 +1,17 @@
"""``python -m python_pkg.phone_guard`` runs the hook."""
from __future__ import annotations
import runpy
from unittest.mock import patch
import pytest
def test_module_entry_exits_with_the_hook_code() -> None:
with (
patch("python_pkg.phone_guard.hook.main", return_value=2),
pytest.raises(SystemExit) as done,
):
runpy.run_module("python_pkg.phone_guard", run_name="__main__")
assert done.value.code == 2

View File

@ -0,0 +1,45 @@
"""Reading phone_vd.sh's per-session state."""
from __future__ import annotations
from pathlib import Path
import pytest
from python_pkg.phone_guard import vd_state
from python_pkg.phone_guard.tests.conftest import make_display
def test_default_root_is_under_the_temp_dir(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PHONE_VD_STATE_DIR")
assert vd_state.state_root().name == "phone_vd"
def test_owner_dir_name_matches_the_script() -> None:
assert vd_state.owner_dir_name("claude:123") == "claude_123"
def test_lists_and_finds_displays(isolated_state: Path) -> None:
make_display(isolated_state, "S", "claude_1", "9", "a.b")
make_display(isolated_state, "S", "claude_2", "10", "c.d")
make_display(isolated_state, "S", "claude_3", "gone", "e.f")
assert [d.display for d in vd_state.displays("S")] == [9, 10]
mine = vd_state.own_display("S", "claude:1")
assert mine is not None
assert (mine.display, mine.package, mine.sf) == (9, "a.b", "123")
assert vd_state.own_display("S", "claude:9") is None
other = vd_state.display_owner("S", 10)
assert other is not None
assert other.owner_dir == "claude_2"
assert vd_state.display_owner("S", 11) is None
def test_no_state_means_no_displays() -> None:
assert vd_state.displays("NOPE") == []
def test_unreadable_files_read_as_empty(isolated_state: Path) -> None:
make_display(isolated_state, "S", "claude_1", "9", "a.b")
(isolated_state / "phone_vd" / "S" / "claude_1" / "package").unlink()
(isolated_state / "phone_vd" / "S" / "claude_1" / "package").mkdir()
assert vd_state.displays("S")[0].package == ""

View File

@ -0,0 +1,83 @@
"""Which session owns which virtual display, read from ``phone_vd.sh`` state.
``phone_vd.sh`` keeps one directory per session under
``$PHONE_VD_STATE_DIR/<serial>/<owner>/`` holding ``display`` (the logical id
``input -d`` takes), ``sf`` (the SurfaceFlinger id ``screencap -d`` takes) and
``package``. The owner directory name is the phone_lease owner id with
anything outside ``[A-Za-z0-9_.-]`` replaced by ``_``, exactly as the script
writes it.
"""
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import re
import tempfile
_UNSAFE = re.compile(r"[^A-Za-z0-9_.-]")
def state_root() -> Path:
"""The directory ``phone_vd.sh`` keeps its per-phone state in.
``$PHONE_VD_STATE_DIR``, else ``phone_vd`` under the temp dir -- the same
``${TMPDIR:-/tmp}`` rule the script applies, so both sides agree.
"""
default = Path(tempfile.gettempdir()) / "phone_vd"
return Path(os.environ.get("PHONE_VD_STATE_DIR", str(default)))
def owner_dir_name(owner: str) -> str:
"""``owner`` as ``phone_vd.sh`` names its state directory."""
return _UNSAFE.sub("_", owner)
@dataclass(frozen=True)
class SessionDisplay:
"""One running virtual display and whose it is."""
owner_dir: str
display: int
sf: str
package: str
def _read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def displays(serial: str) -> list[SessionDisplay]:
"""Every session's virtual display on ``serial``."""
root = state_root() / serial
found: list[SessionDisplay] = []
if not root.is_dir():
return found
for entry in sorted(root.iterdir()):
display = _read(entry / "display")
if not display.isdigit():
continue
found.append(
SessionDisplay(
owner_dir=entry.name,
display=int(display),
sf=_read(entry / "sf"),
package=_read(entry / "package"),
)
)
return found
def own_display(serial: str, owner: str) -> SessionDisplay | None:
"""``owner``'s virtual display on ``serial``, if it has one running."""
mine = owner_dir_name(owner)
return next((d for d in displays(serial) if d.owner_dir == mine), None)
def display_owner(serial: str, display: int) -> SessionDisplay | None:
"""The session whose virtual display has logical id ``display``."""
return next((d for d in displays(serial) if d.display == display), None)