feat(android): add native controls and continuous multi-touch

This commit is contained in:
chenmingxuan committed 2026-11-02 21:33:45 +08:00
1 parent 3dd96078db
commit 4b12ae7728
70 files changed
+13105 -179

No files matched your search

+82
View File
@@ -38,12 +38,15 @@ from __future__ import annotations
from io import BytesIO
import json
import subprocess
import time
from typing import Any
import urllib.error
import urllib.request
from PIL import Image
from artemis.runtime.native_protocol import input_completion_seconds
from artemis.clients.ui_automator_client import (
UIAutomatorScreenData,
_parse_hierarchy_xml_to_elements,
@@ -215,6 +218,85 @@ class AccessibilityClient:
return False
return self._manager.ping(session.local_port) is not None
@property
def native_capabilities(self) -> frozenset[str]:
"""Advertised at attach time; capability queries never block on network IO."""
return frozenset(self._session.capabilities) if self._session is not None else frozenset()
def native_request(
self, command: str, args: dict[str, Any], *, timeout: float | None = None
) -> dict[str, Any]:
"""Preserve native receipts. A failed POST is never sent to another backend."""
read_only = command == "inspect_control"
if command == "inspect_control" and self._session is None:
try:
self._ensure_session()
except (HelperUnavailable, OSError, RuntimeError) as exc:
return {
"success": False,
"code": "UNAVAILABLE",
"execution": "not_started",
"error": str(exc),
}
session = self._session
if session is None:
return {
"success": False,
"code": "UNAVAILABLE",
"execution": "not_started",
"error": "Connect the Android helper before issuing native operations.",
}
if (
command not in {"inspect_control", "control_action", "touch_gesture", "cancel_gesture"}
or command not in session.capabilities
):
return {
"success": False,
"code": "UNSUPPORTED",
"execution": "not_started",
"error": "The attached Android helper does not advertise this native action. Reconnect after upgrading the bundled helper.",
}
completion_window = input_completion_seconds(command, args)
started = time.monotonic()
request_timeout = max(timeout or self._request_timeout, completion_window)
def drain_uncertain_input() -> None:
remaining = completion_window - (time.monotonic() - started)
if remaining > 0:
time.sleep(remaining)
try:
result = json.loads(
self._http_once(
session, "/action", {**args, "cmd": command}, request_timeout
).decode("utf-8")
)
if not isinstance(result, dict) or not isinstance(result.get("success"), bool):
raise ValueError("Malformed native action receipt.")
if result.get("execution") not in {"not_started", "observed", "completed"}:
drain_uncertain_input()
return result
except (
HelperUnavailable,
HelperRequestError,
urllib.error.URLError,
OSError,
ValueError,
TimeoutError,
) as exc:
status = (
exc.status if isinstance(exc, HelperRequestError) else getattr(exc, "code", None)
)
rejected = status in {400, 401, 403, 404, 405, 413, 422, 429}
if not read_only and not rejected:
drain_uncertain_input()
return {
"success": False,
"code": "UNAVAILABLE" if read_only else "TRANSPORT_ERROR",
"execution": "not_started" if read_only or rejected else "unknown",
"error": f"{exc}. Observe current state; do not replay an unacknowledged write.",
}
# ------------------------------------------------------------------ #
# Transport
# ------------------------------------------------------------------ #