testsAndMisc/python_pkg/android_ui/cli.py
Krzysztof kuhy Rudnicki b7e537fe01
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
phone_lease: one session drives the phone at a time; android_ui takes the lease on every call
Two sessions driving the same phone interleave their taps: on 2026-09-20 a
tap meant for the workout sandbox opened another session's manga reader,
twice. The lease is a flock-guarded JSON file per serial under
~/.cache/phone-lease/, owned by the nearest ancestor `claude` process (one per
session), refreshed on every android_ui call and expiring 180 s after the
last one, so a quiet session frees the phone without anyone remembering to.
A foreign fresh lease makes a call wait up to 60 s, then exit 3 naming the
holder and pointing at the alternatives (the phone's virtual display, the
headless emulator) instead of inviting a retry loop. phone_deploy.sh takes
the same lease (exit 40).

The oldest evidence artifact is pruned to stay under the 60-file cap; it
remains in history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
2026-09-20 18:50:26 +02:00

117 lines
4.4 KiB
Python

"""Command line for :mod:`python_pkg.android_ui`.
Every subcommand exits non-zero and names the query when an element is missing
or ambiguous, so a shell script (or an agent) can tell a real failure from a
successful no-op instead of guessing from a screenshot.
android-ui dump
android-ui find "Connect Firebase"
android-ui tap "Connect Firebase"
android-ui type "Sync account email" kuhy@example.com
android-ui wait "Connected to Firebase." --timeout 30
"""
from __future__ import annotations
import argparse
import sys
from python_pkg.android_ui.driver import AndroidUi, UiAutomationError
from python_pkg.phone_lease import PhoneBusyError, acquire
def _build_parser() -> argparse.ArgumentParser:
"""Return the argument parser for the ``android-ui`` command."""
parser = argparse.ArgumentParser(
prog="android-ui",
description="Drive an Android app by element, never by coordinates.",
)
parser.add_argument("-s", "--serial", help="target device serial")
parser.add_argument(
"--exact",
action="store_true",
help="require an exact label match instead of a substring",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("dump", help="list every labelled element on screen")
for name, helptext in (
("find", "locate an element without touching it"),
("tap", "tap the element matching QUERY"),
("wait", "poll until QUERY appears"),
):
cmd = sub.add_parser(name, help=helptext)
cmd.add_argument("query")
if name in {"tap", "wait"}:
cmd.add_argument("--timeout", type=float, default=15.0)
typed = sub.add_parser("type", help="type TEXT into the field at QUERY")
typed.add_argument("query")
typed.add_argument("text")
typed.add_argument("--timeout", type=float, default=15.0)
sub.add_parser("dismiss-keyboard", help="close the IME without popping the route")
sub.add_parser("focus", help="print the currently focused window")
return parser
def _lease_phone(serial: str | None, command: str) -> bool:
"""Refresh this session's lease on the phone; False when another holds it.
Every command goes through here first, so a second session's taps wait
instead of landing in whatever this one has in front (2026-09-20: a
sandbox tap opened another session's manga reader). Serial-less calls
share one key: adb picks one device anyway.
"""
try:
acquire(serial or "default", f"android_ui {command}")
except PhoneBusyError as exc:
sys.stderr.write(f"android-ui: {exc}\n")
return False
return True
def _run(ui: AndroidUi, args: argparse.Namespace) -> None:
"""Dispatch one parsed command to the driver."""
if args.command == "dump":
for element in ui.dump():
sys.stdout.write(f"{element}\n")
elif args.command == "find":
sys.stdout.write(f"{ui.find(args.query, exact=args.exact)}\n")
elif args.command == "tap":
found = ui.tap(args.query, exact=args.exact, timeout=args.timeout)
sys.stdout.write(f"tapped {found}\n")
elif args.command == "wait":
found = ui.wait_for(args.query, timeout=args.timeout, exact=args.exact)
sys.stdout.write(f"{found}\n")
elif args.command == "type":
ui.type_into(args.query, args.text, exact=args.exact, timeout=args.timeout)
sys.stdout.write(f"typed into {args.query!r} and verified\n")
elif args.command == "dismiss-keyboard":
ui.dismiss_keyboard()
elif args.command == "focus":
sys.stdout.write(f"{ui.current_focus()}\n")
def main(argv: list[str] | None = None) -> int:
"""Run the CLI. Returns a process exit code."""
args = _build_parser().parse_args(argv)
if not _lease_phone(args.serial, args.command):
return 3
try:
_run(AndroidUi(serial=args.serial), args)
except UiAutomationError as exc:
# Loud and non-zero on purpose: a silent no-op here is exactly the
# failure this package exists to eliminate. The message already names
# the query and what IS on screen, so print that rather than a
# traceback -- "element not found" is an expected outcome, not a crash,
# and a wall of stack frames buries the one line that helps.
sys.stderr.write(f"android-ui: {exc}\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())