"""Explicit device acceptance for cancellation on the already-explored fixture. Run only when com.artemis.fixture is already foreground with its touch surface and reset button fully visible, no keyboard, and no covering window: uv run python tests/native_actions_fixture/verify_cancellation.py --serial SERIAL --output DIR Writes DIR/result.json even on assertion or precondition failure. Uses the production device lease, AccessibilityClient connection and AndroidAdbDriver cancellation path. Does not launch/restart the fixture, dismiss UI, build an APK, clear data, or replay input. --help never connects to a device. """ import argparse import asyncio import base64 from copy import deepcopy from datetime import UTC, datetime from io import BytesIO import json from pathlib import Path import re import subprocess from threading import Lock import time from types import SimpleNamespace from uuid import uuid4 from adbutils import AdbClient from PIL import Image from artemis.clients.accessibility_client import AccessibilityClient from artemis.drivers.android.adb_driver import AndroidAdbDriver from artemis.mcp.actuators.adb import AdbActuator from artemis.runtime.adb_endpoint import adb_command from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock PACKAGE = "com.artemis.fixture" STATE_FILE = "files/native_actions_state.json" class PreconditionError(RuntimeError): """Current UI cannot safely exercise this already-explored fixture.""" def stamp() -> dict: return {"utc": datetime.now(UTC).isoformat(), "monotonic": time.monotonic()} class RecordingAccessibilityClient(AccessibilityClient): """Log real receipts, including the driver's otherwise internal cancel/drain.""" def __init__(self, serial: str) -> None: super().__init__(serial) self.receipts: list[dict] = [] self.receipt_lock = Lock() def native_request(self, command: str, args: dict, *, timeout: float | None = None) -> dict: record = { "command": command, "args": deepcopy(args), "started": stamp(), "timeout": timeout, } try: receipt = super().native_request(command, args, timeout=timeout) record["receipt"] = deepcopy(receipt) return receipt except BaseException as exc: record["error"] = {"type": type(exc).__name__, "message": str(exc)} raise finally: record["ended"] = stamp() with self.receipt_lock: self.receipts.append(record) def receipt_snapshot(self) -> list[dict]: with self.receipt_lock: return deepcopy(self.receipts) def adb_read(serial: str, *args: str) -> str: return subprocess.run( adb_command(["-s", serial, "shell", *args]), stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True, timeout=5, ).stdout def require(condition: bool, message: str) -> None: if not condition: raise PreconditionError(message) def bounds(node: dict, width: int, height: int) -> list[int]: rectangle = node.get("bounds") require( isinstance(rectangle, list) and len(rectangle) == 4 and all(type(value) is int for value in rectangle), "Native control has no exact pixel bounds; prepare the fixture manually.", ) left, top, right, bottom = rectangle require( 0 <= left < right <= width and 0 <= top < bottom <= height, "Fixture touch/reset control is partially off screen; prepare it manually.", ) return rectangle def check_covering_windows(dump: str, rectangles: list[list[int]]) -> None: """WindowManager dumps windows top to bottom; refuse uncertain windows above the activity.""" windows = list(re.finditer(r"(?m)^\s*Window #\d+ (Window\{[^\n]+\}):\s*$", dump)) require(bool(windows), "Cannot read WindowManager z-order; covering windows are unknown.") fixture = [ index for index, window in enumerate(windows) if PACKAGE + "/" in window.group(1) and "MainActivity" in window.group(1) ] require(len(fixture) == 1, "Fixture activity window is missing or ambiguous.") frame_pattern = r"\b(?:mFrame|frame)=\[(-?\d+),\s*(-?\d+)\]\[(-?\d+),\s*(-?\d+)\]" for index, window in enumerate(windows[: fixture[0]]): end = windows[index + 1].start() block = dump[window.end() : end] if "mHasSurface=false" in block: continue require( "mHasSurface=true" in block, f"Window above fixture has unknown surface state: {window.group(1)}", ) visible = re.search(r"\bisOnScreen=(true|false)\b", block) if visible is None: visible = re.search(r"\bisVisible=(true|false)\b", block) if visible is None: visible = re.search(r"\bisReadyForDisplay\(\)=(true|false)\b", block) if (visible is not None and visible.group(1) == "false") or "mViewVisibility=0x8" in block: continue frame = re.search(frame_pattern, block) require(frame is not None, f"Window above fixture has unknown bounds: {window.group(1)}") left, top, right, bottom = map(int, frame.groups()) overlap = any( left < rect[2] and right > rect[0] and top < rect[3] and bottom > rect[1] for rect in rectangles ) require( not overlap, f"A visible or uncertain window covers fixture controls: {window.group(1)}" ) def gesture_args(width: int, height: int, rectangle: list[int]) -> dict: left, top, right, bottom = rectangle require(right - left >= 160 and bottom - top >= 100, "Observed touch surface is too small.") cx, cy = (left + right) // 2, (top + bottom) // 2 span = (right - left) // 6 dx, dy = (right - left) // 12, (bottom - top) // 12 offsets = [(0, 0), (dx, -dy), (0, dy), (-dx, -dy), (0, 0)] return { "operation_id": str(uuid4()), "screen_size": [width, height], "frames": [ {"at_ms": index * 400, "points": [[cx - span + x, cy + y], [cx + span + x, cy + y]]} for index, (x, y) in enumerate(offsets) ], } async def verify(serial: str, output: Path) -> None: output.mkdir(parents=True, exist_ok=True) report = { "serial": serial, "fixture_package": PACKAGE, "started": stamp(), "status": "running", "checks": [], "records": [], "cleanup_errors": [], } client = RecordingAccessibilityClient(serial) lease = DeviceExecutionLock(serial, description="Native fixture cancellation acceptance") connection_attempted = False gesture_task: asyncio.Task | None = None def check(condition: bool, label: str) -> None: report["checks"].append({"check": label, "passed": bool(condition), "at": stamp()}) if not condition: raise AssertionError(label) async def state(label: str) -> dict: # MainActivity writes in place after MotionEvents; only JSON decoding is retried. for attempt in range(5): raw = await asyncio.to_thread(adb_read, serial, "run-as", PACKAGE, "cat", STATE_FILE) try: value = json.loads(raw) except json.JSONDecodeError as exc: report["records"].append( { "state_read": label, "attempt": attempt + 1, "decode_error": str(exc), "at": stamp(), } ) if attempt == 4: raise await asyncio.sleep(0.02) continue if not isinstance(value, dict) or not isinstance(value.get("touch"), dict): raise ValueError("Fixture state file has no touch object.") report["records"].append({"state_read": label, "state": value, "at": stamp()}) return value raise AssertionError("Unreachable state retry exit") async def unchanged(expected: dict, label: str, duration: float) -> None: deadline = time.monotonic() + duration while True: observed = await state(label) check(observed["touch"] == expected, label) if time.monotonic() >= deadline: return await asyncio.sleep(min(0.05, max(0, deadline - time.monotonic()))) try: lease.acquire(blocking=False) connection_attempted = True await asyncio.to_thread(client.connect) session = client.session require( session is not None and session.protocol_version >= 3, "Helper protocol 3 is required." ) require( {"inspect_control", "control_action", "touch_gesture", "cancel_gesture"} <= client.native_capabilities, "Connected helper lacks the required native capabilities; upgrade separately.", ) report["helper"] = {"version": session.version_name, "code": session.version_code} screen = await asyncio.to_thread(client.get_screen_data) with Image.open(BytesIO(base64.b64decode(screen.base64))) as image: width, height = image.size report["screen_size"] = [width, height] driver = AndroidAdbDriver(serial, AdbClient(), client, width, height) actuator = AdbActuator( SimpleNamespace( device=SimpleNamespace(device_width=width, device_height=height), data_engine=None ), SimpleNamespace(driver=driver, native_request=driver.native_request), ) async def tool(name: str, **args) -> dict: started = stamp() result = await getattr(actuator, name)(**args) report["records"].append( { "tool": name, "args": args, "result": result.model_dump(mode="json"), "started": started, "ended": stamp(), } ) if name == "read_control": require(result.ok, f"Native {name} refused: {result.message}") else: check(result.ok, f"Native {name} accepted: {result.message}") return result.metadata async def preconditions() -> tuple[dict, dict]: foreground = await driver.get_current_package() require( foreground == PACKAGE, "Fixture is not foreground; no UI will be launched or repaired.", ) ime = await asyncio.to_thread(adb_read, serial, "dumpsys", "input_method") ime_flags = re.findall(r"\b(?:mInputShown|mIsInputViewShown)=(?:true|false)\b", ime) report["records"].append( {"foreground": foreground, "ime_flags": ime_flags, "at": stamp()} ) # IME-internal mIsInputViewShown may stay true for a hidden window. # Only the system's unique mInputShown value establishes visibility. system_input_shown = re.findall(r"\bmInputShown=(true|false)\b", ime) require( system_input_shown == ["false"], "Keyboard is visible or visibility is unknown; prepare the fixture manually.", ) nodes = [] for desc in ("fixture.touch", "fixture.touch_reset"): node = ( await tool( "read_control", selector={"package_name": PACKAGE, "content_desc": desc} ) )["node"] require( node.get("package_name") == PACKAGE and node.get("visible") is True and node.get("enabled") is True, f"{desc} is not a visible enabled fixture control.", ) bounds(node, width, height) nodes.append(node) require( "click" in nodes[1].get("actions", []), "Reset control lacks a native click action." ) windows = await asyncio.to_thread(adb_read, serial, "dumpsys", "window", "windows") report["records"].append( { "window_diagnostics": [ line for line in windows.splitlines() if re.search( r"Window #|mCurrentFocus|mHasSurface|isOnScreen|Frames:|mFrame=", line ) ], "at": stamp(), } ) require( not re.search(r"\b(?:mShowingLockscreen|mDreamingLockscreen)=true\b", windows), "A lock screen covers the fixture.", ) check_covering_windows(windows, [node["bounds"] for node in nodes]) require( await driver.get_current_package() == PACKAGE, "Foreground changed during preflight.", ) return nodes[0], nodes[1] async def reset() -> tuple[dict, dict]: _, button = await preconditions() previous = (await state("before native reset"))["touch"] require( previous.get("down_count") == previous.get("up_count", 0) + previous.get("cancel_count", 0), "The fixture already has a held touch; do not reset its live counters.", ) await tool( "control_action", selector={"package_name": PACKAGE, "content_desc": "fixture.touch_reset"}, action="click", expected_token=button["token"], ) baseline = await state("after native reset") check( all( baseline["touch"].get(key) == 0 for key in ( "down_count", "up_count", "cancel_count", "move_count", "max_pointer_count", "pointer_down_count", "pointer_up_count", ) ), "native reset cleared app touch counters", ) check(baseline["touch"].get("events") == [], "native reset cleared app event history") # Reset publishes a status label and may relayout the activity. touch, _ = await preconditions() return touch, baseline touch, baseline = await reset() late_args = gesture_args(width, height, touch["bounds"]) cancelled_id = late_args["operation_id"] for label in ("cancel before registration", "repeated cancel before registration"): receipt = await driver.native_request( "cancel_gesture", {"operation_id": cancelled_id}, timeout=2 ) check( receipt.get("success") is True and receipt.get("code") == "CANCEL_RECORDED" and receipt.get("execution") == "observed", label, ) receipt = await driver.native_request("touch_gesture", late_args, timeout=6.6) check( receipt.get("success") is False and receipt.get("code") == "CANCELLED" and receipt.get("execution") == "not_started" and receipt.get("dispatched_segments") == 0, "late same-ID gesture rejected before any input", ) receipt = await driver.native_request( "cancel_gesture", {"operation_id": cancelled_id}, timeout=2 ) check( receipt.get("success") is True and receipt.get("code") == "CANCEL_RECORDED" and receipt.get("execution") == "observed", "cancel remains safe after rejected request", ) await unchanged( baseline["touch"], "pre-registration cancellation leaves app untouched", 0.15 ) touch, baseline = await reset() active_args = gesture_args(width, height, touch["bounds"]) active_id = active_args["operation_id"] report["records"].append({"gesture_task_started": stamp(), "operation_id": active_id}) gesture_task = asyncio.create_task( driver.native_request("touch_gesture", active_args, timeout=6.6) ) down_deadline = time.monotonic() + 1.2 while True: down = (await state("observe app down before Python cancellation"))["touch"] if down.get("down_count") == 1 and down.get("max_pointer_count") == 2: check( down.get("up_count") == 0 and down.get("cancel_count") == 0, "cancel only while app has an active two-finger contact", ) break check( not gesture_task.done() and time.monotonic() < down_deadline, "app observes two-finger down before the gesture completes", ) await asyncio.sleep(0.025) check(not gesture_task.done(), "production native request still pending at cancellation") cancel_at = stamp() report["records"].append( {"python_task_cancel_requested": cancel_at, "operation_id": active_id} ) gesture_task.cancel() try: await gesture_task except asyncio.CancelledError: current = asyncio.current_task() if current is not None and current.cancelling(): raise cancelled_at = stamp() report["records"].append( {"python_cancelled_error_observed": cancelled_at, "operation_id": active_id} ) else: raise AssertionError("Production driver did not propagate CancelledError") receipts = [ item for item in client.receipt_snapshot() if item["args"].get("operation_id") == active_id ] original = [item for item in receipts if item["command"] == "touch_gesture"] cancellations = [item for item in receipts if item["command"] == "cancel_gesture"] check( len(original) == len(cancellations) == 1, "driver sends one gesture and one cancellation without replay", ) final_receipt = original[0].get("receipt", {}) check( final_receipt.get("code") == "CANCELLED" and final_receipt.get("execution") == "cancelled" and 0 < final_receipt.get("dispatched_segments", 0) < len(active_args["frames"]) - 1, "driver drains the real interrupted gesture receipt", ) check( cancellations[0].get("receipt", {}).get("code") == "CANCEL_REQUESTED", "driver cancellation reaches the active native gesture", ) check( original[0]["ended"]["monotonic"] <= cancelled_at["monotonic"], "CancelledError is observed only after original native request drains", ) stopped = (await state("immediately after CancelledError"))["touch"] check( stopped.get("down_count") == stopped.get("up_count") == 1 and stopped.get("cancel_count") == 0 and stopped.get("max_pointer_count") == 2, "app received one two-finger down/up and no cancel", ) check( stopped.get("pointer_down_count") == stopped.get("pointer_up_count") == 1, "both native pointers are released exactly once", ) await unchanged(stopped, "no new app input for 500ms after CancelledError", 0.5) touch, _ = await preconditions() for invalid in ("out_of_bounds", "nonincreasing_time"): args = gesture_args(width, height, touch["bounds"]) if invalid == "out_of_bounds": args["frames"][-1]["points"][0][0] = width else: args["frames"][-1]["at_ms"] = args["frames"][-2]["at_ms"] receipt = await driver.native_request("touch_gesture", args, timeout=6.6) check( receipt.get("success") is False and receipt.get("code") == "INVALID_ARGUMENT" and receipt.get("execution") == "not_started" and receipt.get("dispatched_segments") == 0, f"raw {invalid} gesture rejected before input", ) await unchanged( stopped, f"raw {invalid} rejection leaves app touch state unchanged", 0.1 ) report["status"] = "passed" except BaseException as exc: report["status"] = ( "blocked" if isinstance(exc, (PreconditionError, DeviceBusyError)) else "cancelled" if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt)) else "failed" ) report["error"] = {"type": type(exc).__name__, "message": str(exc)} raise finally: # Never release the device lease while the production driver still drains input. if gesture_task is not None and not gesture_task.done(): gesture_task.cancel() while not gesture_task.done(): try: await asyncio.shield(gesture_task) except asyncio.CancelledError: continue except Exception as exc: report["cleanup_errors"].append({"stage": "drain gesture", "error": str(exc)}) break if connection_attempted: try: await state("finally") except BaseException as exc: report["cleanup_errors"].append({"stage": "final state", "error": str(exc)}) try: await asyncio.to_thread(client.disconnect) except BaseException as exc: report["cleanup_errors"].append({"stage": "disconnect", "error": str(exc)}) try: lease.release() except Exception as exc: report["cleanup_errors"].append({"stage": "lease release", "error": str(exc)}) if report["cleanup_errors"] and report["status"] == "passed": report["status"] = "failed" report["native_receipts"] = client.receipt_snapshot() report["ended"] = stamp() report["elapsed_seconds"] = report["ended"]["monotonic"] - report["started"]["monotonic"] (output / "result.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) if report["status"] != "passed": raise RuntimeError("Acceptance cleanup failed; see result.json.") print( f"PASS {len(report['checks'])} checks; receipts and app state: {output / 'result.json'}", flush=True, ) if __name__ == "__main__": parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( "--serial", required=True, help="Explicit already-explored Android device serial" ) parser.add_argument( "--output", required=True, type=Path, help="Artifact directory; report is result.json" ) options = parser.parse_args() if not options.serial.strip(): parser.error("--serial cannot be blank") asyncio.run(verify(options.serial, options.output))