diff --git a/.env.example b/.env.example index 2c6b072..8b23d0a 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ ADB_PORT=5037 # Keep the display awake, falling back to a wake lock or safe heartbeat when needed. # Set to false if Artemis is used with a personal device whose screen should time out. ARTEMIS_KEEP_DEVICE_AWAKE=true +# UI hierarchy backend: auto (Accessibility Helper, UIAutomator2 fallback) | helper | uiautomator +ARTEMIS_HIERARCHY_BACKEND=auto +# Let tasks install / upgrade the Accessibility Helper APK on a device they hold. +# Set to false on shared or personal phones: then only `artemis helper install` installs it. +ARTEMIS_HELPER_AUTO_INSTALL=true # Artemis Execution Defaults (flash or pro) ARTEMIS_DEFAULT_PROFILE=flash diff --git a/README.md b/README.md index 49a3108..0c6e2c6 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,26 @@ if __name__ == "__main__": * **Developer CLI (`uv run artemis run`)**: Direct terminal execution for automated test cases, exploratory stability inspection, or AndroidWorld benchmarks with high-fidelity structured terminal output; * **Python SDK**: Integrates as a standard Python library into existing automated testing frameworks (e.g., pytest) or CI/CD pipelines with strongly typed Pydantic structured outputs and assertion support. + +## What ARTEMIS Installs on Your Phone + +The first task on a device installs the **Artemis Accessibility Helper**, a small +accessibility service that reads the screen layout without taking the +UiAutomation connection. Tools using UiAutomation can suppress the helper unless +they enable `FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES`. You will see +a collapsed "Artemis test helper is running" notification and a new entry under +Settings > Accessibility; both are that helper. It listens only on the phone +itself and sends nothing elsewhere. + +* Pre-install it (avoids the ~3 s delay on the first task): `uv run artemis helper install` +* Inspect it: `uv run artemis helper status` / `uv run artemis doctor` +* Remove it any time: `uv run artemis helper uninstall` +* Use UIAutomator2 instead: `ARTEMIS_HIERARCHY_BACKEND=uiautomator` in `.env` +* Prevent automatic installation: `ARTEMIS_HELPER_AUTO_INSTALL=false` in `.env` + +If the helper ever fails mid-task, ARTEMIS falls back to UIAutomator2 and says +so in the task timeline, in `mobile_manage_task` status, and in the final report. + ## Benchmarks: AndroidWorld (SOTA 99%+) diff --git a/README_CN.md b/README_CN.md index 84c1eb0..9b1995a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -252,6 +252,22 @@ if __name__ == "__main__": * **开发者命令行 CLI (`uv run artemis run`)**:支持通过终端直接执行自动化测试用例、探索性稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出; * **Python SDK**:作为标准 Python 库集成至现有自动化测试框架(如 pytest)或 CI/CD 流水线,提供基于 Pydantic 的强类型结构化结果与断言支持。 + +## ARTEMIS 会在手机上安装什么 + +首次在某台设备上执行任务时,ARTEMIS 会安装 **Artemis Accessibility Helper**:一个用于读取屏幕布局的小型无障碍服务, +不占用 UiAutomation 连接。使用 UiAutomation 的工具需要启用 `FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES`,否则可能使该服务暂停运行。 +手机上会出现一条折叠的"Artemis test helper is running"通知,以及"设置 > 无障碍"里的一个新条目,都是它。 +它只监听手机本机回环地址,不向外发送任何数据。 + +* 预先安装(避免首个任务多等约 3 秒):`uv run artemis helper install` +* 查看状态:`uv run artemis helper status` / `uv run artemis doctor` +* 随时移除:`uv run artemis helper uninstall` +* 改用 UIAutomator2:在 `.env` 中设置 `ARTEMIS_HIERARCHY_BACKEND=uiautomator` +* 禁止自动安装:在 `.env` 中设置 `ARTEMIS_HELPER_AUTO_INSTALL=false` + +任务中途 helper 失效时,ARTEMIS 会回退到 UIAutomator2,并在任务时间线、`mobile_manage_task` 状态和最终报告里明确说明。 + ## 基准评测:AndroidWorld (SOTA 99%+) diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts index 041e6d7..5c915fd 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts @@ -40,6 +40,59 @@ describe('startup Work block', () => { expect(item.isActive).toBeTrue(); }); + it('shows the helper install sub-step while the hierarchy service is connecting', () => { + const events: StartupProgressEvent[] = [ + { stage: 'uiautomator', message: 'Connecting to the UI hierarchy service', timestamp: 10 }, + { + stage: 'helper_install', + message: 'Installing the Artemis accessibility helper on this device for the first time (v1.1.3, about 3 seconds)', + timestamp: 10.2 + } + ]; + + const [item] = buildStartupWorkItems(events, 12, false, true); + + expect(item.isActive).toBeTrue(); + expect(item.message).toContain('Installing the Artemis accessibility helper'); + expect(item.elapsed).toBe('2.0s'); + }); + + it('uses the hierarchy source line as the completion message of the second step', () => { + const events: StartupProgressEvent[] = [ + { stage: 'uiautomator', message: 'Connecting to the UI hierarchy service', timestamp: 10 }, + { stage: 'helper_install', message: 'Installing ...', timestamp: 10.2 }, + { stage: 'uiautomator_ready', message: 'UI hierarchy service is ready (helper)', timestamp: 13 }, + { + stage: 'hierarchy_backend', + message: 'UI hierarchy source: Artemis accessibility helper v1.1.3', + timestamp: 13.1 + }, + // A later mid-run switch must not rewrite the startup line. + { + stage: 'hierarchy_backend_changed', + message: 'UI hierarchy source switched from Artemis accessibility helper to UIAutomator2 because ...', + timestamp: 90 + } + ]; + + const [item] = buildStartupWorkItems(events, 100, true, true); + + expect(item.isActive).toBeFalse(); + expect(item.message).toBe('UI hierarchy source: Artemis accessibility helper v1.1.3'); + expect(item.elapsed).toBe('3.0s'); + }); + + it('falls back to the generic completion line for runs without a source event', () => { + const events: StartupProgressEvent[] = [ + { stage: 'uiautomator', message: 'Connecting to the UI hierarchy service', timestamp: 10 }, + { stage: 'environment', message: 'Preparing the device environment', timestamp: 14 } + ]; + + const [item] = buildStartupWorkItems(events, 20, false, true); + + expect(item.message).toBe('UI hierarchy service is ready'); + }); + it('uses old first_response events only as a hidden completion boundary', () => { const events: StartupProgressEvent[] = [ { stage: 'environment', message: 'Preparing the device environment', timestamp: 20 }, diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index cca9489..93172d3 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -962,6 +962,17 @@ limitations under the License. } @if (item.type === 'tool') { + + @if (isBackendSwitchNote(item.data)) { +
+
+ +
+ {{ getBackendSwitchMessage(item.data) }} +
+
+
+ } @if (isLLMRetry(item.data)) {
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts index 5601311..4372372 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts @@ -61,6 +61,10 @@ interface StartupWorkStage { started: string; completed: string; completedMessage: string; + /** Sub-steps that replace the live message while the stage is still running. */ + liveDetailStages?: string[]; + /** A later event whose message is a better completion line than the stage's own. */ + completedDetailStage?: string; } const STARTUP_WORK_STAGES: StartupWorkStage[] = [ @@ -72,7 +76,12 @@ const STARTUP_WORK_STAGES: StartupWorkStage[] = [ { started: 'uiautomator', completed: 'uiautomator_ready', - completedMessage: 'UI Automator is ready' + completedMessage: 'UI hierarchy service is ready', + // First task on a device installs / upgrades the accessibility helper + // (a few seconds): say so instead of a generic "connecting". + liveDetailStages: ['helper_install', 'helper_upgrade'], + // "UI hierarchy source: Artemis accessibility helper v1.1.3" (or UIAutomator2). + completedDetailStage: 'hierarchy_backend' }, { started: 'environment', @@ -119,10 +128,21 @@ export function buildStartupWorkItems( const endTimestamp = completed?.timestamp || (isActive ? nowSeconds : events[events.length - 1]?.timestamp || startTimestamp); + const liveDetail = (stage.liveDetailStages || []) + .map((detailStage) => byStage.get(detailStage)) + .filter((event): event is StartupProgressEvent => Boolean(event)) + .filter((event) => event.timestamp >= startTimestamp) + .sort((a, b) => b.timestamp - a.timestamp)[0]; + const completedDetail = stage.completedDetailStage + ? byStage.get(stage.completedDetailStage) + : undefined; + const message = completed + ? (completedDetail?.message || explicitlyCompleted?.message || stage.completedMessage) + : (liveDetail?.message || started!.message); + return [{ ...(explicitlyCompleted || started!), - message: explicitlyCompleted?.message - || (completed ? stage.completedMessage : started!.message), + message, isActive, elapsed: formatStartupElapsed(endTimestamp - startTimestamp) }]; @@ -198,6 +218,7 @@ import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util'; import { consolidateLogsToBlocks, + isBackendSwitchNote, groupBlocksToPhases, extractBlockTokens, formatTokenCount, @@ -1795,6 +1816,7 @@ export class AgentStreamComponent implements AfterViewInit { || this.isDisplayableLLMFailure(t) || this.isLLMRetry(t) || this.isReportStatusAction(t) + || this.isBackendSwitchNote(t) ); const hasAndroidActions = Boolean(block.data?.action_taken) && (this.isAndroidAction(block.data.action_taken) || this.isReportStatusAction(block.data.action_taken)); return hasNative || hasRaw || hasReset || hasVisibleTools || hasAndroidActions; @@ -1826,6 +1848,15 @@ export class AgentStreamComponent implements AfterViewInit { && (tool?.name === 'llm_pause' || tool?.payload?.pause === true); } + /** Mid-run change of the UI-hierarchy source (helper <-> UIAutomator2). */ + public isBackendSwitchNote(tool: any): boolean { + return isBackendSwitchNote(tool); + } + + public getBackendSwitchMessage(tool: any): string { + return String(tool?.payload?.message || 'UI hierarchy source changed'); + } + public resumePausedTask(event: Event): void { event.stopPropagation(); this.agentService.resumeTask(); diff --git a/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts b/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts index ee8dc8f..d6f1212 100644 --- a/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +++ b/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts @@ -1,4 +1,4 @@ -import { consolidateLogsToBlocks, getSortedStepEvents, persistedStreamToSegments } from './stream-aggregator.util'; +import { consolidateLogsToBlocks, getSortedStepEvents, isBackendSwitchNote, persistedStreamToSegments } from './stream-aggregator.util'; describe('stream aggregator timeline ordering', () => { it('interleaves text, tools, and actions by timestamp', () => { @@ -413,6 +413,51 @@ describe('stream aggregator step ownership', () => { expect(blocks.length).toBe(1); expect(blocks[0].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-note']); }); + + it('keeps a mid-run hierarchy source switch in the step where it happened, as a note', () => { + const initial = { + type: 'trace_recorded', + timestamp: '2026-09-02T03:57:20.000Z', + data: { + trace_id: 'trace-backend-initial', + type: 'log', + name: 'hierarchy_backend', + payload: { backend: 'helper', previous_backend: null, message: 'UI hierarchy source: Artemis accessibility helper v1.1.3' } + } + }; + const switched = { + type: 'trace_recorded', + timestamp: '2026-09-02T03:57:26.000Z', + data: { + trace_id: 'trace-backend-switch', + type: 'log', + name: 'hierarchy_backend', + timestamp: '2026-09-02T03:57:26.000Z', + payload: { + backend: 'uiautomator', + previous_backend: 'helper', + reason: 'HelperUnavailable: tunnel gone', + message: 'UI hierarchy source switched from Artemis accessibility helper to UIAutomator2 because HelperUnavailable: tunnel gone' + } + } + }; + const ordinaryLog = { + type: 'trace_recorded', + timestamp: '2026-09-02T03:57:27.000Z', + data: { trace_id: 'trace-log', type: 'log', name: 'artemis.runtime', payload: { message: 'noise' } } + }; + + const blocks = consolidateLogsToBlocks([initial, plannerStream, switched, ordinaryLog]); + + // The initial line belongs to the startup block; ordinary logs stay out. + expect(blocks.length).toBe(1); + expect(blocks[0].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-backend-switch']); + expect(isBackendSwitchNote(blocks[0].data.generic_tools[0])).toBeTrue(); + expect(isBackendSwitchNote(initial.data)).toBeFalse(); + + const events = getSortedStepEvents(blocks[0].data); + expect(events.map((e) => e.type)).toEqual(['text', 'tool']); + }); }); describe('stream aggregator checker history relocation', () => { diff --git a/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts b/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts index d204a69..8b57e47 100644 --- a/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +++ b/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts @@ -247,8 +247,17 @@ function relocateCheckerTools(blocks: StepBlock[]): void { } /** - * Consolidate raw SSE logs into deduplicated and ordered StepBlocks + * A `hierarchy_backend` log trace that records a *switch* (helper -> UIAutomator2 + * or back). The initial "source: ..." line belongs to the startup block; only + * the mid-run change is worth a row in the timeline. */ +export function isBackendSwitchNote(tool: any): boolean { + return tool?.type === 'log' + && tool?.name === 'hierarchy_backend' + && Boolean(tool?.payload?.previous_backend); +} + +/** Consolidate raw SSE logs into deduplicated and ordered StepBlocks. */ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { if (!rawLogs || rawLogs.length === 0) return []; @@ -487,7 +496,7 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { const isTool = log.data.type === 'tool' || isAction; const isVisibleLLMEvent = log.data.type === 'llm_call' && (log.data.status === 'failed' || log.data.status === 'retrying'); - if (!isTool && !isVisibleLLMEvent) return; + if (!isTool && !isVisibleLLMEvent && !isBackendSwitchNote(log.data)) return; let stepId = log.data.step_id; let existingIndex = -1; diff --git a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts b/apps/showcase_ui/src/app/utils/tool-formatter.util.ts index d0181c2..225c1f9 100644 --- a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +++ b/apps/showcase_ui/src/app/utils/tool-formatter.util.ts @@ -279,6 +279,7 @@ export function shouldShowTool(tool: any, _stepData?: any): boolean { if (!tool || !tool.name) return false; if (tool.type === 'llm_call') return false; if (tool.type === 'agent') return false; + if (tool.type === 'log') return false; // system notes render as quiet rows, not tool cards if (isInternalPlumbingTool(tool)) return false; return true; diff --git a/artemis/agents/outputter/outputter.py b/artemis/agents/outputter/outputter.py index 925760a..b84f429 100644 --- a/artemis/agents/outputter/outputter.py +++ b/artemis/agents/outputter/outputter.py @@ -185,6 +185,27 @@ async def outputter( except Exception as e: logger.error(f"Failed to attach verdict ledger to outputter: {e}") + # Run environment facts the model must not guess: which UI-hierarchy source + # served the run and whether it switched mid-way (a fallback to UIAutomator2 + # explains slower steps and possible conflicts with other automation tools). + from artemis.clients.screen_client_factory import hierarchy_backend_sentence + + relative = ctx.data_engine.get_relative_time if ctx.data_engine else None + environment_line = hierarchy_backend_sentence( + getattr(ctx, "ui_adb_client", None), relative_time=relative + ) + if environment_line: + human_message_content.append( + { + "type": "text", + "text": ( + "## Run Environment (facts, include verbatim under a short " + "'Environment' note only if the source changed mid-run or the " + "user asked about it)" + chr(10) + environment_line + ), + } + ) + if screenshot_b64: human_message_content.append( { diff --git a/artemis/clients/accessibility_client.py b/artemis/clients/accessibility_client.py index e55505c..a86384f 100644 --- a/artemis/clients/accessibility_client.py +++ b/artemis/clients/accessibility_client.py @@ -12,96 +12,324 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Artemis Accessibility Helper client for Android device automation. +"""HTTP client for the Artemis Accessibility Helper running on a device. -Provides a lightweight, conflict-free alternative to UIAutomator2. -Runs concurrently with Mobly, Appium, and Espresso without monopolizing -the singleton UiAutomationConnection. +A lightweight, conflict-free alternative to UIAutomator2: it never takes the +singleton ``UiAutomationConnection``, so it runs alongside Mobly, Appium and +Espresso. This class only speaks the helper's HTTP API. Installing, enabling, +port forwarding, the session token and hot-plug recovery live in +:mod:`artemis.runtime.helper_manager`; this client asks the manager for a +session lazily and hands transport failures back to it once. + +The screen-data contract is byte-for-byte the one :class:`UIAutomatorClient` +exposes (``UIAutomatorScreenData`` with the element dictionaries produced by +``_parse_hierarchy_xml_to_elements``), so every consumer downstream +(``filter_ui_hierarchy``, the Explorer index, the MCP actuators that compare +``clickable == "true"``) sees identical shapes whichever backend is active. + +One observation is one request: ``/snapshot?fields=xml`` returns the XML and, +on Android 11+, the screenshot taken at the same instant with the bitmap's own +dimensions. On older Android the same answer carries the XML and the client +only adds an adb screencap; the hierarchy is never dumped twice. """ +from __future__ import annotations + from io import BytesIO import json -import os import subprocess -import time +from typing import Any import urllib.error import urllib.request from PIL import Image -from artemis.clients.ui_automator_client import UIAutomatorScreenData, _pil_to_base64 +from artemis.clients.ui_automator_client import ( + UIAutomatorScreenData, + _parse_hierarchy_xml_to_elements, + _pil_to_base64, +) from artemis.runtime.adb_endpoint import adb_command +from artemis.runtime.awake_service import ensure_device_awake +from artemis.runtime.helper_manager import ( + DEVICE_PORT, + PACKAGE_NAME, + SERVICE_NAME, + AccessibilityHelperManager, + HelperSession, + HelperUnavailable, + ProvisionEvent, + helper_manager, +) from artemis.utils.logger import get_logger logger = get_logger(__name__) -PACKAGE_NAME = "com.artemis.helper" -SERVICE_NAME = f"{PACKAGE_NAME}/.ArtemisAccessibilityService" -DEFAULT_PORT = 18888 +# Kept for callers that imported these from here. +DEFAULT_PORT = DEVICE_PORT +TOKEN_HEADER = "X-Artemis-Token" +__all__ = [ + "AccessibilityClient", + "DEFAULT_PORT", + "HelperEmptyHierarchy", + "HelperRequestError", + "HelperUnavailable", + "PACKAGE_NAME", + "SERVICE_NAME", + "TOKEN_HEADER", + "normalize_helper_elements", +] + +_BOOL_ATTRIBUTES = ( + "checkable", + "checked", + "clickable", + "enabled", + "focusable", + "focused", + "scrollable", + "long-clickable", + "password", + "selected", + "visible-to-user", +) +_GLOBAL_KEYS = ("back", "home", "recents", "notifications", "quick_settings") + + +class HelperEmptyHierarchy(RuntimeError): + """The helper answered but found no window to dump. + + Raised instead of returning a screen with zero elements, so ``auto`` mode + can try UIAutomator2 and ``helper`` mode reports the real cause instead of + showing the agent an empty screen. + """ + + +class HelperRequestError(RuntimeError): + """The helper answered with an HTTP error that a new tunnel cannot fix.""" + + def __init__(self, status: int, path: str, body: str): + self.status = status + self.path = path + self.body = body + super().__init__(f"accessibility helper {path} answered HTTP {status}: {body[:200]}") + + +def normalize_helper_elements(elements: Any) -> list[dict[str, Any]]: + """Coerce the helper's JSON element list into the UIAutomator element shape. + + Used only when a dump carries elements but no XML. The JSON encodes flags + as booleans and omits ``accessibilityText``; the XML parser emits string + flags and mirrors ``content-desc`` into ``accessibilityText``, and several + consumers compare against the string ``"true"``. + """ + if isinstance(elements, dict): + elements = [elements] + if not isinstance(elements, list): + return [] + normalized: list[dict[str, Any]] = [] + for raw in elements: + if not isinstance(raw, dict): + continue + element = dict(raw) + for key in _BOOL_ATTRIBUTES: + if key in element and isinstance(element[key], bool): + element[key] = "true" if element[key] else "false" + desc = element.get("content-desc") + if desc is not None and "accessibilityText" not in element: + element["accessibilityText"] = desc + bounds = element.get("parsed_bounds") + if isinstance(bounds, dict): + element["parsed_bounds"] = { + side: int(bounds.get(side, 0)) for side in ("left", "top", "right", "bottom") + } + element.pop("children", None) + normalized.append(element) + return normalized class AccessibilityClient: - """Non-exclusive accessibility client for Artemis UI automation.""" + """Screen-data client over the helper's loopback HTTP API.""" - def __init__(self, device_id: str, local_port: int = DEFAULT_PORT): + backend_name = "helper" + + def __init__( + self, + device_id: str, + manager: AccessibilityHelperManager | None = None, + *, + provision_on_connect: bool = True, + request_timeout: float = 6.0, + ) -> None: self._device_id = device_id - self._local_port = local_port - self._base_url = f"http://127.0.0.1:{local_port}" + self._manager = manager or helper_manager + self._provision_on_connect = provision_on_connect + self._request_timeout = request_timeout + self._session: HelperSession | None = None + self._awake_strategy: str | None = None - def ensure_service_ready(self, apk_path: str | None = None) -> bool: - """Ensure ArtemisAccessibilityHelper is installed, enabled, and port-forwarded.""" - # 1. Check if package is installed - if not self._is_installed(): - if apk_path is None: - default_apk = os.path.abspath( - os.path.join( - os.path.dirname(__file__), - "../../packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk", - ) - ) - if os.path.exists(default_apk): - apk_path = default_apk + # ------------------------------------------------------------------ # + # Session lifecycle + # ------------------------------------------------------------------ # - if apk_path and os.path.exists(apk_path): - logger.info(f"Installing ArtemisAccessibilityHelper on {self._device_id}...") - self._run_adb(["install", "-r", "-g", apk_path]) - else: - logger.warning( - f"ArtemisAccessibilityHelper not installed on {self._device_id} and no APK provided." - ) + @property + def device_id(self) -> str: + return self._device_id - # 2. Ensure accessibility service is enabled via secure settings - self._enable_accessibility_service() + @property + def session(self) -> HelperSession | None: + return self._session - # 3. Setup port forward - self._setup_port_forward() + @property + def active_backend(self) -> str | None: + return self.backend_name if self._session is not None else None - # 4. Verify connection - for attempt in range(5): - if self.ping(): - logger.info( - f"ArtemisAccessibilityHelper connected successfully on {self._device_id}" - ) - return True - time.sleep(0.5) + def connect(self, on_event: ProvisionEvent | None = None) -> None: + """Task-path entry: provision (install / upgrade / enable) and attach. - logger.warning( - f"Failed to connect to ArtemisAccessibilityHelper on port {self._local_port}" + ``on_event`` receives ``installing`` / ``upgrading`` before the slow step. + """ + self._session = self._manager.attach( + self._device_id, provision=self._provision_on_connect, on_event=on_event ) - return False + if self._awake_strategy is None: + self._awake_strategy = ensure_device_awake(self._device_id) + + def _ensure_session(self) -> HelperSession: + """Observer-path entry: attach to a helper that is already running.""" + if self._session is None: + self._session = self._manager.attach(self._device_id, provision=False) + if self._awake_strategy is None: + self._awake_strategy = ensure_device_awake(self._device_id) + return self._session + + def disconnect(self) -> None: + self._manager.detach(self._device_id) + self._session = None + self._awake_strategy = None def ping(self) -> bool: - """Health check probe.""" try: - req = urllib.request.Request(f"{self._base_url}/ping") - with urllib.request.urlopen(req, timeout=2.0) as resp: - data = json.loads(resp.read().decode()) - return data.get("success", False) - except Exception: + session = self._ensure_session() + except HelperUnavailable: return False + return self._manager.ping(session.local_port) is not None + + # ------------------------------------------------------------------ # + # Transport + # ------------------------------------------------------------------ # + + def _http( + self, path: str, payload: dict[str, Any] | None = None, timeout: float | None = None + ) -> bytes: + """One request with two single-shot repairs. + + * HTTP 401: the helper lost its token (re-bound service, reboot): push it + again and retry once. + * Transport failure on a read: rebuild the tunnel once and retry. + Actions are not replayed because they may already have executed. + + Any other HTTP status is a :class:`HelperRequestError`; a new tunnel + would not change the answer. + """ + session = self._ensure_session() + try: + return self._http_once(session, path, payload, timeout) + except urllib.error.HTTPError as exc: + body = _read_error_body(exc) + if exc.code != 401: + raise HelperRequestError(exc.code, path, body) from exc + logger.warning( + f"Accessibility helper on {self._device_id} rejected the session token " + f"({body[:120]}); pushing it again." + ) + self._manager.push_token(self._device_id) + try: + return self._http_once(session, path, payload, timeout) + except urllib.error.HTTPError as again: + raise HelperRequestError(again.code, path, _read_error_body(again)) from again + except (urllib.error.URLError, OSError, TimeoutError) as exc: + if payload is not None: + raise + logger.warning( + f"Accessibility helper request {path} on {self._device_id} failed ({exc}); " + "rebuilding the tunnel once." + ) + self._session = self._manager.reattach(self._device_id) + try: + return self._http_once(self._session, path, payload, timeout) + except urllib.error.HTTPError as again: + raise HelperRequestError(again.code, path, _read_error_body(again)) from again + + def _http_once( + self, + session: HelperSession, + path: str, + payload: dict[str, Any] | None, + timeout: float | None, + ) -> bytes: + data = ( + json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None + ) + headers: dict[str, str] = {} + token = session.token or self._manager.host_token() + if token: + headers[TOKEN_HEADER] = token + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + request = urllib.request.Request(f"{session.base_url}{path}", data=data, headers=headers) + with urllib.request.urlopen(request, timeout=timeout or self._request_timeout) as resp: + return resp.read() + + def _rpc(self, cmd: str, params: dict[str, Any] | None = None) -> bool: + payload = {"cmd": cmd, **(params or {})} + try: + data = json.loads(self._http("/action", payload).decode("utf-8")) + except ( + HelperUnavailable, + HelperRequestError, + urllib.error.URLError, + OSError, + ValueError, + TimeoutError, + ) as exc: + logger.warning(f"Accessibility helper command '{cmd}' failed: {exc}") + return False + return bool(data.get("success", False)) + + # ------------------------------------------------------------------ # + # Screen data + # ------------------------------------------------------------------ # + + def get_hierarchy(self) -> str: + """Raw UIAutomator-format XML, the same contract as ``UIAutomatorClient``.""" + return self._http("/dump_xml").decode("utf-8") + + def get_hierarchy_json(self, *, include_invisible: bool = False) -> dict[str, Any]: + """The helper's JSON dump (XML plus its own element list); diagnostics only.""" + query = "fields=xml,elements" + ("&include_invisible=1" if include_invisible else "") + return json.loads(self._http(f"/dump?{query}").decode("utf-8")) + + def _snapshot(self) -> dict[str, Any]: + """``/snapshot?fields=xml``: hierarchy plus, on Android 11+, the screenshot.""" + data = json.loads(self._http("/snapshot?fields=xml", timeout=8.0).decode("utf-8")) + if not isinstance(data, dict): + raise HelperRequestError(200, "/snapshot", "non-object answer") + if not data.get("success"): + raise HelperEmptyHierarchy( + f"accessibility helper on {self._device_id} returned no hierarchy: " + f"{data.get('error') or 'unknown reason'}" + ) + return data + + def get_atomic_snapshot(self) -> dict[str, Any] | None: + """Screenshot + hierarchy captured in one call on the device (Android 11+), else None.""" + data = self._snapshot() + if data.get("has_screenshot") and data.get("screenshot_base64"): + return data + return None def get_screenshot(self) -> Image.Image | None: - """Capture screenshot via high-speed ADB exec-out.""" try: result = subprocess.run( adb_command(["-s", self._device_id, "exec-out", "screencap", "-p"]), @@ -111,160 +339,111 @@ class AccessibilityClient: check=True, ) return Image.open(BytesIO(result.stdout)) - except Exception as e: - logger.error(f"Failed to capture screenshot via adb: {e}") + except (OSError, subprocess.SubprocessError) as exc: + logger.error(f"Failed to capture screenshot via adb: {exc}") return None - def get_hierarchy(self) -> dict: - """Fetch hierarchy JSON directly from ArtemisAccessibilityHelper.""" - req = urllib.request.Request(f"{self._base_url}/dump") - with urllib.request.urlopen(req, timeout=5.0) as resp: - return json.loads(resp.read().decode()) + def get_screenshot_base64(self) -> str | None: + screenshot = self.get_screenshot() + return None if screenshot is None else _pil_to_base64(screenshot, format="JPEG") - def get_hierarchy_xml(self) -> str: - """Fetch raw standard UIAutomator XML string directly from helper.""" - req = urllib.request.Request(f"{self._base_url}/dump_xml") - with urllib.request.urlopen(req, timeout=5.0) as resp: - return resp.read().decode("utf-8") - - def get_atomic_snapshot(self) -> dict | None: - """Fetch atomic snapshot (screenshot Base64 + hierarchy) directly in one call.""" - try: - req = urllib.request.Request(f"{self._base_url}/snapshot") - with urllib.request.urlopen(req, timeout=6.0) as resp: - data = json.loads(resp.read().decode()) - if ( - data.get("success") - and data.get("has_screenshot") - and data.get("screenshot_base64") - ): - return data - except Exception as e: - logger.debug(f"Atomic snapshot request failed (falling back to dual path): {e}") - return None + @staticmethod + def _elements_from_dump(dump: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]: + xml = dump.get("xml") or "" + if isinstance(xml, str) and xml.strip(): + return xml, _parse_hierarchy_xml_to_elements(xml) + return "", normalize_helper_elements(dump.get("elements")) def get_screen_data(self) -> UIAutomatorScreenData: - """Retrieve complete screen data (screenshot + parsed elements + standard XML). - - Prioritizes the atomic hardware snapshot API (Android 11+) to eliminate - any temporal phase mismatch between visual frame and layout DOM tree. - Falls back to separate ADB screencap + HTTP dump on older platforms. - """ - # 1. Try atomic snapshot - atomic = self.get_atomic_snapshot() - if atomic is not None: + snapshot = self._snapshot() + xml, elements = self._elements_from_dump(snapshot) + if snapshot.get("has_screenshot") and snapshot.get("screenshot_base64"): + width, height = _dimensions(snapshot) return UIAutomatorScreenData( - base64=atomic.get("screenshot_base64", ""), - hierarchy_xml=atomic.get("xml", ""), - elements=atomic.get("elements", []), - width=atomic.get("width", 1080), - height=atomic.get("height", 2400), + base64=str(snapshot["screenshot_base64"]), + hierarchy_xml=xml, + elements=elements, + width=width, + height=height, ) - # 2. Fallback: separate screenshot and hierarchy dump + # Android 10 and below (or a rate-limited capture that did not recover): + # keep the hierarchy we already have and only add an adb screencap. + reason = snapshot.get("screenshot_error") + if reason: + logger.debug(f"Helper snapshot without screenshot ({reason}); using adb screencap") screenshot = self.get_screenshot() if screenshot is None: raise RuntimeError("Failed to capture screenshot") - - hierarchy_data = self.get_hierarchy() - elements = hierarchy_data.get("elements", []) - hierarchy_xml = hierarchy_data.get("xml", "") - return UIAutomatorScreenData( base64=_pil_to_base64(screenshot, format="JPEG"), - hierarchy_xml=hierarchy_xml, + hierarchy_xml=xml, elements=elements, width=screenshot.width, height=screenshot.height, ) - def tap(self, x: float, y: float) -> bool: - """Perform tap gesture via AccessibilityService.""" - return self._send_rpc("tap", {"x": x, "y": y}) + # ------------------------------------------------------------------ # + # Input + # ------------------------------------------------------------------ # - def swipe(self, x1: float, y1: float, x2: float, y2: float, duration_ms: int = 300) -> bool: - """Perform swipe gesture via AccessibilityService.""" - return self._send_rpc( - "swipe", - {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "duration": duration_ms}, - ) + def set_clipboard(self, text: str) -> bool: + """Put ``text`` on the device clipboard so the driver can paste it.""" + return self._rpc("clipboard", {"text": text}) def send_text(self, text: str) -> bool: - """Send text input via AccessibilityNodeInfo.ACTION_SET_TEXT.""" - success = self._send_rpc("type", {"text": text}) - if not success: - # Fallback to adb shell input text - safe_text = text.replace(" ", "%s") - self._run_adb(["shell", "input", "text", safe_text]) - return True - return success + """Append ``text`` to the focused field, the way typing through an IME does.""" + return self._rpc("type", {"text": text, "append": True}) def clear_text(self) -> bool: - """Clear text of focused input.""" - return self._send_rpc("clear", {}) + return self._rpc("clear", {}) def press_key(self, key: str) -> bool: - """Perform global navigation action (back, home, recents).""" - key_lower = key.lower() - if key_lower in ("back", "home", "recents", "notifications", "quick_settings"): - return self._send_rpc("global", {"action": key_lower}) - # Fallback to adb keyevent - self._run_adb(["shell", "input", "keyevent", f"KEYCODE_{key.upper()}"]) - return True - - def _send_rpc(self, cmd: str, params: dict) -> bool: - """Send JSON-RPC payload to helper server.""" - payload = {"cmd": cmd, **params} - try: - req = urllib.request.Request( - f"{self._base_url}/action", - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=5.0) as resp: - data = json.loads(resp.read().decode()) - return data.get("success", False) - except Exception as e: - logger.warning(f"RPC command '{cmd}' failed: {e}") - return False - - def _is_installed(self) -> bool: - res = self._run_adb(["shell", "pm", "list", "packages", PACKAGE_NAME]) - return f"package:{PACKAGE_NAME}" in res.stdout - - def _enable_accessibility_service(self) -> None: - """Silently enable the accessibility service via adb settings without UI prompts.""" - res = self._run_adb( - ["shell", "settings", "get", "secure", "enabled_accessibility_services"] - ) - current_services = res.stdout.strip() - if SERVICE_NAME not in current_services: - new_services = ( - f"{current_services}:{SERVICE_NAME}" - if current_services and current_services != "null" - else SERVICE_NAME - ) - self._run_adb( + key_lower = str(key).lower() + if key_lower in _GLOBAL_KEYS: + return self._rpc("global", {"action": key_lower}) + result = subprocess.run( + adb_command( [ + "-s", + self._device_id, "shell", - "settings", - "put", - "secure", - "enabled_accessibility_services", - new_services, + "input", + "keyevent", + f"KEYCODE_{key_lower.upper()}", ] - ) - self._run_adb(["shell", "settings", "put", "secure", "accessibility_enabled", "1"]) - - def _setup_port_forward(self) -> None: - self._run_adb(["forward", f"tcp:{self._local_port}", f"tcp:{DEFAULT_PORT}"]) - - def _run_adb(self, args: list[str]) -> subprocess.CompletedProcess: - return subprocess.run( - adb_command(["-s", self._device_id] + args), + ), stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15, check=False, ) + return result.returncode == 0 + + def tap(self, x: float, y: float) -> bool: + return self._rpc("tap", {"x": x, "y": y}) + + def swipe(self, x1: float, y1: float, x2: float, y2: float, duration_ms: int = 300) -> bool: + return self._rpc("swipe", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "duration": duration_ms}) + + +def _read_error_body(exc: urllib.error.HTTPError) -> str: + try: + return exc.read().decode("utf-8", "replace") + except OSError: + return str(exc) + + +def _dimensions(snapshot: dict[str, Any]) -> tuple[int, int]: + """The screenshot's own size; a snapshot without it is a broken answer, not a 1080x2400 one.""" + try: + width = int(snapshot.get("width") or 0) + height = int(snapshot.get("height") or 0) + except (TypeError, ValueError): + width = height = 0 + if width <= 0 or height <= 0: + raise HelperRequestError( + 200, "/snapshot", f"screenshot without dimensions (width={width}, height={height})" + ) + return width, height diff --git a/artemis/clients/screen_client_factory.py b/artemis/clients/screen_client_factory.py new file mode 100644 index 0000000..311f262 --- /dev/null +++ b/artemis/clients/screen_client_factory.py @@ -0,0 +1,400 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Select the UI-hierarchy backend for a device and build its screen client. + +Three backends, chosen by ``ARTEMIS_HIERARCHY_BACKEND`` (environment or +``.env``): + +* ``auto`` (default): the Accessibility Helper, with UIAutomator2 as a + per-call fallback. UIAutomator2 is only constructed when the helper fails, + so a healthy helper never triggers ``u2.connect`` (which pushes its own APKs + and uninstalls Maestro). +* ``helper``: the helper only. Failures surface as errors; use it in CI to + prove the helper is really what serves the hierarchy. +* ``uiautomator``: the pre-helper path, unchanged. + +Every entrypoint that used to build ``UIAutomatorClient`` directly (the SDK +agent, the MCP device controller, and through it the device smoke test) goes +through :func:`create_screen_client`, so the switch applies everywhere. +""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +import os +import subprocess +import time +from typing import Any + +from PIL import Image + +from artemis.clients.accessibility_client import AccessibilityClient, HelperUnavailable +from artemis.clients.ui_automator_client import UIAutomatorClient, UIAutomatorScreenData +from artemis.config.constants import ENV_ARTEMIS_HIERARCHY_BACKEND +from artemis.runtime.adb_endpoint import adb_command +from artemis.runtime.helper_manager import ProvisionEvent +from artemis.utils.logger import get_logger + +logger = get_logger(__name__) + +#: ``(previous_backend, new_backend, reason)``; ``previous_backend`` is None on first use. +BackendChangeListener = Callable[[str | None, str, str | None], None] + + +class DeviceOfflineError(RuntimeError): + """The device is not attached / authorized; no backend can serve it. + + Raised instead of trying UIAutomator2 when the helper fails because the + phone itself is gone, so the failure names the real cause and does not + burn UIAutomator2's own multi-attempt connect timeout. + """ + + def __init__(self, device_id: str, state: str | None): + self.device_id = device_id + self.state = state + detail = f"adb reports state '{state}'" if state else "adb does not list it" + super().__init__( + f"Device {device_id} is not available ({detail}). Reconnect the device or " + "accept the USB debugging prompt, then retry." + ) + + +def device_state(device_id: str, timeout: float = 5.0) -> str | None: + """``adb get-state`` for one serial: 'device', 'offline', 'unauthorized', or None.""" + try: + result = subprocess.run( + adb_command(["-s", device_id, "get-state"]), + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + text = (result.stdout or "").strip() + if result.returncode != 0 or not text: + # "error: device unauthorized" / "device offline" arrive on stderr. + err = (result.stderr or "").lower() + for known in ("unauthorized", "offline"): + if known in err: + return known + return None + return text + + +class HierarchyBackend(str, Enum): + AUTO = "auto" + HELPER = "helper" + UIAUTOMATOR = "uiautomator" + + +def resolve_backend(value: str | HierarchyBackend | None = None) -> HierarchyBackend: + """Backend from an explicit value, else the environment, else settings, else ``auto``.""" + if isinstance(value, HierarchyBackend): + return value + text = value + if text is None: + text = os.environ.get(ENV_ARTEMIS_HIERARCHY_BACKEND) + if text is None: + try: + from artemis.config import settings + + text = getattr(settings, "ARTEMIS_HIERARCHY_BACKEND", None) + except (ImportError, ValueError): + text = None + normalized = (text or "auto").strip().lower() + try: + return HierarchyBackend(normalized) + except ValueError: + logger.warning( + f"Unknown hierarchy backend {text!r}; valid values are " + f"{', '.join(b.value for b in HierarchyBackend)}. Using 'auto'." + ) + return HierarchyBackend.AUTO + + +class FallbackScreenClient: + """Helper first, UIAutomator2 on failure, with a retry window for the helper. + + After a helper failure the client serves from UIAutomator2 for + ``retry_after`` seconds before trying the helper again, so one dead tunnel + does not cost a 5-second timeout on every step. + """ + + def __init__( + self, + device_id: str, + helper: AccessibilityClient | None = None, + uiautomator_factory: Callable[[], UIAutomatorClient] | None = None, + *, + retry_after: float = 30.0, + clock: Callable[[], float] = time.monotonic, + state_probe: Callable[[str], str | None] = device_state, + ) -> None: + self._device_id = device_id + self._helper = helper or AccessibilityClient(device_id) + self._uiautomator_factory = uiautomator_factory or (lambda: UIAutomatorClient(device_id)) + self._uiautomator: UIAutomatorClient | None = None + self._retry_after = retry_after + self._clock = clock + self._state_probe = state_probe + self._helper_down_until: float | None = None + self._active_backend: str | None = None + self._last_failure: str | None = None + #: Every backend switch, oldest first: {time, backend, reason}. + self.backend_history: list[dict[str, Any]] = [] + self._listeners: list[BackendChangeListener] = [] + + # ------------------------------------------------------------------ # + + @property + def device_id(self) -> str: + return self._device_id + + @property + def helper(self) -> AccessibilityClient: + return self._helper + + @property + def active_backend(self) -> str | None: + """Backend that served the last call: ``"helper"``, ``"uiautomator"`` or ``None``.""" + return self._active_backend + + @property + def uiautomator(self) -> UIAutomatorClient: + if self._uiautomator is None: + self._uiautomator = self._uiautomator_factory() + return self._uiautomator + + def add_backend_listener(self, listener: BackendChangeListener) -> None: + """Be told whenever the serving backend changes (first use included).""" + self._listeners.append(listener) + + def remove_backend_listener(self, listener: BackendChangeListener) -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + def _set_active(self, backend: str, reason: str | None = None) -> None: + previous = self._active_backend + if previous == backend: + return + self._active_backend = backend + self.backend_history.append({"time": time.time(), "backend": backend, "reason": reason}) + for listener in list(self._listeners): + try: + listener(previous, backend, reason) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.debug(f"Backend listener failed: {exc}") + + def _ensure_device_online(self) -> None: + """Fail fast with the real cause when the phone itself is gone.""" + state = self._state_probe(self._device_id) + if state != "device": + raise DeviceOfflineError(self._device_id, state) + + def _helper_allowed(self) -> bool: + if self._helper_down_until is None: + return True + if self._clock() >= self._helper_down_until: + self._helper_down_until = None + return True + return False + + def _degrade(self, reason: BaseException) -> None: + # Never fall back to UIAutomator2 for a phone that is simply not there. + self._ensure_device_online() + self._last_failure = f"{reason.__class__.__name__}: {reason}" + if self._helper_down_until is None: + logger.warning( + f"Accessibility helper unavailable on {self._device_id} ({reason}); " + f"serving from UIAutomator2 and retrying the helper in {self._retry_after:g}s." + ) + self._helper_down_until = self._clock() + self._retry_after + + def _release_uiautomation(self) -> None: + """Stop our UIAutomator2 server before giving the helper another chance. + + Its UiAutomation connection keeps every accessibility service unbound, + so while it lives the helper cannot answer and the retry would be a + guaranteed failure. UIAutomator2 restarts its server by itself on the + next call if the helper turns out to be still dead. + """ + if self._uiautomator is not None and self._active_backend == "uiautomator": + self._uiautomator.stop_server() + + def _call(self, name: str, *args: Any, **kwargs: Any) -> Any: + if self._helper_allowed(): + self._release_uiautomation() + try: + result = getattr(self._helper, name)(*args, **kwargs) + except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as exc: + self._degrade(exc) + else: + self._set_active("helper", "helper recovered" if self._last_failure else None) + self._last_failure = None + return result + result = getattr(self.uiautomator, name)(*args, **kwargs) + self._set_active("uiautomator", self._last_failure) + return result + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + + def connect(self, on_event: ProvisionEvent | None = None) -> None: + self._release_uiautomation() + self.backend_history.clear() + self._active_backend = None + if self._helper_allowed(): + self._release_uiautomation() + try: + self._helper.connect(on_event=on_event) + except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as exc: + self._degrade(exc) + else: + self._set_active("helper") + return + self.uiautomator.connect() + self._set_active("uiautomator", self._last_failure) + + def disconnect(self) -> None: + try: + self._helper.disconnect() + finally: + if self._uiautomator is not None: + # Release UiAutomation so the helper is back for the next task + # on this device (possibly served by another process). + self._uiautomator.disconnect(stop_server=True) + self._active_backend = None + + # ------------------------------------------------------------------ # + # Screen data and input, same surface as UIAutomatorClient + # ------------------------------------------------------------------ # + + def get_screen_data(self) -> UIAutomatorScreenData: + return self._call("get_screen_data") + + def get_hierarchy(self) -> str: + return self._call("get_hierarchy") + + def get_screenshot(self) -> Image.Image | None: + return self._call("get_screenshot") + + def get_screenshot_base64(self) -> str | None: + return self._call("get_screenshot_base64") + + def set_clipboard(self, text: str) -> bool: + return self._call("set_clipboard", text) + + def send_text(self, text: str) -> Any: + return self._call("send_text", text) + + def clear_text(self) -> bool: + return self._call("clear_text") + + def press_key(self, key: str) -> Any: + return self._call("press_key", key) + + +ScreenClient = AccessibilityClient | UIAutomatorClient | FallbackScreenClient + + +def create_screen_client( + device_id: str, backend: str | HierarchyBackend | None = None +) -> ScreenClient: + """Build the screen client for ``device_id`` according to the configured backend.""" + resolved = resolve_backend(backend) + if resolved is HierarchyBackend.UIAUTOMATOR: + return UIAutomatorClient(device_id=device_id) + if resolved is HierarchyBackend.HELPER: + return AccessibilityClient(device_id) + return FallbackScreenClient(device_id) + + +def describe_backend(client: Any) -> str | None: + """Best-effort name of the backend a client is serving from, for diagnostics.""" + active = getattr(client, "active_backend", None) + if active: + return str(active) + if isinstance(client, UIAutomatorClient): + return "uiautomator" + if isinstance(client, AccessibilityClient): + return "helper" + return None + + +def helper_version(client: Any) -> str | None: + """Version name of the helper a client is attached to, if any.""" + helper = getattr(client, "helper", None) or client + session = getattr(helper, "session", None) + name = getattr(session, "version_name", None) + return str(name) if name else None + + +def hierarchy_backend_summary(client: Any) -> dict[str, Any] | None: + """Machine-readable account of which backend served a run, for reports.""" + backend = describe_backend(client) + if backend is None: + return None + history = list(getattr(client, "backend_history", None) or []) + return { + "backend": backend, + "helper_version": helper_version(client), + "switches": history[1:] if len(history) > 1 else [], + } + + +_BACKEND_LABEL = {"helper": "the Artemis accessibility helper", "uiautomator": "UIAutomator2"} + + +def hierarchy_backend_sentence( + client: Any, relative_time: Callable[[float], str] | None = None +) -> str | None: + """One plain sentence for reports: what served the hierarchy and whether it changed.""" + summary = hierarchy_backend_summary(client) + if summary is None: + return None + history = getattr(client, "backend_history", None) or [] + first = (history[0].get("backend") if history else None) or summary["backend"] + text = f"UI hierarchy source: {_BACKEND_LABEL.get(first, first)}" + if first == "helper" and summary.get("helper_version"): + text += f" v{summary['helper_version']}" + for switch in summary["switches"]: + when = relative_time(switch["time"]) if relative_time else None + text += f"; switched to {_BACKEND_LABEL.get(switch['backend'], switch['backend'])}" + if when: + text += f" at {when}" + if switch.get("reason"): + text += f" because {str(switch['reason']).rstrip('. ')}" + return text + "." + + +__all__ = [ + "BackendChangeListener", + "DeviceOfflineError", + "FallbackScreenClient", + "HelperUnavailable", + "HierarchyBackend", + "ScreenClient", + "create_screen_client", + "describe_backend", + "device_state", + "helper_version", + "hierarchy_backend_sentence", + "hierarchy_backend_summary", + "resolve_backend", +] diff --git a/artemis/clients/ui_automator_client.py b/artemis/clients/ui_automator_client.py index b5d3b7a..34c1d21 100644 --- a/artemis/clients/ui_automator_client.py +++ b/artemis/clients/ui_automator_client.py @@ -117,7 +117,7 @@ def _parse_hierarchy_xml_to_elements(hierarchy_xml: str) -> list[dict]: elif attr_name == "bounds": element["bounds"] = attr_value - match = _re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", attr_value) + match = _re.match(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]", attr_value) if match: x1, y1, x2, y2 = map(int, match.groups()) element["parsed_bounds"] = { @@ -190,7 +190,7 @@ def _is_package_installed(device_id: str, pkg: str) -> bool: except subprocess.TimeoutExpired: logger.warning("Timeout checking installed packages") return False - except Exception as e: + except (OSError, subprocess.SubprocessError) as e: logger.warning(f"Error checking installed packages: {e}") return False @@ -400,7 +400,7 @@ class UIAutomatorClient: check=True, ) return Image.open(BytesIO(result.stdout)) - except Exception as e: + except (OSError, subprocess.SubprocessError) as e: logger.warning( f"Failed to capture screenshot via adb: {e}, falling back to uiautomator2" ) @@ -446,12 +446,39 @@ class UIAutomatorClient: height=screenshot.height, ) - def disconnect(self) -> None: - """Disconnect this client without ending the host's awake lifetime.""" + def disconnect(self, stop_server: bool = False) -> None: + """Disconnect this client without ending the host's awake lifetime. + + ``stop_server`` also kills uiautomator2's device server. Its UiAutomation + connection suppresses every accessibility service (the Artemis helper + included) for as long as it lives, so a caller that prefers the helper + must release it; the pure UIAutomator2 backend keeps it for fast reconnects. + """ + device = self._device self._awake_strategy = None self._device = None + if stop_server and device is not None: + self.stop_server(device) logger.info("UIAutomator2 client disconnected") + def stop_server(self, device: "Device | None" = None) -> bool: + """Kill uiautomator2's on-device server (see :meth:`disconnect`).""" + device = device or self._device + if device is None: + return False + try: + device.stop_uiautomator() + logger.info(f"UIAutomator2 device server stopped on {self._device_id}") + return True + except ( + OSError, + RuntimeError, + subprocess.SubprocessError, + u2.exceptions.DeviceError, + ) as exc: + logger.debug(f"Stopping the UIAutomator2 server on {self._device_id} failed: {exc}") + return False + def get_client(device_id: str) -> UIAutomatorClient: """Factory function to create a UIAutomatorClient. diff --git a/artemis/config/constants.py b/artemis/config/constants.py index 40d535c..972bc6d 100644 --- a/artemis/config/constants.py +++ b/artemis/config/constants.py @@ -46,6 +46,11 @@ ENV_ARTEMIS_DEVICE_ID = "ARTEMIS_DEVICE_ID" ENV_ADB_HOST = "ADB_HOST" ENV_ADB_PORT = "ADB_PORT" ENV_ADB_SERVER_SOCKET = "ADB_SERVER_SOCKET" +# UI hierarchy backend: "auto" (helper, UIAutomator2 fallback), "helper", "uiautomator" +ENV_ARTEMIS_HIERARCHY_BACKEND = "ARTEMIS_HIERARCHY_BACKEND" +# "true" (default) lets a task install / upgrade the Accessibility Helper APK on a +# device it holds; "false" only attaches to a helper someone installed by hand. +ENV_ARTEMIS_HELPER_AUTO_INSTALL = "ARTEMIS_HELPER_AUTO_INSTALL" # LLM & Vision OCR API Keys ENV_GOOGLE_API_KEY = "GOOGLE_API_KEY" diff --git a/artemis/config/settings.py b/artemis/config/settings.py index a85b353..7129dd8 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -113,6 +113,12 @@ class Settings(BaseSettings): ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST) ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT) ADB_DEVICE_SERIAL: str | None = None + # UI hierarchy backend: "auto" = Accessibility Helper with UIAutomator2 + # fallback, "helper" = helper only, "uiautomator" = UIAutomator2 only. + ARTEMIS_HIERARCHY_BACKEND: str = Field(default="auto") + # Whether a task may install / upgrade the Accessibility Helper APK on a device + # it holds. False = only attach to a helper installed by `artemis helper install`. + ARTEMIS_HELPER_AUTO_INSTALL: bool = Field(default=True) # Execution Defaults PROJECT_NAME: str | None = None diff --git a/artemis/core/diagnostics/device_smoke.py b/artemis/core/diagnostics/device_smoke.py index 25c6cba..2fcc4ac 100644 --- a/artemis/core/diagnostics/device_smoke.py +++ b/artemis/core/diagnostics/device_smoke.py @@ -268,6 +268,7 @@ async def smoke_test_device( {"ok": bool, "serial": str | None, "elapsed_seconds": float, "screenshot_bytes": int | None, "element_count": int | None, + "hierarchy_backend": "helper" | "uiautomator" | None, "error": str | None, "fix": list[str]} """ started = time.monotonic() @@ -278,6 +279,7 @@ async def smoke_test_device( "elapsed_seconds": 0.0, "screenshot_bytes": None, "element_count": None, + "hierarchy_backend": None, "error": None, "fix": [], } @@ -342,6 +344,9 @@ async def smoke_test_device( result["screenshot_bytes"] = _decoded_length(getattr(device_data, "base64", None)) result["element_count"] = _count_elements(getattr(device_data, "elements", None)) + from artemis.clients.screen_client_factory import describe_backend + + result["hierarchy_backend"] = describe_backend(getattr(controller_ctx, "ui_adb_client", None)) if result["screenshot_bytes"] is None: return _finish("Screen capture returned no screenshot data") diff --git a/artemis/core/diagnostics/hierarchy_parity.py b/artemis/core/diagnostics/hierarchy_parity.py new file mode 100644 index 0000000..e1b905a --- /dev/null +++ b/artemis/core/diagnostics/hierarchy_parity.py @@ -0,0 +1,216 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Real-device parity check between the Accessibility Helper and UIAutomator2. + +The unit tests prove the two backends produce the same element *shapes*; only +a real screen can prove they describe the same *content*. ``compare_backends`` +dumps the current screen with both, then checks what the agent depends on: + +* every labelled UIAutomator element (text or content-desc) has a helper + element with the same label at the same place (bounds IoU >= 0.5), and vice + versa, so neither backend invents or loses what is on screen; +* the helper emits no negative and no off-screen bounds (its bounds are + clipped to the display, like UIAutomator's); +* dump latency of both, for the record. + +``artemis helper parity`` is the CLI front end. +""" + +from __future__ import annotations + +from statistics import median +import time +from typing import Any + +from artemis.clients.ui_automator_client import _parse_hierarchy_xml_to_elements + +MIN_RECALL = 0.9 +MIN_PRECISION = 0.8 + + +def _label(element: dict[str, Any]) -> str: + text = str(element.get("text") or "").strip() + if text: + return text + return str(element.get("content-desc") or "").strip() + + +def _iou(a: dict[str, int], b: dict[str, int]) -> float: + left = max(a["left"], b["left"]) + top = max(a["top"], b["top"]) + right = min(a["right"], b["right"]) + bottom = min(a["bottom"], b["bottom"]) + if right <= left or bottom <= top: + return 0.0 + inter = (right - left) * (bottom - top) + area_a = max(1, (a["right"] - a["left"]) * (a["bottom"] - a["top"])) + area_b = max(1, (b["right"] - b["left"]) * (b["bottom"] - b["top"])) + return inter / (area_a + area_b - inter) + + +def analyze_xml(xml: str, width: int, height: int) -> dict[str, Any]: + """Counts and labelled elements of one dump, plus the bounds problems the helper must not have.""" + elements = _parse_hierarchy_xml_to_elements(xml) + labelled: list[tuple[str, dict[str, int]]] = [] + negative = offscreen = 0 + package = None + for element in elements: + bounds = element.get("parsed_bounds") + if package is None and element.get("package"): + package = element["package"] + if not isinstance(bounds, dict): + continue + if min(bounds.values()) < 0: + negative += 1 + if ( + bounds["left"] >= width + or bounds["top"] >= height + or bounds["right"] > width + or bounds["bottom"] > height + ): + offscreen += 1 + label = _label(element) + if label: + labelled.append((label, bounds)) + return { + "nodes": len(elements), + "labelled": len(labelled), + "labels": labelled, + "negative_bounds": negative, + "offscreen_bounds": offscreen, + "package": package, + } + + +def match_labels( + source: list[tuple[str, dict[str, int]]], target: list[tuple[str, dict[str, int]]] +) -> tuple[int, list[str]]: + """How many labelled ``source`` elements have a same-label, same-place element in ``target``.""" + used = [False] * len(target) + found = 0 + missing: list[str] = [] + for label, bounds in source: + hit = False + for i, (other_label, other_bounds) in enumerate(target): + if used[i] or other_label != label: + continue + if _iou(bounds, other_bounds) >= 0.5: + used[i] = True + hit = True + break + if hit: + found += 1 + else: + missing.append(f"{label!r} at {bounds}") + return found, missing + + +def compare_dumps(helper_xml: str, uiautomator_xml: str, width: int, height: int) -> dict[str, Any]: + """Pure comparison of two dumps of the same screen.""" + helper = analyze_xml(helper_xml, width, height) + u2 = analyze_xml(uiautomator_xml, width, height) + u2_found, missing_in_helper = match_labels(u2["labels"], helper["labels"]) + helper_found, extra_in_helper = match_labels(helper["labels"], u2["labels"]) + recall = u2_found / u2["labelled"] if u2["labelled"] else 1.0 + precision = helper_found / helper["labelled"] if helper["labelled"] else 1.0 + problems: list[str] = [] + if helper["negative_bounds"]: + problems.append( + f"helper emitted {helper['negative_bounds']} element(s) with negative bounds" + ) + if helper["offscreen_bounds"]: + problems.append( + f"helper emitted {helper['offscreen_bounds']} element(s) outside the screen" + ) + if recall < MIN_RECALL: + problems.append( + f"only {recall:.0%} of UIAutomator's labelled elements appear in the helper dump " + f"(need {MIN_RECALL:.0%})" + ) + if precision < MIN_PRECISION: + problems.append( + f"only {precision:.0%} of the helper's labelled elements appear in the UIAutomator dump " + f"(need {MIN_PRECISION:.0%}); the helper is showing things that are not on screen" + ) + for side in (helper, u2): + side.pop("labels", None) + return { + "ok": not problems, + "problems": problems, + "helper": helper, + "uiautomator": u2, + "match": { + "uiautomator_labelled": u2["labelled"], + "uiautomator_in_helper": u2_found, + "recall": recall, + "helper_labelled": helper["labelled"], + "helper_in_uiautomator": helper_found, + "precision": precision, + "missing_in_helper": missing_in_helper, + "extra_in_helper": extra_in_helper, + }, + } + + +def compare_backends(serial: str, rounds: int = 3) -> dict[str, Any]: + """Dump the current screen of ``serial`` with both backends and compare (device required).""" + from artemis.clients.accessibility_client import AccessibilityClient + from artemis.clients.ui_automator_client import UIAutomatorClient + + helper = AccessibilityClient(serial) + u2 = UIAutomatorClient(serial) + + def timed(dump) -> tuple[str, float]: + times: list[float] = [] + xml = "" + for _ in range(rounds): + started = time.perf_counter() + xml = dump() + times.append((time.perf_counter() - started) * 1000.0) + return xml, median(times) + + # The two cannot run at once: UIAutomator2's UiAutomation connection unbinds + # the helper. Dump with the helper first, then with UIAutomator2, then stop + # its server so the helper is back for whoever comes next. + helper_version = None + try: + helper.connect() + helper_version = helper.session.version_name if helper.session else None + info = helper.get_hierarchy_json() + width, height = int(info.get("width") or 0), int(info.get("height") or 0) + if width <= 0 or height <= 0: + screen = helper.get_screen_data() + width, height = screen.width, screen.height + helper_xml, helper_ms = timed(helper.get_hierarchy) + finally: + helper.disconnect() + + try: + u2.connect() + u2_xml, u2_ms = timed(u2.get_hierarchy) + finally: + u2.disconnect(stop_server=True) + + report = compare_dumps(helper_xml, u2_xml, width, height) + report["serial"] = serial + report["screen"] = {"width": width, "height": height} + report["helper"]["median_ms"] = helper_ms + report["helper"]["version"] = helper_version + report["uiautomator"]["median_ms"] = u2_ms + report["xml"] = {"helper": helper_xml, "uiautomator": u2_xml} + return report + + +__all__ = ["analyze_xml", "compare_backends", "compare_dumps", "match_labels"] diff --git a/artemis/data_engine/engine.py b/artemis/data_engine/engine.py index a1af9ba..e880d3a 100644 --- a/artemis/data_engine/engine.py +++ b/artemis/data_engine/engine.py @@ -21,6 +21,7 @@ import os from pathlib import Path import re import socket +import sqlite3 import threading import time from typing import Any @@ -785,6 +786,19 @@ class DataEngine: except Exception as e: logger.error(f"Failed to record video failure in DataEngine: {e}") + def update_session_device_info(self, **fields: Any) -> None: + """Merge ``fields`` into the running session's ``device_info`` record.""" + if not self.storage or not self.current_session_id: + return + try: + session = self.storage.get_session(self.current_session_id) + if session is None: + return + session.device_info = {**(session.device_info or {}), **fields} + self.storage.update_session(session) + except (OSError, ValueError, sqlite3.Error) as e: + logger.warning(f"Failed to update session device_info in DataEngine: {e}") + def update_video_path(self, local_video_path: str | Path): """Update the video path across tables when traces or videos are moved.""" if not self.storage or not self.current_session_id: diff --git a/artemis/drivers/android/hierarchy.py b/artemis/drivers/android/hierarchy.py index bc754ed..c5762e0 100644 --- a/artemis/drivers/android/hierarchy.py +++ b/artemis/drivers/android/hierarchy.py @@ -33,7 +33,7 @@ def parse_ui_hierarchy(xml_string: str) -> list[dict[str, Any]]: for node in root.iter(): attribs = node.attrib bounds_str = attribs.get("bounds", "") - match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str) + match = re.match(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]", bounds_str) if not match: continue diff --git a/artemis/drivers/factory.py b/artemis/drivers/factory.py index b17e10a..ba1bcbf 100644 --- a/artemis/drivers/factory.py +++ b/artemis/drivers/factory.py @@ -33,7 +33,9 @@ logger = get_logger(__name__) def create_driver(ctx: "ArtemisContext") -> BaseDeviceDriver: """Instantiates the appropriate BaseDeviceDriver based on the runtime context.""" - # 1. Cloud mode check + # 1. Cloud mode check. Cloud devices are reached through the gateway's + # RemoteUIAutomatorClient; ARTEMIS_HIERARCHY_BACKEND does not apply there + # because the Accessibility Helper needs a local adb forward. if os.environ.get("ARTEMIS_CLOUD_MODE") == "1": if ctx.adb_client is None: from cloud_service.virtualization import RemoteAdbClient diff --git a/artemis/graph/graph.py b/artemis/graph/graph.py index 32c138c..ab854a1 100644 --- a/artemis/graph/graph.py +++ b/artemis/graph/graph.py @@ -447,8 +447,15 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext): if snapshot.all_check_items or records or verify_blocked: update["run_outcome"] = outcome.model_dump() # BLOCKED/partial wrap-ups carry the last findings in the metadata file. - extra = {"last_findings": blocked_findings} if blocked_findings else None - write_run_outcome(base_dir, outcome, extra) + extra: dict = {"last_findings": blocked_findings} if blocked_findings else {} + # Which UI-hierarchy source served the run, so a report reader can tell a + # helper run from a UIAutomator2 fallback without reading logs. + from artemis.clients.screen_client_factory import hierarchy_backend_summary + + environment = hierarchy_backend_summary(getattr(ctx, "ui_adb_client", None)) + if environment: + extra["environment"] = environment + write_run_outcome(base_dir, outcome, extra or None) publish_checker_event( ctx, { diff --git a/artemis/interfaces/cli/commands/doctor.py b/artemis/interfaces/cli/commands/doctor.py index 49c9584..1c141ec 100644 --- a/artemis/interfaces/cli/commands/doctor.py +++ b/artemis/interfaces/cli/commands/doctor.py @@ -27,6 +27,7 @@ from dataclasses import dataclass import json import platform as sys_platform import shutil +import subprocess from typing import Any from rich.console import Console @@ -194,6 +195,66 @@ def _showcase_row() -> ExtraRow: ) +def _helper_row(results: list[ProbeResult]) -> ExtraRow | None: + """State of the Artemis accessibility helper on the one ready, idle device. + + Tasks install the helper themselves when they take a device; this row lets a + person pre-install it so the first task does not pay the install delay, and + tells them the helper is a thing they can remove. + """ + from artemis.clients.screen_client_factory import resolve_backend + from artemis.runtime.helper_manager import helper_manager + + if resolve_backend().value == "uiautomator": + return None + adb = next((r for r in results if r.id == "android_adb"), None) + devices = (adb.metadata.get("devices") if adb else None) or [] + ready = [str(d.get("serial")) for d in devices if d.get("state") == "device"] + if len(ready) != 1: + return None + serial = ready[0] + try: + status = helper_manager.status(serial) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + return ExtraRow( + key="accessibility_helper", + title="Artemis Accessibility Helper", + status="missing", + status_markup="[dim]⚪ Unknown[/dim]", + summary="Could not read", + detail=f"{serial}: {exc}", + ) + healthy = status["installed"] and status["enabled"] and not status["outdated"] + if healthy and status["reachable"]: + version = status.get("installed_version") + note = " (newer than the bundled build)" if status.get("newer_than_bundled") else "" + return ExtraRow( + key="accessibility_helper", + title="Artemis Accessibility Helper", + status="pass", + status_markup="[bold green]✔ Ready[/bold green]", + summary=f"v{version} on {serial}{note}", + detail=f"Remove any time with: artemis helper uninstall --serial {serial}", + ) + if not status["installed"]: + summary, why = "Not installed", "the first task on this device will install it (about 3 s)" + elif status["outdated"]: + summary = f"Outdated (v{status['installed_version']} < v{status['bundled_version']})" + why = "the next task will upgrade it" + elif not status["enabled"]: + summary, why = "Service disabled", "the next task will try to enable it" + else: + summary, why = "Not answering", "unlock the phone or reinstall with --force" + return ExtraRow( + key="accessibility_helper", + title="Artemis Accessibility Helper", + status="missing", + status_markup="[bold yellow]○ Pending[/bold yellow]", + summary=summary, + detail=f"{why}; pre-install now: artemis helper install --serial {serial}", + ) + + # --------------------------------------------------------------------------- # Rendering # --------------------------------------------------------------------------- @@ -373,6 +434,9 @@ def doctor_command( """Run diagnostics to inspect system dependencies, device connectivity, and configuration.""" results, fixes = asyncio.run(_diagnose(fix)) extras = [_npm_row(), _showcase_row()] + helper_row = _helper_row(results) + if helper_row is not None: + extras.append(helper_row) verdict = base_verdict(results) if json_output: diff --git a/artemis/interfaces/cli/commands/helper.py b/artemis/interfaces/cli/commands/helper.py new file mode 100644 index 0000000..9bc696a --- /dev/null +++ b/artemis/interfaces/cli/commands/helper.py @@ -0,0 +1,226 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""``artemis helper``: manage the Accessibility Helper APK on attached devices. + +Tasks provision the helper themselves when they take a device, so these +commands exist for the cases the automatic path deliberately leaves alone: +inspecting a device, forcing a reinstall, and removing the helper (which is +never done automatically). +""" + +from __future__ import annotations + +import json +from typing import Annotated + +from rich.console import Console +from rich.table import Table +import typer + +from artemis.runtime.device_pool import device_pool +from artemis.runtime.helper_manager import helper_manager +from artemis.utils.logger import get_logger + +logger = get_logger(__name__) +helper_app = typer.Typer(help="Install, inspect, or remove the Artemis Accessibility Helper APK.") + +SerialOption = Annotated[ + str | None, + typer.Option("--serial", "-s", help="Device serial. Defaults to the only ready device."), +] + + +def _resolve_serial(serial: str | None) -> str: + if serial: + return serial + ready = [d.serial for d in device_pool.get_ready_devices()] + if len(ready) == 1: + return ready[0] + if not ready: + typer.secho("No authorized device is attached.", fg=typer.colors.RED) + else: + typer.secho( + "Several devices are ready; pass --serial, or --all to target every idle one: " + + ", ".join(ready), + fg=typer.colors.RED, + ) + raise typer.Exit(code=1) + + +def _idle_ready_serials() -> list[str]: + """Every authorized device that no Artemis task currently holds.""" + return [d.serial for d in device_pool.get_ready_devices() if not d.is_busy] + + +def _bool(value: object) -> str: + return "[green]yes[/green]" if value else "[red]no[/red]" + + +@helper_app.command("status") +def helper_status( + serial: SerialOption = None, + as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False, +) -> None: + """Show install, version, service and tunnel state of the helper on a device.""" + target = _resolve_serial(serial) + status = helper_manager.status(target) + if as_json: + typer.echo(json.dumps(status, indent=2)) + return + table = Table(title=f"Accessibility helper on {target}") + table.add_column("Field", style="cyan") + table.add_column("Value") + table.add_row("Installed", _bool(status["installed"])) + version = str(status["installed_version"]) + if status.get("newer_than_bundled"): + version += f" [yellow](newer than the bundled v{status['bundled_version']}; not downgraded)[/yellow]" + elif status.get("outdated"): + version += f" [yellow](bundled is v{status['bundled_version']}; the next task upgrades it)[/yellow]" + table.add_row("Installed version", version) + table.add_row("Bundled version", str(status["bundled_version"])) + table.add_row("Service enabled", _bool(status["enabled"])) + tunnel = { + "session": f"this process, host port {status['forward_port']}", + "shared": f"another Artemis process, host port {status['forward_port']}", + "probe": "none (probed through a temporary forward)", + None: "none", + }[status.get("tunnel")] + table.add_row("Tunnel", tunnel) + table.add_row("Service answering", _bool(status["reachable"])) + if status.get("reachable"): + protocol = status.get("protocol_version") + proto_text = str(protocol) + if not status.get("protocol_supported"): + proto_text += " [red](too old for this host; reinstall with --force)[/red]" + table.add_row("Protocol", proto_text) + table.add_row("Session token pushed", _bool(status.get("token_set"))) + table.add_row("Auto-install by tasks", _bool(status.get("auto_install", True))) + table.add_row("Transport id", str(status["transport_id"])) + Console().print(table) + + +def _install_one(target: str, force: bool) -> bool: + def on_event(event: str, details: dict) -> None: + if event == "installing": + typer.echo(f"{target}: installing v{details.get('version_name')}...") + elif event == "upgrading": + typer.echo( + f"{target}: upgrading v{details.get('from_version')} -> " + f"v{details.get('to_version')}..." + ) + + result = helper_manager.provision(target, force=force, on_event=on_event) + if result.ok: + typer.secho( + f"{target}: {result.action} (version {result.installed_version}, service enabled).", + fg=typer.colors.GREEN, + ) + return True + typer.secho(f"{target}: {result.action}: {result.error}", fg=typer.colors.RED) + return False + + +@helper_app.command("install") +def helper_install( + serial: SerialOption = None, + force: Annotated[bool, typer.Option("--force", help="Reinstall even when up to date.")] = False, + all_devices: Annotated[ + bool, typer.Option("--all", help="Every authorized device that no task is using.") + ] = False, +) -> None: + """Install or upgrade the helper to the bundled version and enable its service.""" + if all_devices: + targets = _idle_ready_serials() + if not targets: + typer.secho("No idle, authorized device is attached.", fg=typer.colors.RED) + raise typer.Exit(code=1) + else: + targets = [_resolve_serial(serial)] + outcomes = [_install_one(target, force) for target in targets] + if not all(outcomes): + raise typer.Exit(code=1) + + +@helper_app.command("uninstall") +def helper_uninstall( + serial: SerialOption = None, + yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False, +) -> None: + """Disable the service and remove the helper package from a device.""" + target = _resolve_serial(serial) + if not yes and not typer.confirm(f"Remove the Artemis accessibility helper from {target}?"): + raise typer.Exit(code=0) + if helper_manager.uninstall(target): + typer.secho(f"{target}: helper removed.", fg=typer.colors.GREEN) + return + typer.secho(f"{target}: uninstall failed (is the package installed?).", fg=typer.colors.RED) + raise typer.Exit(code=1) + + +@helper_app.command("parity") +def helper_parity( + serial: SerialOption = None, + rounds: Annotated[int, typer.Option("--rounds", help="Timed dumps per backend.")] = 3, + as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False, +) -> None: + """Dump the current screen with the helper and with UIAutomator2 and compare them. + + The two backends must describe the same visible screen: every labelled + UIAutomator element should have a helper element with the same label at + the same place, the helper must emit no off-screen or negative bounds, and + its dump should not be slower. Run it on a busy screen (a scrolled list, a + dialog over an app) to catch regressions the unit tests cannot see. + """ + from artemis.core.diagnostics.hierarchy_parity import compare_backends + + target = _resolve_serial(serial) + report = compare_backends(target, rounds=max(1, rounds)) + if as_json: + typer.echo(json.dumps(report, indent=2, ensure_ascii=False)) + raise typer.Exit(code=0 if report["ok"] else 1) + + table = Table(title=f"Hierarchy parity on {target}") + table.add_column("Metric", style="cyan") + table.add_column("Helper") + table.add_column("UIAutomator2") + h, u = report["helper"], report["uiautomator"] + table.add_row("Dump time, median", f"{h['median_ms']:.0f} ms", f"{u['median_ms']:.0f} ms") + table.add_row("Nodes", str(h["nodes"]), str(u["nodes"])) + table.add_row("Labelled nodes (text / desc)", str(h["labelled"]), str(u["labelled"])) + table.add_row("Negative bounds", str(h["negative_bounds"]), str(u["negative_bounds"])) + table.add_row("Off-screen bounds", str(h["offscreen_bounds"]), str(u["offscreen_bounds"])) + table.add_row("Foreground package", str(h["package"]), str(u["package"])) + Console().print(table) + + m = report["match"] + typer.echo( + f"UIAutomator labels found in helper dump: {m['uiautomator_in_helper']}/{m['uiautomator_labelled']}" + f" ({m['recall']:.0%}); helper labels found in UIAutomator dump: " + f"{m['helper_in_uiautomator']}/{m['helper_labelled']} ({m['precision']:.0%})" + ) + for label in m["missing_in_helper"][:10]: + typer.echo(f" helper lacks: {label}") + for label in m["extra_in_helper"][:10]: + typer.echo(f" helper adds: {label}") + for problem in report["problems"]: + typer.secho(f" ! {problem}", fg=typer.colors.RED) + typer.secho( + "PARITY OK" if report["ok"] else "PARITY FAILED", + fg=typer.colors.GREEN if report["ok"] else typer.colors.RED, + ) + raise typer.Exit(code=0 if report["ok"] else 1) + + +__all__ = ["helper_app"] diff --git a/artemis/interfaces/cli/main.py b/artemis/interfaces/cli/main.py index fe42218..d11cbed 100644 --- a/artemis/interfaces/cli/main.py +++ b/artemis/interfaces/cli/main.py @@ -19,6 +19,7 @@ from typing import Annotated from artemis._version import __version__ from artemis.interfaces.cli.commands.batch import batch_command from artemis.interfaces.cli.commands.doctor import doctor_command +from artemis.interfaces.cli.commands.helper import helper_app from artemis.interfaces.cli.commands.init import init_command from artemis.interfaces.cli.commands.mcp import mcp_command from artemis.interfaces.cli.commands.run import run_command @@ -63,6 +64,9 @@ app.command(name="batch", help="Execute a batch sequence of automation tasks.")( app.command(name="mcp", help="Start the Artemis Model Context Protocol (MCP) server.")(mcp_command) app.add_typer(server_app, name="server", help="Cloud Run proxy and web dashboard server.") app.add_typer(trace_app, name="trace", help="Inspect and query execution traces.") +app.add_typer( + helper_app, name="helper", help="Manage the Accessibility Helper APK on attached devices." +) def version_callback(value: bool): diff --git a/artemis/mcp/adb_server.py b/artemis/mcp/adb_server.py index 507b103..be2b71a 100644 --- a/artemis/mcp/adb_server.py +++ b/artemis/mcp/adb_server.py @@ -39,7 +39,7 @@ except Exception as exc: # pylint: disable=broad-exception-caught # Best-effort compatibility shim for FastMCP/pydantic version drift. logger.debug("FastMCP Settings model_rebuild skipped: %s", exc, exc_info=True) -from artemis.clients.ui_automator_client import UIAutomatorClient +from artemis.clients.screen_client_factory import create_screen_client from artemis.context import ArtemisContext, DeviceContext, DevicePlatform from artemis.controllers.unified_controller import UnifiedMobileController from artemis.platform import platform @@ -146,7 +146,10 @@ def _get_controller(device_serial: str | None = None): device = devices[0] device_id = device.serial - ui_client = UIAutomatorClient(device_id=device_id) + # Observer path: the helper is used only when it is already installed and + # running; nothing is installed from here (that happens inside a task's + # device-lock boundary or via `artemis helper install`). + ui_client = create_screen_client(device_id) try: ui_data = ui_client.get_screen_data() width, height = ui_data.width, ui_data.height diff --git a/artemis/runtime/helper_manager.py b/artemis/runtime/helper_manager.py new file mode 100644 index 0000000..3196d5e --- /dev/null +++ b/artemis/runtime/helper_manager.py @@ -0,0 +1,1030 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Lifecycle of the Artemis Accessibility Helper APK on each Android device. + +The helper (``packages/artemis-accessibility-helper``) is an accessibility +service that serves the UI hierarchy over a loopback HTTP port on the phone. +This module owns everything *around* that HTTP API, split by lifetime: + +* **Provision** (persistent on the device, survives reboots): the APK is + installed at the bundled version and the service is enabled in secure + settings. Only callers that hold the device (a task inside its + ``DeviceExecutionLock`` boundary, ``artemis helper install``, + ``mobile_diagnose(attempt_fix=True)``) provision; read-only observers never + install anything. A cross-process file mutex serialises concurrent installs. +* **Attach** (one process, one device, one session): an ``adb forward`` from a + host port that adb allocates (``tcp:0``) to the fixed device port. Existing + forwards for the same serial are reused so two Artemis processes share one + tunnel; the local port is the identity of the tunnel, never a fixed number, + which is what keeps several devices on one host apart. +* **Serve** (per request): handled by :class:`AccessibilityClient`; on a + transport error it calls :meth:`AccessibilityHelperManager.reattach` once. +* **Detach / evict**: forwards this process created are removed on detach. + A physical unplug needs no host work at all: adb drops the forward with the + transport and the on-device service keeps running. The ``transport_id`` that + ``adb devices -l`` reports changes on every replug, so a session records it + and :meth:`attach` treats a changed id as "stale, rebuild the tunnel". +* **Token**: the loopback port is reachable by every app on the phone, so the + helper serves nothing (except ``/ping``) without the session token this host + pushed. The token is one random secret per host, kept in a file under the + Artemis temp directory so every Artemis process on the machine presents the + same one, and delivered with an ``am broadcast`` that only the adb shell user + can send (the receiver is guarded by ``WRITE_SECURE_SETTINGS``). It is pushed + on every attach and again whenever the helper answers 401 (a re-bound service + starts without one). + +Locking is per device: provisioning one phone (up to a minute when the install +is slow) never blocks attaching to another. + +**UiAutomation suppresses the helper.** Android unbinds every accessibility +service while a ``UiAutomation`` connection created without +``FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES`` is alive. uiautomator2's device +server (``app_process ... com.wetest.uia2.Main``) and Appium's UiAutomator2 +server both do that by default, so the helper is silent for as long as one of +them runs. When the helper does not answer, :meth:`attach` looks for such a +holder: uiautomator2's server (Artemis's own fallback, or one left behind by a +previous run) is stopped so the system re-binds the helper within a second; +Appium's is never touched, the error names it instead. +""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import subprocess +import threading +import time +from typing import Any +import urllib.error +import urllib.request + +from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL +from artemis.config.paths import get_temp_dir +from artemis.runtime.adb_endpoint import adb_command +from artemis.utils.logger import get_logger + +logger = get_logger(__name__) + +PACKAGE_NAME = "com.artemis.helper" +SERVICE_NAME = f"{PACKAGE_NAME}/.ArtemisAccessibilityService" +TOKEN_RECEIVER = f"{PACKAGE_NAME}/.TokenReceiver" +TOKEN_ACTION = f"{PACKAGE_NAME}.SET_TOKEN" +#: Loopback port the service binds on the device. Never used as a host port. +DEVICE_PORT = 18888 +#: Oldest helper HTTP contract this host can talk to (see PROTOCOL_VERSION in the APK). +MIN_PROTOCOL_VERSION = 2 +TOKEN_FILE_NAME = "session.token" + +HELPER_PACKAGE_DIR = ( + Path(__file__).resolve().parent.parent.parent / "packages" / "artemis-accessibility-helper" +) +BUNDLED_APK_PATH = HELPER_PACKAGE_DIR / "ArtemisAccessibilityHelper.apk" +BUNDLED_MANIFEST_PATH = HELPER_PACKAGE_DIR / "helper_manifest.json" + +_PING_ATTEMPTS_AFTER_PROVISION = 12 +_PING_ATTEMPTS_LAZY = 3 +_PING_INTERVAL_SECONDS = 0.5 +_UIAUTOMATION_RELEASE_SETTLE_SECONDS = 1.0 + +#: Process command-line markers of UiAutomation holders. The first group is +#: uiautomator2 (openatx), which Artemis itself uses as the fallback backend and +#: may stop; the second is Appium's server, which belongs to someone else. +_UIAUTOMATOR2_MARKERS = ("com.wetest.uia2.Main", "com.github.uiautomator") +_APPIUM_MARKERS = ("io.appium.uiautomator2",) +_PROVISION_MUTEX_TIMEOUT_SECONDS = 90.0 +_PROVISION_MUTEX_STALE_SECONDS = 180.0 +_ENABLE_ATTEMPTS = 5 +_ENABLE_SETTLE_SECONDS = 0.4 +_REVIVE_SETTLE_SECONDS = 0.3 + +AdbRunner = Callable[[list[str]], subprocess.CompletedProcess] +Pinger = Callable[[int], dict[str, Any] | None] +#: Progress callback: ``(event, details)`` with events ``installing`` / ``upgrading`` / +#: ``enabling`` fired *before* the slow step so a UI can show what is happening. +ProvisionEvent = Callable[[str, dict[str, Any]], None] + +#: Where a person enables the service by hand when secure settings are rejected +#: (some OEM ROMs and Android 13+ restricted settings for sideloaded apps). +MANUAL_ENABLE_PATH = ( + "Settings > Accessibility > Installed apps (or Downloaded apps) > " + "Artemis Accessibility Helper > turn it on" +) +ACCESSIBILITY_SETTINGS_INTENT = "android.settings.ACCESSIBILITY_SETTINGS" + + +class HelperUnavailable(RuntimeError): + """The helper could not be reached on a device (not installed, disabled, or dead).""" + + +def auto_install_allowed() -> bool: + """Whether a task may install / upgrade the helper on a device it holds. + + ``ARTEMIS_HELPER_AUTO_INSTALL`` (environment, then settings); default true. + """ + raw = os.environ.get(ENV_ARTEMIS_HELPER_AUTO_INSTALL) + if raw is None: + try: + from artemis.config import settings + + value = getattr(settings, "ARTEMIS_HELPER_AUTO_INSTALL", True) + return ( + bool(value) + if not isinstance(value, str) + else value.strip().lower() + not in ( + "0", + "false", + "no", + "off", + ) + ) + except (ImportError, ValueError): + return True + return raw.strip().lower() not in ("0", "false", "no", "off") + + +@dataclass(frozen=True) +class BundledHelper: + """The APK shipped in the repository, described by ``helper_manifest.json``.""" + + apk_path: Path + version_code: int + version_name: str + sha256: str + + def to_dict(self) -> dict[str, Any]: + return { + "apk_path": str(self.apk_path), + "version_code": self.version_code, + "version_name": self.version_name, + "sha256": self.sha256, + } + + +def load_bundled_helper( + apk_path: Path = BUNDLED_APK_PATH, manifest_path: Path = BUNDLED_MANIFEST_PATH +) -> BundledHelper | None: + """Read the bundled APK's manifest; ``None`` when the artifact is not shipped.""" + if not apk_path.is_file() or not manifest_path.is_file(): + return None + try: + data = json.loads(manifest_path.read_text(encoding="utf-8")) + return BundledHelper( + apk_path=apk_path, + version_code=int(data["version_code"]), + version_name=str(data.get("version_name", "")), + sha256=str(data.get("sha256", "")), + ) + except (OSError, ValueError, KeyError, TypeError) as exc: + logger.warning(f"Bundled accessibility helper manifest is unreadable: {exc}") + return None + + +@dataclass +class HelperSession: + """One process's live tunnel to the helper on one device.""" + + serial: str + local_port: int + transport_id: str | None + version_code: int | None + version_name: str + owns_forward: bool + attached_at: float = field(default_factory=time.time) + #: Session token the helper expects in ``X-Artemis-Token``; None for pre-token helpers. + token: str | None = field(default=None, repr=False) + protocol_version: int = 1 + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.local_port}" + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data.pop("token", None) + data["base_url"] = self.base_url + return data + + +@dataclass +class ProvisionResult: + ok: bool + action: str # "up_to_date" | "installed" | "upgraded" | "skipped" | "failed" + installed_version: int | None + bundled_version: int | None + enabled: bool + error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _default_run_adb(args: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess: + return subprocess.run( + adb_command(args), + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def _default_ping(local_port: int, timeout: float = 2.0) -> dict[str, Any] | None: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{local_port}/ping", timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError, TimeoutError): + return None + if not isinstance(data, dict) or not data.get("success"): + return None + return data + + +class AccessibilityHelperManager: + """Process-wide registry of helper sessions, keyed by device serial.""" + + def __init__( + self, + run_adb: AdbRunner | None = None, + ping: Pinger | None = None, + bundled: BundledHelper | None | Callable[[], BundledHelper | None] = load_bundled_helper, + sleep: Callable[[float], None] = time.sleep, + token_path: Path | None = None, + auto_install: Callable[[], bool] = auto_install_allowed, + ) -> None: + self._run_adb = run_adb or _default_run_adb + self._ping = ping or _default_ping + self._bundled_source = bundled + self._sleep = sleep + self._token_path = token_path + self._auto_install = auto_install + self._token: str | None = None + self._sessions: dict[str, HelperSession] = {} + #: Guards the session registry and the per-device lock table only. + self._registry_lock = threading.Lock() + self._device_locks: dict[str, threading.RLock] = {} + + def _device_lock(self, serial: str) -> threading.RLock: + """One re-entrant lock per device, so devices provision and attach in parallel.""" + with self._registry_lock: + lock = self._device_locks.get(serial) + if lock is None: + lock = self._device_locks[serial] = threading.RLock() + return lock + + # ------------------------------------------------------------------ # + # Bundled artifact + # ------------------------------------------------------------------ # + + @property + def bundled(self) -> BundledHelper | None: + source = self._bundled_source + return source() if callable(source) else source + + # ------------------------------------------------------------------ # + # Session token + # ------------------------------------------------------------------ # + + def host_token(self) -> str: + """The per-host secret every Artemis process presents to the helper. + + Created on first use (48 hex chars) in the Artemis temp directory with + owner-only permissions; shared by the MCP daemon, the CLI and the SDK. + """ + if self._token: + return self._token + path = self._token_path or (get_temp_dir("helper-token") / TOKEN_FILE_NAME) + with self._provision_mutex(f"host-token:{path.resolve()}"): + return self._load_or_create_token(path) + + def _load_or_create_token(self, path: Path) -> str: + try: + text = path.read_text(encoding="utf-8").strip() + if re.fullmatch(r"[0-9a-f]{32,128}", text): + self._token = text + return text + except OSError: + pass + token = secrets.token_hex(24) + try: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(token) + except OSError as exc: + # No persistent file (read-only temp?): a process-local token still + # protects the helper; other processes will simply push their own. + logger.warning(f"Could not persist the helper session token at {path}: {exc}") + self._token = token + return token + + def push_token(self, serial: str) -> bool: + """Deliver the host token to the helper on ``serial`` (idempotent). + + The broadcast is explicit (component named) so it reaches the manifest + receiver on every Android version, and it is sent by the adb shell user, + the only sender the receiver's permission guard admits. + """ + result = self._adb( + serial, + "shell", + "am", + "broadcast", + "-n", + TOKEN_RECEIVER, + "-a", + TOKEN_ACTION, + "--es", + "token", + self.host_token(), + ) + output = f"{result.stdout or ''}\n{result.stderr or ''}" + ok = result.returncode == 0 and "Broadcast completed" in output + if not ok: + logger.warning( + f"Pushing the session token to the accessibility helper on {serial} failed: " + f"adb exited with code {result.returncode}." + ) + return ok + + # ------------------------------------------------------------------ # + # Device queries (no side effects) + # ------------------------------------------------------------------ # + + def _adb(self, serial: str, *args: str) -> subprocess.CompletedProcess: + return self._run_adb(["-s", serial, *args]) + + def transport_id(self, serial: str) -> str | None: + """The adb transport id of ``serial``; changes every time the device reconnects.""" + try: + result = self._run_adb(["devices", "-l"]) + except (OSError, subprocess.SubprocessError) as exc: + logger.debug(f"adb devices -l failed while reading transport id: {exc}") + return None + for line in (result.stdout or "").splitlines(): + parts = line.split() + if len(parts) < 2 or parts[0] != serial: + continue + for token in parts[2:]: + if token.startswith("transport_id:"): + return token.split(":", 1)[1] + return None + return None + + def installed_version(self, serial: str) -> int | None: + """versionCode of the helper on the device, or ``None`` when it is not installed.""" + result = self._adb(serial, "shell", "dumpsys", "package", PACKAGE_NAME) + output = result.stdout or "" + if result.returncode != 0 or "Unable to find package" in output: + return None + match = re.search(r"versionCode=(\d+)", output) + if match is None: + # dumpsys answered but without a version block: the package is absent. + return None + return int(match.group(1)) + + def is_service_enabled(self, serial: str) -> bool: + result = self._adb( + serial, "shell", "settings", "get", "secure", "enabled_accessibility_services" + ) + enabled = (result.stdout or "").strip() + return SERVICE_NAME in enabled.split(":") if enabled and enabled != "null" else False + + def existing_forward(self, serial: str) -> int | None: + """Host port of an adb forward that already targets the helper on ``serial``.""" + result = self._run_adb(["forward", "--list"]) + for line in (result.stdout or "").splitlines(): + parts = line.split() + if len(parts) != 3 or parts[0] != serial: + continue + local, remote = parts[1], parts[2] + if remote == f"tcp:{DEVICE_PORT}" and local.startswith("tcp:"): + try: + return int(local[4:]) + except ValueError: + continue + return None + + def ping(self, local_port: int) -> dict[str, Any] | None: + return self._ping(local_port) + + def uiautomation_holders(self, serial: str) -> dict[str, list[int]]: + """Processes on ``serial`` holding a UiAutomation connection that mutes the helper. + + Returns ``{"uiautomator2": [pids], "appium": [pids]}``. + """ + holders: dict[str, list[int]] = {"uiautomator2": [], "appium": []} + try: + result = self._adb(serial, "shell", "ps", "-A", "-o", "PID,ARGS") + except (OSError, subprocess.SubprocessError) as exc: + logger.debug(f"ps on {serial} failed while looking for UiAutomation holders: {exc}") + return holders + for line in (result.stdout or "").splitlines(): + parts = line.split(None, 1) + if len(parts) != 2 or not parts[0].isdigit(): + continue + pid, args = int(parts[0]), parts[1] + if args.lstrip().startswith("sh "): + continue # the `sh -c` wrapper around app_process, not the holder itself + if any(marker in args for marker in _UIAUTOMATOR2_MARKERS): + holders["uiautomator2"].append(pid) + elif any(marker in args for marker in _APPIUM_MARKERS): + holders["appium"].append(pid) + return holders + + def stop_uiautomator2_server(self, serial: str, pids: list[int] | None = None) -> bool: + """Kill uiautomator2's device server so Android re-binds the helper. + + Only ever aimed at openatx's server (``u2.jar`` / ``com.github.uiautomator``), + which Artemis owns as its fallback backend. Returns whether anything was stopped. + """ + if pids is None: + pids = self.uiautomation_holders(serial)["uiautomator2"] + stopped = False + for pid in pids: + result = self._adb(serial, "shell", "kill", str(pid)) + stopped = stopped or result.returncode == 0 + result = self._adb(serial, "shell", "am", "force-stop", "com.github.uiautomator") + stopped = stopped or (result.returncode == 0 and bool(pids)) + if stopped: + logger.info( + f"Stopped uiautomator2's device server on {serial} so the accessibility helper " + "can bind again (UiAutomation suppresses other accessibility services)." + ) + self._sleep(_UIAUTOMATION_RELEASE_SETTLE_SECONDS) + return stopped + + def session(self, serial: str) -> HelperSession | None: + with self._registry_lock: + return self._sessions.get(serial) + + def status(self, serial: str) -> dict[str, Any]: + """Everything a diagnostic wants to know, without changing the device. + + ``reachable`` answers "is the service alive on the phone", not "does + this process have a tunnel": when no forward exists yet a temporary one + is created for the probe and removed again. ``tunnel`` says which it was. + """ + bundled = self.bundled + installed = self.installed_version(serial) + enabled = self.is_service_enabled(serial) if installed is not None else False + session = self.session(serial) + tunnel: str | None + forward_port: int | None + if session is not None: + forward_port, tunnel = session.local_port, "session" + else: + forward_port = self.existing_forward(serial) + tunnel = "shared" if forward_port is not None else None + ping = None + if forward_port is not None: + ping = self.ping(forward_port) + elif installed is not None and enabled: + ping = self._probe_with_temporary_forward(serial) + tunnel = "probe" + return { + "package": PACKAGE_NAME, + "installed": installed is not None, + "installed_version": installed, + "bundled_version": bundled.version_code if bundled else None, + "bundled_apk_present": bundled is not None, + "outdated": bool( + bundled and installed is not None and installed < bundled.version_code + ), + "newer_than_bundled": bool( + bundled and installed is not None and installed > bundled.version_code + ), + "enabled": enabled, + "forward_port": forward_port, + "tunnel": tunnel, + "reachable": ping is not None, + "reported_version": ping.get("version_code") if ping else None, + "protocol_version": ping.get("protocol_version", 1) if ping else None, + "protocol_supported": bool( + ping and int(ping.get("protocol_version", 1)) >= MIN_PROTOCOL_VERSION + ), + "token_set": bool(ping.get("token_set")) if ping else None, + "auto_install": self._auto_install(), + "transport_id": self.transport_id(serial), + "session": session.to_dict() if session else None, + } + + def _probe_with_temporary_forward(self, serial: str) -> dict[str, Any] | None: + try: + port = self._create_forward(serial) + except HelperUnavailable: + return None + try: + return self.ping(port) + finally: + self._remove_forward(serial, port) + + # ------------------------------------------------------------------ # + # Provision: install / upgrade / enable (persistent device state) + # ------------------------------------------------------------------ # + + @contextmanager + def _provision_mutex(self, serial: str): + """Serialise installs across Artemis processes sharing one device.""" + root = get_temp_dir("helper-provision") + root.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16] + path = root / f"{digest}.mutex" + started = time.monotonic() + while True: + try: + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except (FileExistsError, PermissionError) as exc: + # Windows may report access denied while a lock file is being deleted. + if isinstance(exc, PermissionError) and os.name != "nt": + raise + if time.monotonic() - started > _PROVISION_MUTEX_TIMEOUT_SECONDS: + raise TimeoutError( + f"Timed out waiting for the accessibility helper lock on {serial}." + ) from exc + try: + age = time.time() - path.stat().st_mtime + except OSError: + self._sleep(0.1) + continue + if age > _PROVISION_MUTEX_STALE_SECONDS: + path.unlink(missing_ok=True) + continue + if time.monotonic() - started > _PROVISION_MUTEX_TIMEOUT_SECONDS: + raise TimeoutError( + f"Timed out waiting for another process to finish installing the " + f"accessibility helper on {serial}." + ) + self._sleep(0.1) + else: + try: + os.write(fd, str(os.getpid()).encode("ascii")) + finally: + os.close(fd) + break + try: + yield + finally: + path.unlink(missing_ok=True) + + def _enable_service(self, serial: str) -> bool: + """Add the service to secure settings and confirm the write stuck. + + Right after ``pm install`` (and right after boot) AccessibilityManager + may still not resolve the component and prunes it from the setting + again, so a single write followed by an immediate read can report + success for a value that is gone a moment later. Re-read after a short + delay and retry a few times before giving up. + """ + for attempt in range(_ENABLE_ATTEMPTS): + result = self._adb( + serial, "shell", "settings", "get", "secure", "enabled_accessibility_services" + ) + current = (result.stdout or "").strip() + services = [s for s in current.split(":") if s and s != "null"] if current else [] + if SERVICE_NAME in services: + # Already enabled (the common, up-to-date case): no settle wait. + self._adb( + serial, "shell", "settings", "put", "secure", "accessibility_enabled", "1" + ) + return True + services.append(SERVICE_NAME) + self._adb( + serial, + "shell", + "settings", + "put", + "secure", + "enabled_accessibility_services", + ":".join(services), + ) + self._adb(serial, "shell", "settings", "put", "secure", "accessibility_enabled", "1") + self._sleep(_ENABLE_SETTLE_SECONDS) + if self.is_service_enabled(serial): + return True + logger.debug( + f"Accessibility service setting on {serial} did not stick " + f"(attempt {attempt + 1}/{_ENABLE_ATTEMPTS}); retrying." + ) + return False + + def _revive_service(self, serial: str) -> None: + """Make AccessibilityManager rebind a service that stopped answering. + + A force-stopped (or ROM-killed) accessibility service stays dead even + though it is still listed as enabled; removing and re-adding it in the + secure setting is what makes the system bind it again. Persistent state + ends up exactly as before, so this is a repair, not provisioning. + """ + result = self._adb( + serial, "shell", "settings", "get", "secure", "enabled_accessibility_services" + ) + current = (result.stdout or "").strip() + others = [s for s in current.split(":") if s and s != "null" and s != SERVICE_NAME] + self._adb( + serial, + "shell", + "settings", + "put", + "secure", + "enabled_accessibility_services", + ":".join(others) if others else "null", + ) + self._sleep(_REVIVE_SETTLE_SECONDS) + self._adb( + serial, + "shell", + "settings", + "put", + "secure", + "enabled_accessibility_services", + ":".join([*others, SERVICE_NAME]), + ) + self._adb(serial, "shell", "settings", "put", "secure", "accessibility_enabled", "1") + logger.info(f"Re-bound the accessibility helper service on {serial}.") + + def provision( + self, + serial: str, + *, + force: bool = False, + on_event: ProvisionEvent | None = None, + install: bool = True, + ) -> ProvisionResult: + """Install or upgrade the helper to the bundled version and enable the service. + + Idempotent: an up-to-date, enabled helper costs two ``adb shell`` calls. + A device running a *newer* helper than the bundle is left alone. + ``on_event`` is told ``installing`` / ``upgrading`` before the install. + ``install=False`` (auto-install disabled) never runs ``adb install``: a + missing helper is reported as a failure that names the manual command, + an outdated one is used as is. + """ + bundled = self.bundled + + def emit(event: str, **details: Any) -> None: + if on_event is None: + return + try: + on_event(event, {"serial": serial, **details}) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.debug(f"Provision event callback failed: {exc}") + + with self._provision_mutex(serial): + installed = self.installed_version(serial) + action = "up_to_date" + needs_install = ( + force + or installed is None + or (bundled is not None and installed < bundled.version_code) + ) + if needs_install and not install: + if installed is None: + return ProvisionResult( + ok=False, + action="failed", + installed_version=None, + bundled_version=bundled.version_code if bundled else None, + enabled=False, + error=( + "The accessibility helper is not installed on this device and " + f"{ENV_ARTEMIS_HELPER_AUTO_INSTALL}=false forbids installing it " + f"from a task. Install it once with: artemis helper install " + f"--serial {serial}" + ), + ) + logger.info( + f"Accessibility helper on {serial} is v{installed} (bundled " + f"v{bundled.version_code if bundled else '?'}); not upgrading because " + f"{ENV_ARTEMIS_HELPER_AUTO_INSTALL}=false." + ) + action = "skipped" + needs_install = False + if needs_install: + if bundled is None: + if installed is None: + return ProvisionResult( + ok=False, + action="failed", + installed_version=None, + bundled_version=None, + enabled=False, + error=( + "The accessibility helper is not installed and no bundled APK " + f"was found at {BUNDLED_APK_PATH}." + ), + ) + action = "skipped" + else: + # This is the one moment Artemis changes what is installed on the + # phone; say so in a way that survives in every log. + logger.warning( + f"Installing the Artemis accessibility helper v{bundled.version_name} " + f"(code {bundled.version_code}) on {serial}. It is an accessibility " + "service that reads the screen layout for automated tests, answers only " + "on the phone's loopback port to a host holding the session token, and " + "stays installed until removed with `artemis helper uninstall`. Set " + f"{ENV_ARTEMIS_HELPER_AUTO_INSTALL}=false to install it by hand only." + ) + emit( + "installing" if installed is None else "upgrading", + from_version=installed, + to_version=bundled.version_code, + version_name=bundled.version_name, + ) + result = self._adb(serial, "install", "-r", "-g", str(bundled.apk_path)) + output = f"{result.stdout or ''}\n{result.stderr or ''}" + if result.returncode != 0 or "Success" not in output: + return ProvisionResult( + ok=False, + action="failed", + installed_version=installed, + bundled_version=bundled.version_code, + enabled=False, + error=f"adb install failed: {output.strip()[-400:]}", + ) + action = "installed" if installed is None else "upgraded" + installed = self.installed_version(serial) + enabled = self._enable_service(serial) + if not enabled: + # Secure settings were rejected (OEM ROM or restricted settings): + # open the Accessibility settings screen so the person only has to + # flip the switch, and say exactly where it is. + self._adb(serial, "shell", "am", "start", "-a", ACCESSIBILITY_SETTINGS_INTENT) + return ProvisionResult( + ok=enabled, + action=action, + installed_version=installed, + bundled_version=bundled.version_code if bundled else None, + enabled=enabled, + error=None + if enabled + else ( + "This device rejected enabling the accessibility service from adb. " + f"The Accessibility settings screen was opened on {serial}; enable it by hand: " + f"{MANUAL_ENABLE_PATH}." + ), + ) + + def uninstall(self, serial: str) -> bool: + """Remove the helper from the device (never done automatically).""" + self.detach(serial) + result = self._adb( + serial, "shell", "settings", "get", "secure", "enabled_accessibility_services" + ) + current = (result.stdout or "").strip() + services = [s for s in current.split(":") if s and s != "null" and s != SERVICE_NAME] + self._adb( + serial, + "shell", + "settings", + "put", + "secure", + "enabled_accessibility_services", + ":".join(services) if services else "null", + ) + result = self._adb(serial, "uninstall", PACKAGE_NAME) + return result.returncode == 0 and "Success" in (result.stdout or "") + + # ------------------------------------------------------------------ # + # Attach / detach (host-side tunnel) + # ------------------------------------------------------------------ # + + def _create_forward(self, serial: str) -> int: + result = self._adb(serial, "forward", "--no-rebind", "tcp:0", f"tcp:{DEVICE_PORT}") + if result.returncode != 0: + raise HelperUnavailable( + f"adb forward to the accessibility helper on {serial} failed: " + f"{(result.stderr or result.stdout or '').strip()}" + ) + text = (result.stdout or "").strip() + match = re.search(r"\b(\d{2,5})\b", text) + if match is not None: + return int(match.group(1)) + existing = self.existing_forward(serial) + if existing is None: + raise HelperUnavailable( + f"adb forward on {serial} did not report the allocated host port (output: {text!r})." + ) + return existing + + def _remove_forward(self, serial: str, local_port: int) -> None: + try: + self._adb(serial, "forward", "--remove", f"tcp:{local_port}") + except (OSError, subprocess.SubprocessError) as exc: + logger.debug(f"Removing adb forward tcp:{local_port} on {serial} failed: {exc}") + + def _wait_for_ping(self, local_port: int, attempts: int) -> dict[str, Any] | None: + for attempt in range(attempts): + info = self.ping(local_port) + if info is not None: + return info + if attempt + 1 < attempts: + self._sleep(_PING_INTERVAL_SECONDS) + return None + + def attach( + self, + serial: str, + *, + provision: bool = True, + on_event: ProvisionEvent | None = None, + revive: bool | None = None, + ) -> HelperSession: + """Return a live session for ``serial``, building the tunnel when needed. + + ``provision=True`` (task path) installs / upgrades / enables first. + ``provision=False`` (observers) only connects to a helper that is + already running and raises :class:`HelperUnavailable` otherwise. + ``revive`` (defaults to ``provision``) allows re-binding a dead but + enabled service; :meth:`reattach` always does, because the caller + already owned a working session on this device. + """ + if revive is None: + revive = provision + with self._device_lock(serial): + transport = self.transport_id(serial) + existing = self.session(serial) + if existing is not None: + if existing.transport_id == transport and self.ping(existing.local_port): + return existing + logger.info( + f"Accessibility helper session on {serial} is stale " + f"(transport {existing.transport_id} -> {transport}); rebuilding the tunnel." + ) + self._drop_session(serial, remove_forward=existing.transport_id == transport) + + if provision: + result = self.provision(serial, on_event=on_event, install=self._auto_install()) + if not result.ok: + raise HelperUnavailable(result.error or "Provisioning the helper failed.") + + reused_port = self.existing_forward(serial) + owns_forward = reused_port is None + local_port = reused_port if reused_port is not None else self._create_forward(serial) + + attempts = _PING_ATTEMPTS_AFTER_PROVISION if provision else _PING_ATTEMPTS_LAZY + info = self._wait_for_ping(local_port, attempts) + suppressed_by: str | None = None + if info is None and self.installed_version(serial) is not None: + # Silent although installed. The usual cause is a UiAutomation + # holder (uiautomator2 / Appium) that made Android unbind every + # accessibility service; only then a killed service. + holders = self.uiautomation_holders(serial) + if holders["uiautomator2"] and revive: + self.stop_uiautomator2_server(serial, holders["uiautomator2"]) + info = self._wait_for_ping(local_port, _PING_ATTEMPTS_AFTER_PROVISION) + elif holders["uiautomator2"]: + suppressed_by = "uiautomator2's device server" + if info is None and holders["appium"]: + suppressed_by = "Appium's UiAutomator2 server" + if info is None and revive and suppressed_by is None: + # Installed and enabled but silent: the service was killed + # (force-stop, ROM task killer) or has not bound yet after an + # upgrade. Re-binding it is cheap and needs no install. + if self.is_service_enabled(serial): + self._revive_service(serial) + else: + self._enable_service(serial) + info = self._wait_for_ping(local_port, _PING_ATTEMPTS_AFTER_PROVISION) + if info is None: + if owns_forward: + self._remove_forward(serial, local_port) + if suppressed_by: + raise HelperUnavailable( + f"The accessibility helper on {serial} is suppressed by {suppressed_by}: " + "Android unbinds accessibility services while a UiAutomation connection " + "without FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES is alive. Stop that " + "server (for Appium, set the disableSuppressAccessibilityServices " + "capability) or run with ARTEMIS_HIERARCHY_BACKEND=uiautomator." + ) + raise HelperUnavailable( + f"The accessibility helper on {serial} is not answering on " + f"127.0.0.1:{local_port} (device port {DEVICE_PORT})." + ) + + protocol = _protocol_of(info) + if protocol < MIN_PROTOCOL_VERSION: + # A helper older than this host can talk to (a dev build with a + # newer versionCode but the old contract, or a downgrade). Reinstall + # the bundle when this caller may, otherwise say what to run. + bundled = self.bundled + if provision and self._auto_install() and bundled is not None: + logger.warning( + f"Accessibility helper on {serial} speaks protocol {protocol} " + f"(host needs >= {MIN_PROTOCOL_VERSION}); reinstalling the bundled " + f"v{bundled.version_name}." + ) + result = self.provision(serial, force=True, on_event=on_event) + if not result.ok: + raise HelperUnavailable(result.error or "Reinstalling the helper failed.") + info = self._wait_for_ping(local_port, _PING_ATTEMPTS_AFTER_PROVISION) + protocol = _protocol_of(info) if info else 0 + if protocol < MIN_PROTOCOL_VERSION: + if owns_forward: + self._remove_forward(serial, local_port) + raise HelperUnavailable( + f"The accessibility helper on {serial} speaks protocol {protocol}, " + f"this host needs {MIN_PROTOCOL_VERSION} or newer. Reinstall it with: " + f"artemis helper install --serial {serial} --force" + ) + + token: str | None = None + if info.get("auth_required", protocol >= 2): + token = self.host_token() + self.push_token(serial) + + version = info.get("version_code") + session = HelperSession( + serial=serial, + local_port=local_port, + transport_id=transport, + version_code=int(version) if isinstance(version, (int, float)) else None, + version_name=str(info.get("version_name") or ""), + owns_forward=owns_forward, + token=token, + protocol_version=protocol, + ) + with self._registry_lock: + self._sessions[serial] = session + logger.info( + f"Accessibility helper attached on {serial}: 127.0.0.1:{local_port} " + f"(v{session.version_name or '?'}, protocol {protocol}, transport {transport})" + ) + return session + + def reattach(self, serial: str) -> HelperSession: + """Rebuild the tunnel after a request failed; never installs anything. + + The caller had a working session, so a dead service is re-bound. + """ + with self._device_lock(serial): + self._drop_session(serial, remove_forward=True) + return self.attach(serial, provision=False, revive=True) + + def detach(self, serial: str) -> None: + with self._device_lock(serial): + self._drop_session(serial, remove_forward=True) + + def _drop_session(self, serial: str, *, remove_forward: bool) -> None: + with self._registry_lock: + session = self._sessions.pop(serial, None) + if session is None: + return + if remove_forward and session.owns_forward: + self._remove_forward(serial, session.local_port) + + def detach_all(self) -> None: + with self._registry_lock: + serials = list(self._sessions) + for serial in serials: + self.detach(serial) + + +def _protocol_of(info: dict[str, Any] | None) -> int: + try: + return int((info or {}).get("protocol_version", 1)) + except (TypeError, ValueError): + return 1 + + +helper_manager = AccessibilityHelperManager() + +__all__ = [ + "AccessibilityHelperManager", + "BundledHelper", + "DEVICE_PORT", + "HelperSession", + "HelperUnavailable", + "MANUAL_ENABLE_PATH", + "MIN_PROTOCOL_VERSION", + "PACKAGE_NAME", + "ProvisionEvent", + "ProvisionResult", + "SERVICE_NAME", + "TOKEN_ACTION", + "TOKEN_RECEIVER", + "auto_install_allowed", + "helper_manager", + "load_bundled_helper", +] diff --git a/artemis/runtime/trace_store.py b/artemis/runtime/trace_store.py index 09f99c8..7cb1f01 100644 --- a/artemis/runtime/trace_store.py +++ b/artemis/runtime/trace_store.py @@ -343,6 +343,20 @@ def update_trace_pid(trace_id: str, pid: int) -> dict[str, Any] | None: return data +def update_trace_fields(trace_id: str, **fields: Any) -> dict[str, Any] | None: + """Merge ``fields`` into status.json; no-op when the trace has no status file yet.""" + path = get_status_path(trace_id) + if not os.path.exists(path): + return None + with _status_lock(path): + data = read_status(trace_id) + if not data: + return None + data.update(fields) + write_status(trace_id, data) + return data + + def update_trace_device_serial(trace_id: str, device_serial: str) -> dict[str, Any] | None: """Updates the device_serial field of the status.json for a given trace_id.""" path = get_status_path(trace_id) diff --git a/artemis/sdk/agent.py b/artemis/sdk/agent.py index 913437c..389e677 100644 --- a/artemis/sdk/agent.py +++ b/artemis/sdk/agent.py @@ -13,6 +13,7 @@ # limitations under the License. import asyncio +import inspect import os import re @@ -29,7 +30,7 @@ from shutil import which import sys import threading from types import NoneType -from typing import TypeVar, overload +from typing import Any, TypeVar, overload import uuid from adbutils import AdbClient @@ -41,7 +42,13 @@ from pydantic import BaseModel from artemis.agents.flash.runner import FlashRunner from artemis.agents.outputter.outputter import outputter -from artemis.clients.ui_automator_client import UIAutomatorClient +from artemis.clients.screen_client_factory import ( + ScreenClient, + create_screen_client, + describe_backend, + helper_version, + hierarchy_backend_sentence, +) from artemis.config import ( CheckerConfig, OutputConfig, @@ -60,7 +67,7 @@ from artemis.controllers.controller_factory import get_controller from artemis.controllers.platform_specific_commands_controller import ( get_first_device, ) -from artemis.runtime import DeviceExecutionLock +from artemis.runtime import DeviceExecutionLock, trace_store from artemis.runtime.cancel_requests import watch_for_cancel_request from artemis.data_engine.engine import DataEngine from artemis.data_engine.trace import DataEngineCallbackHandler @@ -126,7 +133,7 @@ class Agent: _initialized: bool = False _device_context: DeviceContext _adb_client: AdbClient | None - _ui_adb_client: UIAutomatorClient | None + _ui_adb_client: ScreenClient | Any | None _current_task: asyncio.Task | None = None _task_lock: asyncio.Lock @@ -237,6 +244,8 @@ class Agent: platform=platform, ) else: + # Cloud mode is pinned to UIAutomator2 through the gateway; the + # hierarchy backend switch only applies to locally attached devices. from cloud_service.virtualization import RemoteAdbClient, RemoteUIAutomatorClient self._adb_client = RemoteAdbClient() @@ -632,13 +641,7 @@ class Agent: self._prepare_output_files(task=task) if os.environ.get("ARTEMIS_CLOUD_MODE") != "1": if self._ui_adb_client is not None: - publish_startup_progress( - "uiautomator", "Connecting to UI Automator", session_id=str(sess_id) - ) - await asyncio.to_thread(self._ui_adb_client.connect) - publish_startup_progress( - "uiautomator_ready", "UI Automator is ready", session_id=str(sess_id) - ) + await self._connect_screen_client(context, str(sess_id)) await self._ensure_device_unlocked() publish_startup_progress( "environment", "Preparing the device environment", session_id=str(sess_id) @@ -1277,6 +1280,141 @@ class Agent: def _get_graph_state(self, task: Task): return State.initial(task.request.goal) + # ------------------------------------------------------------------ # + # UI hierarchy backend: connect, and keep the user told which one serves + # ------------------------------------------------------------------ # + + async def _connect_screen_client(self, context: ArtemisContext, session_id: str) -> None: + """Connect the screen client with visible progress for slow first-time steps.""" + client = self._ui_adb_client + previous_listener = getattr(self, "_hierarchy_backend_listener", None) + if previous_listener is not None: + previous_client, listener = previous_listener + previous_client.remove_backend_listener(listener) + self._hierarchy_backend_listener = None + publish_startup_progress( + "uiautomator", "Connecting to the UI hierarchy service", session_id=session_id + ) + + def on_provision(event: str, details: dict) -> None: + version = details.get("version_name") or details.get("to_version") + if event == "installing": + publish_startup_progress( + "helper_install", + "Installing the Artemis accessibility helper on this device for the " + f"first time (v{version}, about 3 seconds)", + session_id=session_id, + **details, + ) + elif event == "upgrading": + publish_startup_progress( + "helper_upgrade", + "Upgrading the Artemis accessibility helper " + f"(v{details.get('from_version')} -> v{details.get('to_version')})", + session_id=session_id, + **details, + ) + + # Clients without provisioning (UIAutomator2, cloud) take no callback. + try: + accepts_events = "on_event" in inspect.signature(client.connect).parameters + except (TypeError, ValueError): + accepts_events = False + if accepts_events: + await asyncio.to_thread(client.connect, on_event=on_provision) + else: + await asyncio.to_thread(client.connect) + + backend = describe_backend(client) or "uiautomator" + publish_startup_progress( + "uiautomator_ready", + f"UI hierarchy service is ready ({backend})", + session_id=session_id, + ) + self._announce_hierarchy_backend(context, session_id, None, backend, None) + add_listener = getattr(client, "add_backend_listener", None) + if callable(add_listener): + + def listener(previous, new, reason): + self._announce_hierarchy_backend(context, session_id, previous, new, reason) + + add_listener(listener) + self._hierarchy_backend_listener = (client, listener) + + def _announce_hierarchy_backend( + self, + context: ArtemisContext, + session_id: str, + previous: str | None, + backend: str, + reason: str | None, + ) -> None: + """One line everywhere a person or an agent looks for it. + + Startup-progress event (UI timeline), a named log trace, the session's + device_info, and status.json for ``mobile_manage_task``. Runs from + worker threads too, so every sink is best effort. + """ + label = {"helper": "Artemis accessibility helper", "uiautomator": "UIAutomator2"} + version = helper_version(self._ui_adb_client) + if previous is None: + message = f"UI hierarchy source: {label.get(backend, backend)}" + if backend == "helper" and version: + message += f" v{version}" + stage = "hierarchy_backend" + else: + message = ( + f"UI hierarchy source switched from {label.get(previous, previous)} to " + f"{label.get(backend, backend)}" + ) + if reason: + message += f" because {reason}" + stage = "hierarchy_backend_changed" + logger.info(message) + try: + publish_startup_progress( + stage, + message, + session_id=session_id, + backend=backend, + previous_backend=previous, + reason=reason, + helper_version=version, + ) + except (OSError, ValueError, RuntimeError) as exc: + logger.debug(f"Could not publish hierarchy backend progress: {exc}") + engine = getattr(context, "data_engine", None) + if engine is not None and getattr(engine, "current_session_id", None): + try: + engine.record_trace( + type="log", + name="hierarchy_backend", + payload={ + "message": message, + "backend": backend, + "previous_backend": previous, + "reason": reason, + "helper_version": version, + "level": "WARNING" if previous is not None else "INFO", + }, + ) + engine.update_session_device_info( + hierarchy_backend=backend, + hierarchy_backend_note=hierarchy_backend_sentence( + self._ui_adb_client, relative_time=engine.get_relative_time + ), + ) + except (OSError, ValueError, RuntimeError) as exc: + logger.debug(f"Could not record hierarchy backend in the data engine: {exc}") + try: + trace_store.update_trace_fields( + session_id, + hierarchy_backend=backend, + hierarchy_backend_note=hierarchy_backend_sentence(self._ui_adb_client), + ) + except (OSError, ValueError, RuntimeError) as exc: + logger.debug(f"Could not record hierarchy backend in status.json: {exc}") + def _init_clients( self, device_id: str, @@ -1286,7 +1424,7 @@ class Agent: host=self._config.servers.adb_host, port=self._config.servers.adb_port, ) - self._ui_adb_client = UIAutomatorClient(device_id=device_id) + self._ui_adb_client = create_screen_client(device_id) async def _get_device_context( self, diff --git a/artemis/utils/ui_filter.py b/artemis/utils/ui_filter.py index 6f9f5ef..c68d768 100644 --- a/artemis/utils/ui_filter.py +++ b/artemis/utils/ui_filter.py @@ -59,7 +59,7 @@ def _parse_bounds(bounds: Any) -> dict[str, int] | None: return res if isinstance(bounds, str): - match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds) + match = re.match(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]", bounds) if match: x1, y1, x2, y2 = map(int, match.groups()) return {"left": x1, "top": y1, "right": x2, "bottom": y2} @@ -112,6 +112,11 @@ def _clip_bounds( if is_fully_clipped: node["is_clipped"] = True + # The cached parse is what every later consumer (and _parse_bounds itself) + # reads first; leaving it stale would hand out the unclipped rectangle. + if isinstance(node.get("parsed_bounds"), dict): + node["parsed_bounds"] = new_bounds.copy() + # Re-serialize bounds into the node matching original format orig = node.get("bounds") if isinstance(orig, str): diff --git a/artemis/utils/visualization.py b/artemis/utils/visualization.py index 705a601..c6c2ddd 100644 --- a/artemis/utils/visualization.py +++ b/artemis/utils/visualization.py @@ -398,29 +398,42 @@ def format_minimal_list_with_elements( idx += 1 registered = True - if not registered and text.strip() and bounds: + # An empty input field carries no text but usually a hint ("Search settings"); + # a rejected one carries an error ("Password too short"). Both come from the + # accessibility helper's dump and are exactly what the agent needs to see. + hint = str(node.get("hint") or "").strip() + error = str(node.get("error") or "").strip() + shown = text.strip() or hint + if not registered and shown and bounds: left, top, right, bottom = bounds cx, cy = get_center_coordinates(left, top, right, bottom) - if is_duplicate(text, cx, cy): + if is_duplicate(shown, cx, cy): continue norm_bounds = normalize_bounds(left, top, right, bottom) - lines.append(f"[{idx}] Text: '{text.strip()}' | Bounds: {norm_bounds}") + kind = "Text" if text.strip() else "Hint" + line = f"[{idx}] {kind}: '{shown}' | Bounds: {norm_bounds}" + if error: + line += f" | Error: '{error}'" + lines.append(line) items_bounds.append((idx, (left, top, right, bottom))) - elements.append( - { - "index": idx, - "center": [cx, cy], - "text": text.strip(), - "bounds": [left, top, right, bottom], - "class": node.get("class"), - "resource_id": node.get("resource-id"), - "is_ocr": False, - } - ) + element = { + "index": idx, + "center": [cx, cy], + "text": shown, + "bounds": [left, top, right, bottom], + "class": node.get("class"), + "resource_id": node.get("resource-id"), + "is_ocr": False, + } + if not text.strip(): + element["is_hint"] = True + if error: + element["error"] = error + elements.append(element) labels.append(str(idx)) - added_items.append((text.strip(), cx, cy)) + added_items.append((shown, cx, cy)) idx += 1 lines = _inject_mutual_occlusion_warnings(lines, items_bounds) diff --git a/mcp_server/tools/diagnose.py b/mcp_server/tools/diagnose.py index 9f443df..c62b522 100644 --- a/mcp_server/tools/diagnose.py +++ b/mcp_server/tools/diagnose.py @@ -24,6 +24,8 @@ to end (screenshot + UI hierarchy) to prove a task could actually start. from __future__ import annotations +import subprocess + import asyncio import os from pathlib import Path @@ -42,6 +44,7 @@ from artemis.core.diagnostics.readiness import ( ) from artemis.core.diagnostics.schema import ProbeResult, ProbeStatus, SystemReadinessReport from artemis.runtime import DeviceExecutionLock, trace_store +from artemis.runtime.helper_manager import helper_manager from artemis.utils.credentials_validator import validate_api_key from artemis.utils.logger import get_logger @@ -499,6 +502,159 @@ def _device_probe_steps(device_probe: dict[str, Any] | None) -> list[str]: return steps +# --------------------------------------------------------------------------- # +# Accessibility helper (UI hierarchy backend) +# --------------------------------------------------------------------------- # + + +def _hierarchy_backend() -> str: + try: + from artemis.clients.screen_client_factory import resolve_backend + + return resolve_backend().value + except (ImportError, ValueError): + return "auto" + + +def _helper_target(adb_result: ProbeResult | None, requested_device: str | None) -> str | None: + """The one ready device whose helper state is worth reporting, or None.""" + devices = (adb_result.metadata.get("devices") if adb_result else None) or [] + ready = [str(d.get("serial")) for d in devices if d.get("state") == "device"] + if requested_device: + return requested_device if requested_device in ready else None + return ready[0] if len(ready) == 1 else None + + +def _helper_status(serial: str) -> dict[str, Any]: + try: + status = helper_manager.status(serial) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: + return {"serial": serial, "error": f"{exc.__class__.__name__}: {exc}"} + status["serial"] = serial + status["backend"] = _hierarchy_backend() + return status + + +def _helper_needs_provision(status: dict[str, Any] | None) -> bool: + if not status or status.get("error"): + return False + return not status.get("installed") or bool(status.get("outdated")) or not status.get("enabled") + + +def _helper_fix( + adb_result: ProbeResult | None, requested_device: str | None +) -> dict[str, Any] | None: + """Install / upgrade / enable the helper on the idle target device (attempt_fix only).""" + serial = _helper_target(adb_result, requested_device) + if serial is None or _hierarchy_backend() == "uiautomator": + return None + status = _helper_status(serial) + if not _helper_needs_provision(status): + return None + if not status.get("bundled_apk_present"): + return { + "fix": "install_accessibility_helper", + "success": False, + "skipped": True, + "message": "Skipped: the bundled ArtemisAccessibilityHelper.apk is missing from the checkout.", + } + wanted = _normalize_serial(serial) + if any(_normalize_serial(entry.get("device")) == wanted for entry in _task_state()["active"]): + return { + "fix": "install_accessibility_helper", + "success": False, + "skipped": True, + "message": f"Skipped: a task is running on {serial}; install it after the task finishes.", + } + try: + result = helper_manager.provision(serial) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: + return { + "fix": "install_accessibility_helper", + "success": False, + "skipped": False, + "message": f"provisioning raised {exc.__class__.__name__}: {exc}", + } + message = ( + f"{result.action} (installed version {result.installed_version}, " + f"bundled {result.bundled_version}, service enabled={result.enabled})" + ) + if result.error: + message += f": {result.error}" + return { + "fix": "install_accessibility_helper", + "success": result.ok, + "skipped": False, + "message": message, + } + + +def _helper_steps(status: dict[str, Any] | None, *, attempt_fix: bool) -> list[str]: + if not status: + return [] + serial = status.get("serial") or "the device" + backend = status.get("backend") or "auto" + if backend == "uiautomator": + return [] + if status.get("error"): + return [ + f"[OPTIONAL] Accessibility helper state on {serial} could not be read: {status['error']}" + ] + healthy = status.get("installed") and not status.get("outdated") and status.get("enabled") + if healthy and status.get("reachable"): + return [] + if not status.get("installed"): + problem = "is not installed" + elif status.get("outdated"): + problem = ( + f"is outdated (device has version {status.get('installed_version')}, " + f"bundled is {status.get('bundled_version')})" + ) + elif not status.get("enabled"): + problem = "is installed but its accessibility service is disabled" + elif status.get("newer_than_bundled") and status.get("reachable"): + return [] # a newer dev build is fine; nothing to do + else: + problem = "is installed and enabled but not answering on its loopback port" + tag = "REQUIRED" if backend == "helper" else "OPTIONAL" + consequence = ( + "tasks cannot read the UI hierarchy until it is fixed" + if backend == "helper" + else "tasks fall back to UIAutomator2 (slower dumps, conflicts with Appium/Mobly)" + ) + steps = [f"[{tag}] Accessibility helper on {serial} {problem}; {consequence}."] + if not status.get("bundled_apk_present"): + steps.append( + " Guidance: the bundled APK is missing; build it with " + "packages/artemis-accessibility-helper/build_apk.sh." + ) + return steps + if problem.startswith("is installed and enabled"): + steps.append( + " Guidance: unlock the phone; if the helper still does not answer, reinstall it with " + "the command below (add --force)." + ) + steps.append(f" Run: uv run artemis helper install --serial {serial} --force") + return steps + if not attempt_fix: + steps.append( + " Guidance: call mobile_diagnose(attempt_fix=true) to install and enable it " + "without touching the phone, or run the command below." + ) + steps.append(f" Run: uv run artemis helper install --serial {serial}") + if problem.startswith("is installed but"): + # Enabling from adb was already attempted once by whoever installed it; + # on ROMs that reject it only a person can flip the switch. + from artemis.runtime.helper_manager import MANUAL_ENABLE_PATH + + steps.append( + " Guidance: if the command reports that this device rejected enabling the " + f"service, ask the user to turn it on by hand: {MANUAL_ENABLE_PATH} " + "(the command opens that settings screen on the phone)." + ) + return steps + + # --------------------------------------------------------------------------- # # next_steps # --------------------------------------------------------------------------- # @@ -575,6 +731,7 @@ def _next_steps( credentials: list[dict[str, Any]] | None, tasks: dict[str, list[dict[str, Any]]], device_probe: dict[str, Any] | None, + accessibility_helper: dict[str, Any] | None = None, ) -> list[str]: steps: list[str] = [] needs_restart = False @@ -628,6 +785,7 @@ def _next_steps( ) steps.extend(launch_steps) steps.extend(_device_probe_steps(device_probe)) + steps.extend(_helper_steps(accessibility_helper, attempt_fix=attempt_fix)) for fix in fixes_applied: outcome = "applied" if fix.get("success") else "did not help" @@ -750,7 +908,9 @@ def _cleanup_stale_locks() -> dict[str, Any] | None: } -async def _apply_safe_fixes(report: SystemReadinessReport) -> list[dict[str, Any]]: +async def _apply_safe_fixes( + report: SystemReadinessReport, requested_device: str | None = None +) -> list[dict[str, Any]]: """Run the self-heals the console exposes as buttons, only when they cannot disrupt a task.""" applied: list[dict[str, Any]] = [] cleanup = _cleanup_stale_locks() @@ -768,7 +928,12 @@ async def _apply_safe_fixes(report: SystemReadinessReport) -> list[dict[str, Any devices = adb.metadata.get("devices") or [] if any(d.get("state") == "device" for d in devices): - return applied # a ready device exists; nothing to restart + # A ready device exists; nothing to restart. Provision the accessibility + # helper on it when it is idle and missing, outdated or disabled. + helper_fix = await asyncio.to_thread(_helper_fix, adb, requested_device) + if helper_fix is not None: + applied.append(helper_fix) + return applied if DeviceExecutionLock.get_active_owners(): applied.append( { @@ -787,7 +952,9 @@ async def _apply_safe_fixes(report: SystemReadinessReport) -> list[dict[str, Any def _probes_changed(fixes_applied: list[dict[str, Any]]) -> bool: """Only ADB-side fixes can change probe results; lock cleanup does not warrant a re-run.""" return any( - fix.get("success") and not fix.get("skipped") and fix.get("fix") != "cleanup_stale_locks" + fix.get("success") + and not fix.get("skipped") + and fix.get("fix") not in ("cleanup_stale_locks", "install_accessibility_helper") for fix in fixes_applied ) @@ -817,15 +984,18 @@ async def _run_extras( else: emulator = _emulator_status() - credentials, device_probe = await asyncio.gather( + helper_serial = _helper_target(adb_result, requested_device) + credentials, device_probe, accessibility_helper = await asyncio.gather( _verify_credentials(_find(results, "gemini_api_key")) if verify_credentials else _none(), _run_device_probe(adb_result, requested_device) if probe_device else _none(), + asyncio.to_thread(_helper_status, helper_serial) if helper_serial else _none(), ) return { "emulator": emulator, "launch_steps": launch_steps, "credentials": credentials, "device_probe": device_probe, + "accessibility_helper": accessibility_helper, "tasks": _task_state(), } @@ -847,6 +1017,7 @@ def _verdict( requested_device: str | None, credentials: list[dict[str, Any]] | None, device_probe: dict[str, Any] | None, + accessibility_helper: dict[str, Any] | None = None, ) -> str: verdict = base_verdict(results) if verdict == "blocked" or not _requested_device_ready(results, requested_device): @@ -856,8 +1027,13 @@ def _verdict( return "blocked" if device_probe is not None and not device_probe.get("ok"): return "blocked" + helper_backend = (accessibility_helper or {}).get("backend") + if helper_backend == "helper" and not accessibility_helper.get("reachable"): + return "blocked" if any(not entry.get("valid") for entry in credentials or []): return "degraded" + if helper_backend == "auto" and _helper_needs_provision(accessibility_helper): + return "degraded" if verdict == "ready" else verdict return verdict @@ -949,7 +1125,12 @@ async def mobile_diagnose( traces_dir, mcp_client (when known), daemon {port, reachable, port_held_by_other_process, log_path}. - `device`: the device a task would use ({serial, state, model, - android_version, is_locked, is_emulator}) or null. + android_version, is_locked, is_emulator, accessibility_helper}) or + null. `accessibility_helper` describes the Artemis UI-hierarchy + helper APK on that device ({installed, installed_version, + bundled_version, outdated, enabled, forward_port, reachable, + backend}); with backend "auto" a missing helper only degrades + (UIAutomator2 fallback), with backend "helper" it blocks. - `emulator`: background emulator launch state ({avd_name, status, stage_message, error, serial, elapsed_seconds, progress_percent}) or null when nothing was launched. `status` is starting / @@ -970,8 +1151,10 @@ async def mobile_diagnose( Args: attempt_fix: When true, applies the safe self-heals the ARTEMIS console offers: remove device locks left by dead processes, - regenerate corrupted ADB RSA keys, and restart the ADB server - (only when no device is ready and no task holds a device). Then + regenerate corrupted ADB RSA keys, restart the ADB server (only + when no device is ready and no task holds a device), and install, + upgrade or enable the Artemis accessibility helper APK on the idle + target device when it is missing, outdated or disabled. Then re-runs the checks. Nothing else is changed. device_serial: Optional serial the user wants to use; the report then states explicitly whether that device is attached, authorized @@ -1004,7 +1187,7 @@ async def mobile_diagnose( ) if attempt_fix: fixes_applied = await asyncio.wait_for( - _apply_safe_fixes(report), timeout=DIAGNOSIS_TIMEOUT_SECONDS + _apply_safe_fixes(report, requested_device), timeout=DIAGNOSIS_TIMEOUT_SECONDS ) if _probes_changed(fixes_applied): report, host = await asyncio.wait_for( @@ -1026,12 +1209,17 @@ async def mobile_diagnose( credentials: list[dict[str, Any]] | None = extras["credentials"] device_probe: dict[str, Any] | None = extras["device_probe"] + accessibility_helper: dict[str, Any] | None = extras.get("accessibility_helper") verdict = _verdict( results, requested_device=requested_device, credentials=credentials, device_probe=device_probe, + accessibility_helper=accessibility_helper, ) + device = _compact_device(report) + if device is not None and accessibility_helper is not None: + device["accessibility_helper"] = accessibility_helper env_file = host.metadata.get("env_file") return { "verdict": verdict, @@ -1047,10 +1235,11 @@ async def mobile_diagnose( credentials=credentials, tasks=extras["tasks"], device_probe=device_probe, + accessibility_helper=accessibility_helper, ), "checks": [_render_check(r) for r in sort_by_fix_order(results)], "host": _compact_host(host.metadata), - "device": _compact_device(report), + "device": device, "emulator": extras["emulator"], "tasks": extras["tasks"], "credentials": credentials, diff --git a/mcp_server/tools/task_manager.py b/mcp_server/tools/task_manager.py index b541836..4eb800d 100644 --- a/mcp_server/tools/task_manager.py +++ b/mcp_server/tools/task_manager.py @@ -334,6 +334,12 @@ def mobile_manage_task( "stdout_log": os.path.join(trace_dir, "stdout.log"), "stderr_log": os.path.join(trace_dir, "stderr.log"), } + # Which UI-hierarchy source served the run ("helper" or "uiautomator"), + # plus any mid-run switch; written by the agent once the device connects. + if status_data.get("hierarchy_backend"): + response["hierarchy_backend"] = status_data.get("hierarchy_backend") + if status_data.get("hierarchy_backend_note"): + response["hierarchy_backend_note"] = status_data.get("hierarchy_backend_note") if status_data.get("model", "").lower() != "flash": response["notes_dir"] = os.path.join(trace_dir, "notes") diff --git a/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk b/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk index d8f9b63..16f4480 100644 Binary files a/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk and b/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk differ diff --git a/packages/artemis-accessibility-helper/app/build.gradle.kts b/packages/artemis-accessibility-helper/app/build.gradle.kts index eda8fd9..ad802d6 100644 --- a/packages/artemis-accessibility-helper/app/build.gradle.kts +++ b/packages/artemis-accessibility-helper/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "com.artemis.helper" minSdk = 24 targetSdk = 35 - versionCode = 1 - versionName = "1.0.0" + versionCode = 6 + versionName = "1.2.0" } buildTypes { diff --git a/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml b/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml index 650b6cf..94b1a1c 100644 --- a/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +++ b/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml @@ -32,6 +32,18 @@ android:value="Artemis mobile automation test bridge" /> + + + + + + + diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java index 9dc260b..676728a 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java @@ -1,6 +1,5 @@ package com.artemis.helper; -import android.graphics.Rect; import org.json.JSONArray; import org.json.JSONObject; @@ -12,6 +11,12 @@ import java.util.List; * Decoupled from live Android AccessibilityNodeInfo objects to prevent * Binder proxy memory leaks, avoid stale node crashes, and ensure * instant thread-safe serialization to both XML and JSON. + * + * {@code bounds} are the node's visible bounds: the raw + * {@code getBoundsInScreen} rectangle intersected with the display, the + * node's window and every scrollable ancestor, exactly like UIAutomator's + * {@code getVisibleBoundsInScreen}. They are therefore never negative and + * never extend past the screen. */ public final class A11yNode { @@ -32,6 +37,7 @@ public final class A11yNode { public boolean longClickable = false; public boolean password = false; public boolean selected = false; + public boolean visibleToUser = true; // Window metadata for multi-window awareness public int windowId = -1; @@ -94,9 +100,12 @@ public final class A11yNode { XmlUtils.appendBooleanAttribute(sb, "long-clickable", longClickable); XmlUtils.appendBooleanAttribute(sb, "password", password); XmlUtils.appendBooleanAttribute(sb, "selected", selected); + XmlUtils.appendBooleanAttribute(sb, "visible-to-user", visibleToUser); XmlUtils.appendAttribute(sb, "bounds", getBoundsString()); XmlUtils.appendIntAttribute(sb, "drawing-order", drawingOrder); - XmlUtils.appendAttribute(sb, "hint", hint); + if (!hint.isEmpty()) { + XmlUtils.appendAttribute(sb, "hint", hint); + } // Window metadata (emitted on window root nodes) if (windowId >= 0) { @@ -199,6 +208,7 @@ public final class A11yNode { obj.put("selected", selected); obj.put("password", password); obj.put("long-clickable", longClickable); + obj.put("visible-to-user", visibleToUser); obj.put("drawing-order", drawingOrder); if (!hint.isEmpty()) { obj.put("hint", hint); diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java index a2067b2..895a59d 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java @@ -7,6 +7,7 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageInfo; import android.content.pm.ServiceInfo; import android.os.Build; import android.util.Log; @@ -17,12 +18,24 @@ import android.view.accessibility.AccessibilityEvent; * * Runs concurrently with any Android UI testing framework (Mobly, Appium, Espresso) * without monopolizing Android's singleton UiAutomationService connection. + * + * The service subscribes to window-state changes only: that is the single event it + * uses (to remember the foreground package / activity), and anything wider costs + * CPU and battery on a device that is idle between tasks. */ public class ArtemisAccessibilityService extends AccessibilityService { private static final String TAG = "ArtemisA11yService"; public static final int DEFAULT_PORT = 18888; - private static final String CHANNEL_ID = "artemis_helper_channel"; + /** + * Bumped whenever the HTTP contract changes in a way an older host cannot use. + * 2: token authentication, byte-accurate bodies, visible-only dumps, fields=. + */ + public static final int PROTOCOL_VERSION = 2; + // Channel importance is frozen by Android once a channel exists, so a quieter + // channel needs a new id; the pre-1.1.3 channel is deleted on start. + private static final String LEGACY_CHANNEL_ID = "artemis_helper_channel"; + private static final String CHANNEL_ID = "artemis_helper_quiet"; private static final int NOTIFICATION_ID = 18888; private static volatile ArtemisAccessibilityService instance; @@ -43,6 +56,29 @@ public class ArtemisAccessibilityService extends AccessibilityService { return currentActivityName; } + /** Installed helper versionCode, or -1 when the package manager cannot answer. */ + @SuppressWarnings("deprecation") + public long getVersionCode() { + try { + PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return info.getLongVersionCode(); + } + return info.versionCode; + } catch (Throwable t) { + return -1L; + } + } + + public String getVersionName() { + try { + PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0); + return info.versionName == null ? "" : info.versionName; + } catch (Throwable t) { + return ""; + } + } + @Override public void onServiceConnected() { super.onServiceConnected(); @@ -58,12 +94,14 @@ public class ArtemisAccessibilityService extends AccessibilityService { if (info == null) { info = new AccessibilityServiceInfo(); } - info.eventTypes = AccessibilityEvent.TYPES_ALL_MASK; + info.eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED; info.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC; - info.notificationTimeout = 50; + info.notificationTimeout = 100; info.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS - | AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS | AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS; + // Not-important views are what UIAutomator's compressed dump leaves out; + // keep them out here too so both backends describe the same tree. + info.flags &= ~AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS; setServiceInfo(info); Log.i(TAG, "AccessibilityServiceInfo flags dynamically enforced"); } catch (Throwable t) { @@ -94,12 +132,19 @@ public class ArtemisAccessibilityService extends AccessibilityService { if (nm == null) return; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + nm.deleteNotificationChannel(LEGACY_CHANNEL_ID); + } catch (Throwable ignored) {} + // Ask for IMPORTANCE_MIN (no sound, collapsed in the shade). Android raises + // foreground-service channels to LOW at most, which is still silent. The + // notification exists only to keep the service alive on aggressive ROMs; + // it must not look like something the person needs to act on. NotificationChannel channel = new NotificationChannel( CHANNEL_ID, - "Artemis Helper Service", - NotificationManager.IMPORTANCE_LOW + "Artemis test helper", + NotificationManager.IMPORTANCE_MIN ); - channel.setDescription("Keeps Artemis Accessibility Helper active for automation testing"); + channel.setDescription("Shown while the Artemis test helper is installed on this device"); channel.setShowBadge(false); nm.createNotificationChannel(channel); } @@ -111,10 +156,20 @@ public class ArtemisAccessibilityService extends AccessibilityService { builder = new Notification.Builder(this); } - builder.setContentTitle("Artemis Accessibility Helper") - .setContentText("Active on 127.0.0.1:" + DEFAULT_PORT) + builder.setContentTitle("Artemis test helper is running") + .setContentText("Lets Artemis read this screen during automated tests. " + + "Remove with: artemis helper uninstall") + .setStyle(new Notification.BigTextStyle().bigText( + "Installed by Artemis to read the screen layout during automated " + + "tests. It answers only on this device (127.0.0.1:" + DEFAULT_PORT + + "), only to the computer that is connected over USB debugging and " + + "holds the session token, and it sends nothing anywhere. Remove it " + + "any time with `artemis helper uninstall` or from Settings > Apps.")) .setSmallIcon(android.R.drawable.stat_notify_sync) .setOngoing(true); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + builder.setPriority(Notification.PRIORITY_MIN); + } if (Build.VERSION.SDK_INT >= 34) { startForeground(NOTIFICATION_ID, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java index 032a303..328f717 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java @@ -3,27 +3,46 @@ package com.artemis.helper; import android.util.Log; import org.json.JSONObject; -import java.io.BufferedReader; +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; import java.io.Closeable; -import java.io.InputStreamReader; +import java.io.InputStream; import java.io.OutputStream; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** - * High-performance, multi-threaded command server bound strictly to loopback (127.0.0.1:18888). - * Supports: - * - HTTP REST API (/dump, /dump_xml, /hierarchy.xml, /ping, /action) - * - Line-delimited JSON-RPC over TCP socket + * Multi-threaded command server bound strictly to loopback (127.0.0.1:18888). + * + * Protocols: + * - HTTP (/ping, /dump, /dump_xml, /hierarchy.xml, /snapshot, /action) + * - Line-delimited JSON-RPC over a raw TCP socket + * + * Every endpoint except {@code /ping} requires the session token the host pushed + * through {@link TokenReceiver} (header {@code X-Artemis-Token}, query parameter + * {@code token}, or JSON field {@code token}). Loopback is reachable by every app + * on the device, so without the token this service would hand any local app a + * full-screen reader and a gesture injector. + * + * Request bodies are read as bytes (Content-Length is a byte count), so UTF-8 + * payloads of any script are handled exactly. */ public class CommandServer extends Thread { private static final String TAG = "ArtemisCommandServer"; + private static final int MAX_HEADER_LINE = 16 * 1024; + private static final int MAX_BODY = 4 * 1024 * 1024; + private static final String TOKEN_HEADER = "x-artemis-token"; + private final ArtemisAccessibilityService service; private final int port; private volatile boolean isRunning = true; @@ -67,25 +86,66 @@ public class CommandServer extends Thread { } } + // ------------------------------------------------------------------ // + // Byte-accurate request reading + // ------------------------------------------------------------------ // + + /** Reads one line (without CR/LF) as raw bytes; null at end of stream. */ + private static byte[] readLineBytes(InputStream in) throws Exception { + ByteArrayOutputStream line = new ByteArrayOutputStream(256); + int b; + while ((b = in.read()) >= 0) { + if (b == '\n') { + break; + } + if (b != '\r') { + line.write(b); + } + if (line.size() > MAX_HEADER_LINE) { + throw new IllegalStateException("Header line too long"); + } + } + if (b < 0 && line.size() == 0) { + return null; + } + return line.toByteArray(); + } + + private static byte[] readExactly(InputStream in, int length) throws Exception { + byte[] buf = new byte[length]; + int total = 0; + while (total < length) { + int r = in.read(buf, total, length - total); + if (r < 0) break; + total += r; + } + if (total < length) { + byte[] shorter = new byte[total]; + System.arraycopy(buf, 0, shorter, 0, total); + return shorter; + } + return buf; + } + private void handleClient(Socket socket) { try { socket.setSoTimeout(10000); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + InputStream in = new BufferedInputStream(socket.getInputStream()); OutputStream output = socket.getOutputStream(); - String firstLine = reader.readLine(); + byte[] firstLine = readLineBytes(in); if (firstLine == null) return; - String trimmed = firstLine.trim(); + String ascii = new String(firstLine, StandardCharsets.ISO_8859_1).trim(); - if (trimmed.startsWith("GET ") || trimmed.startsWith("POST ")) { - handleHttp(trimmed, reader, output); - } else if (trimmed.startsWith("{")) { - handleJsonRpc(trimmed, output); + if (ascii.startsWith("GET ") || ascii.startsWith("POST ")) { + handleHttp(ascii, in, output); + } else if (ascii.startsWith("{")) { + handleJsonRpc(new String(firstLine, StandardCharsets.UTF_8).trim(), output); } else { JSONObject err = new JSONObject(); err.put("success", false); err.put("error", "Unsupported protocol"); - sendJsonResponse(output, err.toString()); + sendJson(output, 400, err.toString()); } } catch (Exception e) { Log.w(TAG, "Client handling error: " + e.getMessage()); @@ -94,82 +154,131 @@ public class CommandServer extends Thread { } } - private void handleHttp(String requestLine, BufferedReader reader, OutputStream output) { + // ------------------------------------------------------------------ // + // HTTP + // ------------------------------------------------------------------ // + + private static Map parseQuery(String query) { + Map params = new HashMap<>(); + if (query == null || query.isEmpty()) return params; + for (String pair : query.split("&")) { + if (pair.isEmpty()) continue; + int eq = pair.indexOf('='); + try { + String key = URLDecoder.decode(eq < 0 ? pair : pair.substring(0, eq), "UTF-8"); + String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), "UTF-8"); + params.put(key, value); + } catch (Exception ignored) {} + } + return params; + } + + private void handleHttp(String requestLine, InputStream in, OutputStream output) { try { String[] parts = requestLine.split(" "); - String path = parts.length > 1 ? parts[1] : "/"; + String target = parts.length > 1 ? parts[1] : "/"; + String path = target; + Map query; + int q = target.indexOf('?'); + if (q >= 0) { + path = target.substring(0, q); + query = parseQuery(target.substring(q + 1)); + } else { + query = new HashMap<>(); + } int contentLength = 0; - String line; - while ((line = reader.readLine()) != null) { - if (line.isEmpty()) break; - String lower = line.toLowerCase(); - if (lower.startsWith("content-length:")) { + String headerToken = null; + byte[] lineBytes; + while ((lineBytes = readLineBytes(in)) != null) { + if (lineBytes.length == 0) break; + String line = new String(lineBytes, StandardCharsets.ISO_8859_1); + int colon = line.indexOf(':'); + if (colon < 0) continue; + String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT); + String value = line.substring(colon + 1).trim(); + if (name.equals("content-length")) { try { - contentLength = Integer.parseInt(lower.substring("content-length:".length()).trim()); + contentLength = Integer.parseInt(value); } catch (Exception ignored) {} + } else if (name.equals(TOKEN_HEADER)) { + headerToken = value; } } + if (contentLength < 0 || contentLength > MAX_BODY) { + sendJson(output, 413, errorJson("Body too large").toString()); + return; + } String body = ""; if (contentLength > 0) { - char[] buf = new char[contentLength]; - int total = 0; - while (total < contentLength) { - int r = reader.read(buf, total, contentLength - total); - if (r < 0) break; - total += r; - } - body = new String(buf, 0, total); + body = new String(readExactly(in, contentLength), StandardCharsets.UTF_8); + } + + String token = headerToken != null ? headerToken : query.get("token"); + boolean authed = TokenStore.matches(token); + + if (path.equals("/ping") || path.equals("/")) { + sendJson(output, 200, buildPing(authed).toString()); + return; + } + if (!authed) { + sendJson(output, 401, unauthorizedJson().toString()); + return; } // Route endpoints - if (path.startsWith("/snapshot")) { - JSONObject json = HierarchyDumper.dumpAtomicSnapshot(service); - sendJsonResponse(output, json.toString()); - } else if (path.equals("/dump_xml") || path.equals("/hierarchy.xml") || path.startsWith("/dump?format=xml")) { - String xml = HierarchyDumper.dumpXml(service); - sendXmlResponse(output, xml); - } else if (path.startsWith("/dump") || path.startsWith("/hierarchy")) { - JSONObject json = HierarchyDumper.dump(service); - sendJsonResponse(output, json.toString()); - } else if (path.startsWith("/ping") || path.equals("/")) { - JSONObject r = new JSONObject(); - r.put("success", true); - r.put("service", "ArtemisAccessibilityService"); - r.put("package", service.getCurrentPackageName()); - r.put("activity", service.getCurrentActivityName()); - sendJsonResponse(output, r.toString()); - } else if (path.startsWith("/action") || path.startsWith("/rpc")) { + if (path.equals("/snapshot")) { + HierarchyDumper.DumpOptions options = HierarchyDumper.DumpOptions.forSnapshot().apply(query); + JSONObject json = HierarchyDumper.dumpAtomicSnapshot(service, options); + sendJson(output, 200, json.toString()); + } else if (path.equals("/dump_xml") || path.equals("/hierarchy.xml") + || (path.equals("/dump") && "xml".equals(query.get("format")))) { + HierarchyDumper.DumpOptions options = HierarchyDumper.DumpOptions.forSnapshot().apply(query); + sendXml(output, HierarchyDumper.dumpXml(service, options)); + } else if (path.equals("/dump") || path.equals("/hierarchy")) { + HierarchyDumper.DumpOptions options = HierarchyDumper.DumpOptions.forDump().apply(query); + sendJson(output, 200, HierarchyDumper.dump(service, options).toString()); + } else if (path.equals("/action") || path.equals("/rpc")) { JSONObject json = body.isEmpty() ? new JSONObject() : new JSONObject(body); JSONObject resp = executeCommand(json.optString("cmd", ""), json); - sendJsonResponse(output, resp.toString()); + sendJson(output, 200, resp.toString()); } else { - JSONObject r = new JSONObject(); - r.put("success", false); - r.put("error", "Unknown endpoint: " + path); - sendJsonResponse(output, r.toString()); + sendJson(output, 404, errorJson("Unknown endpoint: " + path).toString()); } } catch (Exception e) { Log.e(TAG, "HTTP error", e); + try { + sendJson(output, 500, errorJson("Internal error: " + e.getMessage()).toString()); + } catch (Throwable ignored) {} } } + // ------------------------------------------------------------------ // + // Raw JSON-RPC + // ------------------------------------------------------------------ // + private void handleJsonRpc(String line, OutputStream output) { try { JSONObject json = new JSONObject(line); String cmd = json.optString("cmd", ""); - JSONObject resp = executeCommand(cmd, json); + boolean authed = TokenStore.matches(json.optString("token", null)); + JSONObject resp; + if (cmd.equalsIgnoreCase("ping")) { + resp = buildPing(authed); + } else if (!authed) { + resp = unauthorizedJson(); + } else { + resp = executeCommand(cmd, json); + } byte[] bytes = resp.toString().getBytes(StandardCharsets.UTF_8); output.write(bytes); output.write('\n'); output.flush(); } catch (Exception e) { try { - JSONObject err = new JSONObject(); - err.put("success", false); - err.put("error", "JSON parse error: " + e.getMessage()); + JSONObject err = errorJson("JSON parse error: " + e.getMessage()); output.write(err.toString().getBytes(StandardCharsets.UTF_8)); output.write('\n'); output.flush(); @@ -177,21 +286,21 @@ public class CommandServer extends Thread { } } + // ------------------------------------------------------------------ // + // Commands + // ------------------------------------------------------------------ // + private JSONObject executeCommand(String cmd, JSONObject params) { JSONObject resp = new JSONObject(); try { - switch (cmd.toLowerCase()) { + switch (cmd.toLowerCase(Locale.ROOT)) { case "ping": - resp.put("success", true); - resp.put("service", "ArtemisAccessibilityService"); - resp.put("package", service.getCurrentPackageName()); - resp.put("activity", service.getCurrentActivityName()); - break; + return buildPing(true); case "dump": case "dump_ui": - return HierarchyDumper.dump(service); + return HierarchyDumper.dump(service, HierarchyDumper.DumpOptions.forDump()); case "snapshot": - return HierarchyDumper.dumpAtomicSnapshot(service); + return HierarchyDumper.dumpAtomicSnapshot(service, HierarchyDumper.DumpOptions.forSnapshot()); case "dump_xml": resp.put("success", true); resp.put("xml", HierarchyDumper.dumpXml(service)); @@ -239,11 +348,15 @@ public class CommandServer extends Thread { break; case "type": String text = params.optString("text", ""); - resp.put("success", GestureController.setText(service, text)); + boolean append = params.optBoolean("append", false); + resp.put("success", GestureController.setText(service, text, append)); break; case "clear": resp.put("success", GestureController.clearText(service)); break; + case "clipboard": + resp.put("success", GestureController.setClipboard(service, params.optString("text", ""))); + break; case "global": String action = params.optString("action", ""); resp.put("success", GestureController.performGlobalAction(service, action)); @@ -262,11 +375,64 @@ public class CommandServer extends Thread { return resp; } - private void sendJsonResponse(OutputStream output, String jsonStr) { + /** + * Health payload shared by GET /ping and the "ping" RPC. The host compares + * version_code / protocol_version against the bundled APK to decide whether + * to upgrade, and token_set to decide whether to push its token. The + * foreground package and activity are only disclosed to an authenticated caller. + */ + private JSONObject buildPing(boolean authed) throws Exception { + JSONObject r = new JSONObject(); + r.put("success", true); + r.put("service", "ArtemisAccessibilityService"); + r.put("version_code", service.getVersionCode()); + r.put("version_name", service.getVersionName()); + r.put("protocol_version", ArtemisAccessibilityService.PROTOCOL_VERSION); + r.put("port", port); + r.put("auth_required", true); + r.put("token_set", TokenStore.isSet()); + r.put("authenticated", authed); + if (authed) { + r.put("package", service.getCurrentPackageName()); + r.put("activity", service.getCurrentActivityName()); + } + return r; + } + + private static JSONObject errorJson(String message) throws Exception { + JSONObject r = new JSONObject(); + r.put("success", false); + r.put("error", message); + return r; + } + + private static JSONObject unauthorizedJson() throws Exception { + JSONObject r = errorJson(TokenStore.isSet() + ? "unauthorized: wrong or missing X-Artemis-Token" + : "unauthorized: no session token has been pushed to the helper yet"); + r.put("token_set", TokenStore.isSet()); + return r; + } + + // ------------------------------------------------------------------ // + // Responses + // ------------------------------------------------------------------ // + + private static String reason(int status) { + switch (status) { + case 200: return "OK"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 404: return "Not Found"; + case 413: return "Payload Too Large"; + default: return "Internal Server Error"; + } + } + + private static void send(OutputStream output, int status, String contentType, byte[] bytes) { try { - byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8); - String header = "HTTP/1.1 200 OK\r\n" + - "Content-Type: application/json; charset=utf-8\r\n" + + String header = "HTTP/1.1 " + status + " " + reason(status) + "\r\n" + + "Content-Type: " + contentType + "; charset=utf-8\r\n" + "Content-Length: " + bytes.length + "\r\n" + "Connection: close\r\n\r\n"; output.write(header.getBytes(StandardCharsets.UTF_8)); @@ -275,17 +441,12 @@ public class CommandServer extends Thread { } catch (Exception ignored) {} } - private void sendXmlResponse(OutputStream output, String xmlStr) { - try { - byte[] bytes = xmlStr.getBytes(StandardCharsets.UTF_8); - String header = "HTTP/1.1 200 OK\r\n" + - "Content-Type: application/xml; charset=utf-8\r\n" + - "Content-Length: " + bytes.length + "\r\n" + - "Connection: close\r\n\r\n"; - output.write(header.getBytes(StandardCharsets.UTF_8)); - output.write(bytes); - output.flush(); - } catch (Exception ignored) {} + private static void sendJson(OutputStream output, int status, String jsonStr) { + send(output, status, "application/json", jsonStr.getBytes(StandardCharsets.UTF_8)); + } + + private static void sendXml(OutputStream output, String xmlStr) { + send(output, 200, "application/xml", xmlStr.getBytes(StandardCharsets.UTF_8)); } public void shutdown() { diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java index 3c29fd2..1714a63 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java @@ -2,6 +2,9 @@ package com.artemis.helper; import android.accessibilityservice.AccessibilityService; import android.accessibilityservice.GestureDescription; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; import android.graphics.Path; import android.os.Build; import android.os.Bundle; @@ -65,14 +68,36 @@ public final class GestureController { } public static boolean setText(AccessibilityService service, String text) { + return setText(service, text, false); + } + + /** + * Sets (or, with {@code append}, extends) the text of the focused / first editable field. + * ACTION_SET_TEXT replaces the whole content; appending mirrors what typing through an + * IME does, which is the contract the host's send_text has always had. + */ + public static boolean setText(AccessibilityService service, String text, boolean append) { AccessibilityNodeInfo inputNode = HierarchyDumper.findInputNode(service); if (inputNode == null) { Log.w(TAG, "No editable/focused input node found for setText"); return false; } try { + String value = text == null ? "" : text; + if (append) { + CharSequence existing = inputNode.getText(); + boolean showingHint = false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + showingHint = inputNode.isShowingHintText(); + } catch (Throwable ignored) {} + } + if (existing != null && existing.length() > 0 && !showingHint) { + value = existing.toString() + value; + } + } Bundle args = new Bundle(); - args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text); + args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, value); return inputNode.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args); } catch (Throwable t) { Log.w(TAG, "performAction ACTION_SET_TEXT failed", t); @@ -171,4 +196,37 @@ public final class GestureController { return false; } } + + /** + * Write text to the system clipboard. Clipboard writes are allowed from any + * app on every API level (only reads are restricted since Android 10), so this + * is the IME-free path the host uses for multiline and non-ASCII input: + * set the clip here, then KEYCODE_PASTE over adb. + */ + public static boolean setClipboard(final AccessibilityService service, final String text) { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean ok = new AtomicBoolean(false); + MAIN_HANDLER.post(new Runnable() { + @Override + public void run() { + try { + ClipboardManager cm = (ClipboardManager) service.getSystemService(Context.CLIPBOARD_SERVICE); + if (cm != null) { + cm.setPrimaryClip(ClipData.newPlainText("artemis", text == null ? "" : text)); + ok.set(true); + } + } catch (Throwable t) { + Log.w(TAG, "setClipboard failed: " + t.getMessage()); + } finally { + latch.countDown(); + } + } + }); + try { + latch.await(2000, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return ok.get(); + } } diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java index 7b43e59..ee1084b 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java @@ -21,11 +21,13 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; /** @@ -34,73 +36,132 @@ import java.util.concurrent.atomic.AtomicReference; * Capabilities: * 1. Zero WaitForIdle hangs: Bypasses UIAutomator's waitForIdle() timeout. * 2. Multi-window penetration: Captures App, Dialog, System UI, IME Keyboard, Split-Screen. - * 3. Rich semantic extraction: Captures errorText, isHeading, editable, paneTitle, tooltip. - * 4. Atomic screenshot snapshot: On API 30+, captures hardware bitmap and DOM tree simultaneously. - * 5. W3C XML 1.0 compliance: Strict character sanitization prevents Python parse failures. + * 3. UIAutomator parity: invisible nodes are skipped and bounds are clipped to the + * display, the window and scrollable ancestors, so the tree describes what is on + * screen and nothing else. {@code include_invisible=1} keeps everything (debugging). + * 4. Rich semantic extraction: Captures errorText, isHeading, editable, paneTitle, tooltip. + * 5. Atomic screenshot snapshot: On API 30+, captures hardware bitmap and DOM tree simultaneously. + * 6. W3C XML 1.0 compliance: Strict character sanitization prevents Python parse failures. + * 7. Batched IPC on API 33+: descendants are prefetched per getChild call instead of one + * Binder round trip per node. */ public final class HierarchyDumper { private static final String TAG = "ArtemisHierarchyDumper"; private static final int MAX_DEPTH = 75; private static final int MAX_NODES = 8000; - private static final int RETRY_COUNT = 3; - private static final long RETRY_INTERVAL_MS = 40L; private static final ExecutorService SCREENSHOT_EXECUTOR = Executors.newSingleThreadExecutor(); + /** Prefetch strategy for API 33+ ({@code AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS_HYBRID}). */ + private static final int PREFETCH_DESCENDANTS_HYBRID = 1 << 3; + private HierarchyDumper() {} /** - * Dumps the complete hierarchy as a JSON response. + * What a dump should contain. Every field defaults to the cheapest useful answer. */ + public static final class DumpOptions { + public boolean includeInvisible = false; + public boolean wantXml = true; + public boolean wantElements = false; + public boolean wantTree = false; + + public static DumpOptions forDump() { + DumpOptions o = new DumpOptions(); + o.wantElements = true; + o.wantTree = true; + return o; + } + + public static DumpOptions forSnapshot() { + return new DumpOptions(); + } + + /** Applies {@code fields=xml,elements,tree} and {@code include_invisible=1}. */ + public DumpOptions apply(Map query) { + if (query == null) return this; + String fields = query.get("fields"); + if (fields != null && !fields.trim().isEmpty()) { + wantXml = wantElements = wantTree = false; + for (String f : fields.split(",")) { + String key = f.trim().toLowerCase(); + if (key.equals("xml")) wantXml = true; + else if (key.equals("elements")) wantElements = true; + else if (key.equals("tree")) wantTree = true; + } + } + String inv = query.get("include_invisible"); + if (inv != null) { + includeInvisible = inv.equals("1") || inv.equalsIgnoreCase("true"); + } + return this; + } + } + + /** Per-dump counters reported back to the host. */ + private static final class DumpStats { + int nodes = 0; + int skippedInvisible = 0; + boolean truncated = false; + } + public static JSONObject dump(AccessibilityService service) { + return dump(service, DumpOptions.forDump()); + } + + /** + * Dumps the hierarchy as a JSON response containing the requested representations. + */ + public static JSONObject dump(AccessibilityService service, DumpOptions options) { long startTime = System.currentTimeMillis(); JSONObject result = new JSONObject(); - JSONArray elementsJson = new JSONArray(); try { DisplayUtils.DisplayInfo displayInfo = DisplayUtils.getDisplayInfo(service); - List rootSnapshots = captureRootSnapshots(service); + DumpStats stats = new DumpStats(); + List rootSnapshots = captureRootSnapshots(service, displayInfo, options, stats); + + result.put("rotation", displayInfo.rotation); + result.put("width", displayInfo.width); + result.put("height", displayInfo.height); if (rootSnapshots.isEmpty()) { result.put("success", false); result.put("error", "No active window or root node found"); result.put("xml", ""); - result.put("elements", elementsJson); - result.put("rotation", displayInfo.rotation); - result.put("width", displayInfo.width); - result.put("height", displayInfo.height); + result.put("elements", new JSONArray()); return result; } - // 1. Build standard UIAutomator XML - String xml = buildXml(rootSnapshots, displayInfo.rotation, displayInfo.width, displayInfo.height); - - // 2. Build flat elements list - List flatList = new ArrayList<>(); - for (A11yNode root : rootSnapshots) { - root.collectFlatElements(flatList); + if (options.wantXml) { + result.put("xml", buildXml(rootSnapshots, displayInfo.rotation)); } - for (JSONObject elem : flatList) { - elementsJson.put(elem); + if (options.wantElements) { + JSONArray elementsJson = new JSONArray(); + List flatList = new ArrayList<>(); + for (A11yNode root : rootSnapshots) { + root.collectFlatElements(flatList); + } + for (JSONObject elem : flatList) { + elementsJson.put(elem); + } + result.put("elements", elementsJson); } - - // 3. Build tree - JSONArray trees = new JSONArray(); - for (A11yNode root : rootSnapshots) { - trees.put(root.toTreeJson()); + if (options.wantTree) { + JSONArray trees = new JSONArray(); + for (A11yNode root : rootSnapshots) { + trees.put(root.toTreeJson()); + } + result.put("tree", trees.length() == 1 ? trees.getJSONObject(0) : trees); } - long elapsed = System.currentTimeMillis() - startTime; - result.put("success", true); - result.put("xml", xml); - result.put("elements", elementsJson); - result.put("tree", trees.length() == 1 ? trees.getJSONObject(0) : trees); - result.put("rotation", displayInfo.rotation); - result.put("width", displayInfo.width); - result.put("height", displayInfo.height); - result.put("elapsed_ms", elapsed); + result.put("node_count", stats.nodes); + result.put("skipped_invisible", stats.skippedInvisible); + result.put("truncated", stats.truncated); + result.put("window_count", rootSnapshots.size()); + result.put("elapsed_ms", System.currentTimeMillis() - startTime); if (service instanceof ArtemisAccessibilityService) { ArtemisAccessibilityService s = (ArtemisAccessibilityService) service; @@ -114,70 +175,109 @@ public final class HierarchyDumper { result.put("success", false); result.put("error", "Dump failed: " + t.getMessage()); result.put("xml", ""); - result.put("elements", elementsJson); + result.put("elements", new JSONArray()); } catch (Throwable ignored) {} } return result; } - /** - * Dumps an atomic snapshot combining hardware screenshot (JPEG base64) and UI hierarchy XML/JSON - * at the exact same clock tick, eliminating temporal phase mismatch. - */ + private static final int ERROR_TAKE_SCREENSHOT_INTERVAL_TIME_SHORT = 3; + private static final long SCREENSHOT_INTERVAL_RETRY_MS = 350L; + + @android.annotation.TargetApi(Build.VERSION_CODES.R) + private static void requestScreenshot( + final AccessibilityService service, + final AtomicReference bitmapRef, + final AtomicInteger errorRef, + final CountDownLatch latch, + final boolean allowRetry) { + try { + service.takeScreenshot( + Display.DEFAULT_DISPLAY, + SCREENSHOT_EXECUTOR, + new AccessibilityService.TakeScreenshotCallback() { + @Override + public void onSuccess(AccessibilityService.ScreenshotResult screenshotResult) { + try { + HardwareBuffer buffer = screenshotResult.getHardwareBuffer(); + ColorSpace colorSpace = screenshotResult.getColorSpace(); + Bitmap hwBitmap = Bitmap.wrapHardwareBuffer(buffer, colorSpace); + if (hwBitmap != null) { + Bitmap swBitmap = hwBitmap.copy(Bitmap.Config.ARGB_8888, false); + hwBitmap.recycle(); + buffer.close(); + bitmapRef.set(swBitmap); + } + } catch (Throwable t) { + Log.w(TAG, "Error copying screenshot buffer", t); + } finally { + latch.countDown(); + } + } + + @Override + public void onFailure(int errorCode) { + errorRef.set(errorCode); + if (allowRetry && errorCode == ERROR_TAKE_SCREENSHOT_INTERVAL_TIME_SHORT) { + Log.i(TAG, "takeScreenshot rate-limited; retrying after " + SCREENSHOT_INTERVAL_RETRY_MS + " ms"); + SCREENSHOT_EXECUTOR.execute(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(SCREENSHOT_INTERVAL_RETRY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + requestScreenshot(service, bitmapRef, errorRef, latch, false); + } + }); + return; + } + Log.w(TAG, "takeScreenshot failed, errorCode: " + errorCode); + latch.countDown(); + } + } + ); + } catch (Throwable t) { + Log.w(TAG, "takeScreenshot invocation error", t); + latch.countDown(); + } + } + public static JSONObject dumpAtomicSnapshot(AccessibilityService service) { + return dumpAtomicSnapshot(service, DumpOptions.forSnapshot()); + } + + /** + * Dumps an atomic snapshot combining hardware screenshot (JPEG base64) and UI hierarchy + * at the exact same clock tick, eliminating temporal phase mismatch. When the screenshot + * is present, {@code width} / {@code height} are the bitmap's own dimensions so the host + * normalizes coordinates against the very image it is looking at. + */ + public static JSONObject dumpAtomicSnapshot(AccessibilityService service, DumpOptions options) { long startTime = System.currentTimeMillis(); - // 1. Trigger hardware screenshot asynchronously + // 1. Trigger hardware screenshot asynchronously. The framework rate-limits + // takeScreenshot to one call per ~333 ms (ERROR_TAKE_SCREENSHOT_INTERVAL_TIME_SHORT = 3); + // back-to-back observations therefore wait out the interval and retry once + // here, which is far cheaper than the host falling back to adb screencap. final AtomicReference bitmapRef = new AtomicReference<>(null); + final AtomicInteger errorRef = new AtomicInteger(0); final CountDownLatch screenshotLatch = new CountDownLatch(1); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - service.takeScreenshot( - Display.DEFAULT_DISPLAY, - SCREENSHOT_EXECUTOR, - new AccessibilityService.TakeScreenshotCallback() { - @Override - public void onSuccess(AccessibilityService.ScreenshotResult screenshotResult) { - try { - HardwareBuffer buffer = screenshotResult.getHardwareBuffer(); - ColorSpace colorSpace = screenshotResult.getColorSpace(); - Bitmap hwBitmap = Bitmap.wrapHardwareBuffer(buffer, colorSpace); - if (hwBitmap != null) { - Bitmap swBitmap = hwBitmap.copy(Bitmap.Config.ARGB_8888, false); - hwBitmap.recycle(); - buffer.close(); - bitmapRef.set(swBitmap); - } - } catch (Throwable t) { - Log.w(TAG, "Error copying screenshot buffer", t); - } finally { - screenshotLatch.countDown(); - } - } - - @Override - public void onFailure(int errorCode) { - Log.w(TAG, "takeScreenshot failed, errorCode: " + errorCode); - screenshotLatch.countDown(); - } - } - ); - } catch (Throwable t) { - Log.w(TAG, "takeScreenshot invocation error", t); - screenshotLatch.countDown(); - } + requestScreenshot(service, bitmapRef, errorRef, screenshotLatch, true); } else { screenshotLatch.countDown(); } // 2. Concurrently capture the UI hierarchy - JSONObject dumpData = dump(service); + JSONObject dumpData = dump(service, options); - // 3. Wait for the screenshot with a strict 1.5s timeout + // 3. Wait for the screenshot (one retry after the rate-limit interval fits inside) try { - screenshotLatch.await(1500L, TimeUnit.MILLISECONDS); + screenshotLatch.await(2500L, TimeUnit.MILLISECONDS); } catch (InterruptedException ignored) {} Bitmap bitmap = bitmapRef.get(); @@ -189,6 +289,8 @@ public final class HierarchyDumper { String base64Str = Base64.encodeToString(jpegBytes, Base64.NO_WRAP); dumpData.put("screenshot_base64", base64Str); dumpData.put("has_screenshot", true); + dumpData.put("width", bitmap.getWidth()); + dumpData.put("height", bitmap.getHeight()); } catch (Throwable t) { Log.w(TAG, "Failed to compress screenshot to JPEG Base64", t); try { dumpData.put("has_screenshot", false); } catch (Throwable ignored) {} @@ -198,8 +300,11 @@ public final class HierarchyDumper { } else { try { dumpData.put("has_screenshot", false); + dumpData.put("screenshot_error_code", errorRef.get()); dumpData.put("screenshot_error", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - ? "Screenshot capture timed out or failed" + ? (errorRef.get() != 0 + ? "takeScreenshot failed with errorCode " + errorRef.get() + : "Screenshot capture timed out") : "takeScreenshot not supported on Android < 11"); } catch (Throwable ignored) {} } @@ -211,23 +316,27 @@ public final class HierarchyDumper { return dumpData; } + public static String dumpXml(AccessibilityService service) { + return dumpXml(service, DumpOptions.forSnapshot()); + } + /** * Dumps the hierarchy directly as a raw standard UIAutomator XML string. */ - public static String dumpXml(AccessibilityService service) { + public static String dumpXml(AccessibilityService service, DumpOptions options) { DisplayUtils.DisplayInfo displayInfo = DisplayUtils.getDisplayInfo(service); - List rootSnapshots = captureRootSnapshots(service); + List rootSnapshots = captureRootSnapshots(service, displayInfo, options, new DumpStats()); if (rootSnapshots.isEmpty()) { return "\n\n"; } - return buildXml(rootSnapshots, displayInfo.rotation, displayInfo.width, displayInfo.height); + return buildXml(rootSnapshots, displayInfo.rotation); } /** * Builds the standard Android UIAutomator XML string from root snapshots. */ - private static String buildXml(List roots, int rotation, int width, int height) { + private static String buildXml(List roots, int rotation) { StringBuilder sb = new StringBuilder(roots.size() * 1024 + 256); sb.append("\n"); sb.append(""); @@ -249,6 +358,8 @@ public final class HierarchyDumper { final int windowLayer; final boolean windowActive; final boolean windowFocused; + /** Screen rectangle of the window, or null when unknown (display bounds are used). */ + final Rect windowBounds; RawRootEntry( AccessibilityNodeInfo root, @@ -256,7 +367,8 @@ public final class HierarchyDumper { String windowType, int windowLayer, boolean windowActive, - boolean windowFocused + boolean windowFocused, + Rect windowBounds ) { this.root = root; this.windowId = windowId; @@ -264,14 +376,25 @@ public final class HierarchyDumper { this.windowLayer = windowLayer; this.windowActive = windowActive; this.windowFocused = windowFocused; + this.windowBounds = windowBounds; } } + public static List captureRootSnapshots(AccessibilityService service) { + return captureRootSnapshots( + service, DisplayUtils.getDisplayInfo(service), DumpOptions.forSnapshot(), new DumpStats()); + } + /** * Captures snapshots of all active and interactive windows with adaptive progressive retry support. * Guaranteed to capture the complete hierarchy even during activity transitions and cold starts. */ - public static List captureRootSnapshots(AccessibilityService service) { + private static List captureRootSnapshots( + AccessibilityService service, + DisplayUtils.DisplayInfo displayInfo, + DumpOptions options, + DumpStats stats + ) { List rawRoots = Collections.emptyList(); long[] retryBackoff = new long[]{40L, 80L, 120L, 160L, 220L, 300L}; @@ -285,13 +408,19 @@ public final class HierarchyDumper { } } + Rect displayRect = new Rect(0, 0, displayInfo.width, displayInfo.height); List snapshots = new ArrayList<>(rawRoots.size()); - int[] nodeCounter = new int[]{0}; for (int i = 0; i < rawRoots.size(); i++) { RawRootEntry entry = rawRoots.get(i); try { - A11yNode snapshot = snapshotNode(entry.root, 0, i, nodeCounter); + Rect clip = new Rect(displayRect); + if (entry.windowBounds != null && !clip.intersect(entry.windowBounds)) { + // Window entirely off the display (e.g. a second display): keep the + // display rect so the root still serializes with sane bounds. + clip = new Rect(displayRect); + } + A11yNode snapshot = snapshotNode(entry.root, 0, i, clip, options, stats); if (snapshot != null) { snapshot.windowId = entry.windowId; snapshot.windowType = entry.windowType; @@ -310,6 +439,24 @@ public final class HierarchyDumper { return snapshots; } + private static AccessibilityNodeInfo windowRoot(AccessibilityWindowInfo window) { + if (Build.VERSION.SDK_INT >= 33) { + try { + return window.getRoot(PREFETCH_DESCENDANTS_HYBRID); + } catch (Throwable ignored) {} + } + return window.getRoot(); + } + + private static AccessibilityNodeInfo childOf(AccessibilityNodeInfo node, int index) { + if (Build.VERSION.SDK_INT >= 33) { + try { + return node.getChild(index, PREFETCH_DESCENDANTS_HYBRID); + } catch (Throwable ignored) {} + } + return node.getChild(index); + } + /** * Multi-tier hierarchy root discovery strategy: * Tier 1: Multi-window enumeration (sorted by Z-layer descending). @@ -335,7 +482,7 @@ public final class HierarchyDumper { for (AccessibilityWindowInfo window : sortedWindows) { try { - AccessibilityNodeInfo root = window.getRoot(); + AccessibilityNodeInfo root = windowRoot(window); if (root != null) { int hash = root.hashCode(); if (seenHashes.add(hash)) { @@ -343,13 +490,16 @@ public final class HierarchyDumper { if (window.getType() == AccessibilityWindowInfo.TYPE_APPLICATION) { hasAppWindow = true; } + Rect bounds = new Rect(); + window.getBoundsInScreen(bounds); roots.add(new RawRootEntry( root, window.getId(), typeStr, window.getLayer(), window.isActive(), - window.isFocused() + window.isFocused(), + bounds.isEmpty() ? null : bounds )); } else { safeRecycle(root); @@ -373,7 +523,8 @@ public final class HierarchyDumper { "application", 0, true, - true + true, + null )); hasAppWindow = true; } else { @@ -414,7 +565,8 @@ public final class HierarchyDumper { "application", 0, true, - true + true, + null )); } else { safeRecycle(current); @@ -450,6 +602,11 @@ public final class HierarchyDumper { * Recursively snapshots an AccessibilityNodeInfo into an immutable A11yNode, * extracting rich semantic properties (error, heading, editable, paneTitle, tooltip, stateDescription). * + * {@code clip} is the rectangle the node may be visible in: the display intersected with the + * window and every scrollable ancestor. Children that are not visible to the user are skipped + * unless {@code options.includeInvisible} is set; the window root itself is always kept, as in + * UIAutomator. + * * Protected against recursion loops via MAX_DEPTH and MAX_NODES without flat hash collision drops. */ @SuppressWarnings("deprecation") @@ -457,20 +614,37 @@ public final class HierarchyDumper { AccessibilityNodeInfo node, int depth, int childIndex, - int[] nodeCounter + Rect clip, + DumpOptions options, + DumpStats stats ) { if (node == null) return null; - if (depth > MAX_DEPTH || nodeCounter[0] >= MAX_NODES) { + if (depth > MAX_DEPTH || stats.nodes >= MAX_NODES) { + stats.truncated = true; return null; } - nodeCounter[0]++; + boolean visible = true; + try { + visible = node.isVisibleToUser(); + } catch (Throwable ignored) {} + if (!visible && depth > 0 && !options.includeInvisible) { + stats.skippedInvisible++; + return null; + } + + stats.nodes++; A11yNode snapshot = new A11yNode(); snapshot.index = childIndex; + snapshot.visibleToUser = visible; + Rect childClip = clip; try { Rect bounds = new Rect(); node.getBoundsInScreen(bounds); + if (!bounds.intersect(clip)) { + bounds.setEmpty(); + } snapshot.left = bounds.left; snapshot.top = bounds.top; snapshot.right = bounds.right; @@ -499,6 +673,13 @@ public final class HierarchyDumper { snapshot.password = node.isPassword(); snapshot.selected = node.isSelected(); + // Children of a scrollable container are clipped by it (UIAutomator's + // trimScrollableParent): a list row half under the toolbar keeps only its + // visible part, so its center is a point the user can actually touch. + if (snapshot.scrollable && !bounds.isEmpty()) { + childClip = new Rect(bounds); + } + // 1. Editable property snapshot.editable = node.isEditable(); @@ -515,11 +696,15 @@ public final class HierarchyDumper { } catch (Throwable ignored) {} } - // 4. Hint text (API 26+) + // 4. Hint text (API 26+). When the field is empty the framework reports the + // hint as the text; keep text and hint apart so "empty input" stays detectable. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { CharSequence hint = node.getHintText(); if (hint != null) snapshot.hint = hint.toString(); + if (node.isShowingHintText()) { + snapshot.text = ""; + } } catch (Throwable ignored) {} } @@ -557,9 +742,9 @@ public final class HierarchyDumper { for (int i = 0; i < childCount; i++) { AccessibilityNodeInfo childNode = null; try { - childNode = node.getChild(i); + childNode = childOf(node, i); if (childNode != null) { - A11yNode childSnapshot = snapshotNode(childNode, depth + 1, i, nodeCounter); + A11yNode childSnapshot = snapshotNode(childNode, depth + 1, i, childClip, options, stats); if (childSnapshot != null) { snapshot.children.add(childSnapshot); } @@ -594,34 +779,49 @@ public final class HierarchyDumper { /** * Finds the currently focused or editable input node for text entry. + * Window roots are enumerated once and recycled; only the returned node stays alive. */ public static AccessibilityNodeInfo findInputNode(AccessibilityService service) { - for (RawRootEntry entry : getActiveRawRoots(service)) { - try { - AccessibilityNodeInfo focused = entry.root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT); - if (focused != null) return focused; - } catch (Throwable ignored) {} + List roots = getActiveRawRoots(service); + AccessibilityNodeInfo found = null; + try { + for (RawRootEntry entry : roots) { + try { + AccessibilityNodeInfo focused = entry.root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT); + if (focused != null) { + found = focused; + return found; + } + } catch (Throwable ignored) {} + } + for (RawRootEntry entry : roots) { + AccessibilityNodeInfo editable = findFirstEditable(entry.root, 0); + if (editable != null) { + found = editable; + return found; + } + } + return null; + } finally { + for (RawRootEntry entry : roots) { + if (entry.root != found) { + safeRecycle(entry.root); + } + } } - - for (RawRootEntry entry : getActiveRawRoots(service)) { - AccessibilityNodeInfo editable = findFirstEditable(entry.root); - if (editable != null) return editable; - } - - return null; } - private static AccessibilityNodeInfo findFirstEditable(AccessibilityNodeInfo node) { - if (node == null) return null; + private static AccessibilityNodeInfo findFirstEditable(AccessibilityNodeInfo node, int depth) { + if (node == null || depth > MAX_DEPTH) return null; try { - if (node.isEditable() && node.isFocusable() && node.isEnabled()) { + if (node.isEditable() && node.isFocusable() && node.isEnabled() && node.isVisibleToUser()) { return node; } int count = node.getChildCount(); for (int i = 0; i < count; i++) { - AccessibilityNodeInfo child = node.getChild(i); + AccessibilityNodeInfo child = childOf(node, i); if (child != null) { - AccessibilityNodeInfo res = findFirstEditable(child); + AccessibilityNodeInfo res = findFirstEditable(child, depth + 1); if (res != null) return res; safeRecycle(child); } diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java new file mode 100644 index 0000000..7e70904 --- /dev/null +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java @@ -0,0 +1,36 @@ +package com.artemis.helper; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * Receives the host's session token. + * + * The receiver is guarded in the manifest by + * {@code android.permission.WRITE_SECURE_SETTINGS}, a permission ordinary apps + * cannot hold but the adb shell user does, so only something with adb access to + * the device can set (or clear) the token: + * + *
+ * adb shell am broadcast -n com.artemis.helper/.TokenReceiver \
+ *     -a com.artemis.helper.SET_TOKEN --es token <hex>
+ * 
+ */ +public class TokenReceiver extends BroadcastReceiver { + + private static final String TAG = "ArtemisTokenReceiver"; + public static final String ACTION_SET_TOKEN = "com.artemis.helper.SET_TOKEN"; + public static final String EXTRA_TOKEN = "token"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || !ACTION_SET_TOKEN.equals(intent.getAction())) { + return; + } + String value = intent.getStringExtra(EXTRA_TOKEN); + TokenStore.set(value); + Log.i(TAG, TokenStore.isSet() ? "Session token set" : "Session token cleared"); + } +} diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java new file mode 100644 index 0000000..2062fc1 --- /dev/null +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java @@ -0,0 +1,38 @@ +package com.artemis.helper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * The session token the host pushed for this process lifetime. + * + * The loopback server is reachable by every app on the device, so requests are + * only served when they carry this token. It lives in memory only: a killed or + * re-bound service starts without one and answers 401 until the host pushes it + * again (which the host does on every attach and on every 401). + */ +public final class TokenStore { + + private static volatile String token; + + private TokenStore() {} + + public static void set(String value) { + token = (value == null || value.isEmpty()) ? null : value; + } + + public static boolean isSet() { + return token != null; + } + + /** Constant-time comparison so a local app cannot guess the token byte by byte. */ + public static boolean matches(String candidate) { + String current = token; + if (current == null || candidate == null) { + return false; + } + return MessageDigest.isEqual( + current.getBytes(StandardCharsets.UTF_8), + candidate.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml b/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml index 359c504..ee07cb5 100644 --- a/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +++ b/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml @@ -1,9 +1,9 @@ /dev/null 2>&1; then + cygpath -u "$1" + else + printf '%s' "$1" + fi +} + # 1. Locate Android SDK -SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" -if [ ! -d "$SDK_ROOT" ]; then - for candidate in /opt/android-sdk /usr/local/share/android-sdk "$HOME/Android/Sdk"; do - if [ -d "$candidate" ]; then +SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [ -n "$SDK_ROOT" ]; then + SDK_ROOT="$(_to_posix "$SDK_ROOT")" +fi +if [ -z "$SDK_ROOT" ] || [ ! -d "$SDK_ROOT" ]; then + for candidate in \ + "$HOME/Library/Android/sdk" \ + "$HOME/Android/Sdk" \ + /opt/android-sdk \ + /usr/local/share/android-sdk \ + "$( [ -n "$LOCALAPPDATA" ] && _to_posix "$LOCALAPPDATA/Android/Sdk" )"; do + if [ -n "$candidate" ] && [ -d "$candidate" ]; then SDK_ROOT="$candidate" break fi done fi -if [ ! -d "$SDK_ROOT" ]; then - echo "Error: Android SDK not found at $SDK_ROOT. Set ANDROID_HOME." >&2 +if [ -z "$SDK_ROOT" ] || [ ! -d "$SDK_ROOT" ]; then + echo "Error: Android SDK not found. Set ANDROID_HOME." >&2 exit 1 fi @@ -35,9 +62,15 @@ fi # 2. Locate Java compiler and runtime if [ -z "$JAVA_HOME" ]; then - if [ -d "/Applications/Android Studio.app/Contents/jbr/Contents/Home" ]; then - export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" - fi + for candidate in \ + "/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ + "$(_to_posix "${PROGRAMFILES:-/c/Program Files}/Android/Android Studio/jbr")" \ + "/opt/android-studio/jbr"; do + if [ -n "$candidate" ] && [ -x "$candidate/bin/javac" ] || [ -x "$candidate/bin/javac.exe" ]; then + export JAVA_HOME="$candidate" + break + fi + done fi if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH" @@ -48,8 +81,49 @@ if ! command -v javac >/dev/null 2>&1; then exit 1 fi +PYTHON_BIN="${PYTHON:-}" +if [ -z "$PYTHON_BIN" ]; then + # The Windows Store "python" stub is on PATH but exits 49; prefer the project venv. + for candidate in "$DIR/../../.venv/Scripts/python.exe" "$DIR/../../.venv/bin/python" python3 python; do + if "$candidate" -c "import zipfile" >/dev/null 2>&1; then + PYTHON_BIN="$candidate" + break + fi + done +fi +if [ -z "$PYTHON_BIN" ]; then + echo "Error: python3 not found (needed to package the DEX and write helper_manifest.json)" >&2 + exit 1 +fi + +# Build-tools ship as .exe / .bat wrappers on Windows; resolve the right name once. +_tool() { + local base="$BUILD_TOOLS/$1" + for suffix in "" ".exe" ".bat"; do + if [ -f "$base$suffix" ]; then + printf '%s' "$base$suffix" + return 0 + fi + done + echo "Error: $1 not found under $BUILD_TOOLS" >&2 + exit 1 +} +AAPT2="$(_tool aapt2)" +D8="$(_tool d8)" +ZIPALIGN="$(_tool zipalign)" +APKSIGNER="$(_tool apksigner)" + +# 3. Version: single source is app/build.gradle.kts +VERSION_CODE="$(sed -n 's/^[[:space:]]*versionCode[[:space:]]*=[[:space:]]*\([0-9][0-9]*\).*/\1/p' app/build.gradle.kts | head -n 1)" +VERSION_NAME="$(sed -n 's/^[[:space:]]*versionName[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' app/build.gradle.kts | head -n 1)" +if [ -z "$VERSION_CODE" ] || [ -z "$VERSION_NAME" ]; then + echo "Error: could not read versionCode / versionName from app/build.gradle.kts" >&2 + exit 1 +fi + echo "==================================================" echo " Building ArtemisAccessibilityHelper.apk" +echo " Version: $VERSION_NAME (code $VERSION_CODE)" echo " Build Tools: $(basename "$BUILD_TOOLS")" echo " Platform: $(basename "$PLATFORM")" echo " Java: $(javac -version 2>&1)" @@ -59,14 +133,16 @@ rm -rf build mkdir -p build/gen build/obj build/apk build/res_compiled echo "-> Compiling resources..." -"$BUILD_TOOLS/aapt2" compile --dir app/src/main/res -o build/res_compiled/res.zip +"$AAPT2" compile --dir app/src/main/res -o build/res_compiled/res.zip echo "-> Linking APK..." -"$BUILD_TOOLS/aapt2" link \ +"$AAPT2" link \ -I "$PLATFORM/android.jar" \ --manifest app/src/main/AndroidManifest.xml \ --min-sdk-version 24 \ --target-sdk-version 35 \ + --version-code "$VERSION_CODE" \ + --version-name "$VERSION_NAME" \ --java build/gen \ -o build/apk/unaligned.apk \ build/res_compiled/res.zip @@ -74,25 +150,29 @@ echo "-> Linking APK..." echo "-> Compiling Java sources..." JAVA_FILES="$(find build/gen app/src/main/java -name "*.java")" javac -encoding UTF-8 \ + -source 8 -target 8 \ + -Xlint:-options \ -cp "$PLATFORM/android.jar" \ -d build/obj \ $JAVA_FILES echo "-> Dexing with D8..." CLASS_FILES="$(find build/obj -name "*.class")" -"$BUILD_TOOLS/d8" \ +"$D8" \ --lib "$PLATFORM/android.jar" \ --output build/apk/ \ --min-api 24 \ $CLASS_FILES echo "-> Packaging DEX..." -cd build/apk -zip -u -q unaligned.apk classes.dex -cd "$DIR" +"$PYTHON_BIN" - <<'PY' +import zipfile +with zipfile.ZipFile("build/apk/unaligned.apk", "a", compression=zipfile.ZIP_DEFLATED) as apk: + apk.write("build/apk/classes.dex", "classes.dex") +PY echo "-> Zipaligning..." -"$BUILD_TOOLS/zipalign" -f -p 4 build/apk/unaligned.apk build/apk/aligned.apk +"$ZIPALIGN" -f -p 4 build/apk/unaligned.apk build/apk/aligned.apk echo "-> Signing APK..." if [ ! -f debug.keystore ]; then @@ -103,7 +183,7 @@ if [ ! -f debug.keystore ]; then -dname "CN=Artemis Debug,O=Artemis,C=US" fi -"$BUILD_TOOLS/apksigner" sign \ +"$APKSIGNER" sign \ --ks debug.keystore \ --ks-pass pass:android \ --key-pass pass:android \ @@ -111,6 +191,23 @@ fi --out ArtemisAccessibilityHelper.apk \ build/apk/aligned.apk +echo "-> Writing helper_manifest.json..." +VERSION_CODE="$VERSION_CODE" VERSION_NAME="$VERSION_NAME" "$PYTHON_BIN" - <<'PY' +import datetime, hashlib, json, os +digest = hashlib.sha256(open("ArtemisAccessibilityHelper.apk", "rb").read()).hexdigest() +manifest = { + "package": "com.artemis.helper", + "version_code": int(os.environ["VERSION_CODE"]), + "version_name": os.environ["VERSION_NAME"], + "sha256": digest, + "built_at": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat(), +} +with open("helper_manifest.json", "w", encoding="utf-8", newline="\n") as fh: + json.dump(manifest, fh, indent=2) + fh.write("\n") +print(json.dumps(manifest, indent=2)) +PY + echo "==================================================" echo " Build SUCCESSFUL!" echo " Artifact: $DIR/ArtemisAccessibilityHelper.apk" diff --git a/packages/artemis-accessibility-helper/helper_manifest.json b/packages/artemis-accessibility-helper/helper_manifest.json new file mode 100644 index 0000000..f8d979d --- /dev/null +++ b/packages/artemis-accessibility-helper/helper_manifest.json @@ -0,0 +1,7 @@ +{ + "package": "com.artemis.helper", + "version_code": 6, + "version_name": "1.2.0", + "sha256": "133e373fe03615c251cd74b03a38539c844d09a074954bca2d2dde6684561969", + "built_at": "2026-09-10T23:13:11+00:00" +} diff --git a/tests/unit/clients/test_accessibility_client.py b/tests/unit/clients/test_accessibility_client.py index ccc8546..2d722dc 100644 --- a/tests/unit/clients/test_accessibility_client.py +++ b/tests/unit/clients/test_accessibility_client.py @@ -1,87 +1,364 @@ -import unittest +"""AccessibilityClient: HTTP surface, token handling, element-shape parity, repairs.""" + +from __future__ import annotations + +import base64 +from io import BytesIO +import json +import urllib.error from unittest.mock import MagicMock, patch -from artemis.clients.accessibility_client import AccessibilityClient, DEFAULT_PORT + +from PIL import Image +import pytest + +from artemis.clients.accessibility_client import ( + TOKEN_HEADER, + AccessibilityClient, + HelperEmptyHierarchy, + HelperRequestError, + HelperUnavailable, + normalize_helper_elements, +) +from artemis.clients.ui_automator_client import _parse_hierarchy_xml_to_elements +from artemis.runtime.helper_manager import HelperSession + +XML = ( + '' + '' + '' + "" +) +TOKEN = "a" * 48 -class TestAccessibilityClient(unittest.TestCase): - def setUp(self): - self.client = AccessibilityClient(device_id="dummy_device", local_port=DEFAULT_PORT) +class FakeManager: + def __init__(self): + self.session = self._session(41000) + self.attach_calls: list[bool] = [] + self.reattach_calls = 0 + self.pushed_tokens = 0 + self.detached = False + self.fail_reattach = False - @patch("artemis.clients.accessibility_client.urllib.request.urlopen") - def test_ping_success(self, mock_urlopen): - mock_resp = MagicMock() - mock_resp.read.return_value = b'{"success": true, "service": "ArtemisAccessibilityService"}' - mock_resp.__enter__.return_value = mock_resp - mock_urlopen.return_value = mock_resp + @staticmethod + def _session(port: int) -> HelperSession: + return HelperSession( + serial="dev", + local_port=port, + transport_id="3", + version_code=6, + version_name="1.2.0", + owns_forward=True, + token=TOKEN, + protocol_version=2, + ) - self.assertTrue(self.client.ping()) + def attach(self, serial, *, provision=True, on_event=None): + self.attach_calls.append(provision) + if on_event is not None: + on_event("installing", {"serial": serial}) + return self.session - @patch("artemis.clients.accessibility_client.urllib.request.urlopen") - def test_get_hierarchy(self, mock_urlopen): - mock_resp = MagicMock() - mock_resp.read.return_value = b'{"success": true, "elements": [{"text": "OK"}]}' - mock_resp.__enter__.return_value = mock_resp - mock_urlopen.return_value = mock_resp + def reattach(self, serial): + self.reattach_calls += 1 + if self.fail_reattach: + raise HelperUnavailable("dead") + self.session = self._session(41001) + return self.session - data = self.client.get_hierarchy() - self.assertTrue(data["success"]) - self.assertEqual(data["elements"][0]["text"], "OK") + def push_token(self, serial): + self.pushed_tokens += 1 + return True - @patch("artemis.clients.accessibility_client.urllib.request.urlopen") - def test_tap_action(self, mock_urlopen): - mock_resp = MagicMock() - mock_resp.read.return_value = b'{"success": true}' - mock_resp.__enter__.return_value = mock_resp - mock_urlopen.return_value = mock_resp + def host_token(self): + return TOKEN - @patch("artemis.clients.accessibility_client.urllib.request.urlopen") - def test_get_hierarchy_xml(self, mock_urlopen): - mock_resp = MagicMock() - mock_resp.read.return_value = b'' - mock_resp.__enter__.return_value = mock_resp - mock_urlopen.return_value = mock_resp + def detach(self, serial): + self.detached = True - xml = self.client.get_hierarchy_xml() - self.assertIn("
', - "elements": [{"text": "OK"}], - "width": 1080, - "height": 2400, + +def _response(body: bytes): + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + +def _http_error(code: int, body: bytes = b'{"success": false, "error": "no"}'): + return urllib.error.HTTPError("http://x", code, "err", {}, BytesIO(body)) + + +def _urlopen(): + return patch("artemis.clients.accessibility_client.urllib.request.urlopen") + + +@pytest.fixture +def client(): + manager = FakeManager() + with patch("artemis.clients.accessibility_client.ensure_device_awake", return_value=None): + yield AccessibilityClient("dev", manager=manager), manager + + +# --------------------------------------------------------------------------- # +# Session and transport +# --------------------------------------------------------------------------- # + + +def test_connect_provisions_and_lazy_path_does_not(client): + c, manager = client + events = [] + c.connect(on_event=lambda name, details: events.append(name)) + assert manager.attach_calls == [True] + assert events == ["installing"] + assert c.active_backend == "helper" + + lazy = AccessibilityClient("dev", manager=FakeManager()) + with ( + patch("artemis.clients.accessibility_client.ensure_device_awake", return_value=None), + _urlopen() as urlopen, + ): + urlopen.return_value = _response(XML.encode()) + assert lazy.get_hierarchy() == XML + assert lazy._manager.attach_calls == [False] + + +def test_every_request_carries_the_session_token(client): + c, _ = client + with _urlopen() as urlopen: + urlopen.return_value = _response(XML.encode()) + assert c.get_hierarchy() == XML + request = urlopen.call_args.args[0] + assert request.full_url == "http://127.0.0.1:41000/dump_xml" + # urllib stores header names capitalized: "X-artemis-token". + assert request.get_header(TOKEN_HEADER.capitalize()) == TOKEN + + +def test_401_pushes_the_token_again_and_retries_once(client): + c, manager = client + with _urlopen() as urlopen: + urlopen.side_effect = [_http_error(401), _response(XML.encode())] + assert c.get_hierarchy() == XML + assert manager.pushed_tokens == 1 + assert manager.reattach_calls == 0 # a tunnel rebuild would not fix a token problem + + with _urlopen() as urlopen: + urlopen.side_effect = [_http_error(401), _http_error(401)] + with pytest.raises(HelperRequestError) as info: + c.get_hierarchy() + assert info.value.status == 401 + assert manager.pushed_tokens == 2 + + +def test_other_http_errors_surface_without_repairs(client): + c, manager = client + with _urlopen() as urlopen: + urlopen.side_effect = _http_error(404, b"Unknown endpoint") + with pytest.raises(HelperRequestError, match="HTTP 404"): + c.get_hierarchy() + assert manager.reattach_calls == 0 and manager.pushed_tokens == 0 + + +def test_transport_failure_reattaches_once_and_retries(client): + c, manager = client + with _urlopen() as urlopen: + urlopen.side_effect = [urllib.error.URLError("connection refused"), _response(XML.encode())] + assert c.get_hierarchy() == XML + assert manager.reattach_calls == 1 + assert urlopen.call_args.args[0].full_url == "http://127.0.0.1:41001/dump_xml" + + +def test_transport_failure_after_reattach_propagates(client): + c, manager = client + manager.fail_reattach = True + with _urlopen() as urlopen: + urlopen.side_effect = urllib.error.URLError("connection refused") + with pytest.raises(HelperUnavailable): + c.get_hierarchy() + assert manager.reattach_calls == 1 + + +def test_action_timeout_does_not_repeat_text_entry(client): + c, manager = client + with _urlopen() as urlopen: + urlopen.side_effect = TimeoutError("response lost after action") + assert c.send_text("hello") is False + assert urlopen.call_count == 1 + assert manager.reattach_calls == 0 + + +# --------------------------------------------------------------------------- # +# Screen data: one /snapshot request, UIAutomator element shapes +# --------------------------------------------------------------------------- # + + +def test_screen_data_is_one_snapshot_request_with_xml_only(client): + c, _ = client + snapshot = { + "success": True, + "has_screenshot": True, + "screenshot_base64": "QUJD", + "xml": XML, + "width": 1080, + "height": 2424, + } + with _urlopen() as urlopen: + urlopen.return_value = _response(json.dumps(snapshot).encode()) + data = c.get_screen_data() + assert urlopen.call_count == 1 + assert urlopen.call_args.args[0].full_url == "http://127.0.0.1:41000/snapshot?fields=xml" + + assert data.base64 == "QUJD" and data.width == 1080 and data.height == 2424 + assert data.hierarchy_xml == XML + assert data.elements == _parse_hierarchy_xml_to_elements(XML) + element = data.elements[1] + assert element["clickable"] == "true" + assert element["visible-to-user"] == "true" + assert element["accessibilityText"] == "Confirm" + assert element["parsed_bounds"] == {"left": 10, "top": 20, "right": 110, "bottom": 70} + assert element["state-description"] == "on" + + +def test_screen_data_without_screenshot_reuses_the_hierarchy_and_adds_screencap(client): + c, _ = client + no_shot = { + "success": True, + "has_screenshot": False, + "screenshot_error": "takeScreenshot not supported on Android < 11", + "xml": XML, + "width": 1080, + "height": 2400, + } + img = Image.new("RGB", (4, 6), "white") + with ( + _urlopen() as urlopen, + patch.object(AccessibilityClient, "get_screenshot", return_value=img), + ): + urlopen.return_value = _response(json.dumps(no_shot).encode()) + data = c.get_screen_data() + assert urlopen.call_count == 1 # the hierarchy is not dumped a second time + assert data.width == 4 and data.height == 6 + assert Image.open(BytesIO(base64.b64decode(data.base64))).size == (4, 6) + assert data.elements == _parse_hierarchy_xml_to_elements(XML) + + +def test_screen_data_falls_back_to_normalized_json_without_xml(client): + c, _ = client + snapshot = { + "success": True, + "has_screenshot": True, + "screenshot_base64": "QUJD", + "xml": "", + "elements": [ + { + "text": "", + "content-desc": "Menu", + "clickable": True, + "focused": False, + "visible-to-user": True, + "parsed_bounds": {"left": 1.0, "top": 2, "right": 3, "bottom": 4}, + "children": [{"text": "x"}], + } + ], + "width": 1080, + "height": 2400, + } + with _urlopen() as urlopen: + urlopen.return_value = _response(json.dumps(snapshot).encode()) + data = c.get_screen_data() + assert data.elements == [ + { + "text": "", + "content-desc": "Menu", + "accessibilityText": "Menu", + "clickable": "true", + "focused": "false", + "visible-to-user": "true", + "parsed_bounds": {"left": 1, "top": 2, "right": 3, "bottom": 4}, } - screen_data = self.client.get_screen_data() - self.assertEqual(screen_data.width, 1080) - self.assertEqual(screen_data.height, 2400) - self.assertEqual(screen_data.base64, "dummy_b64") - self.assertIn('rotation="0"', screen_data.hierarchy_xml) - self.assertEqual(len(screen_data.elements), 1) - - @patch.object(AccessibilityClient, "get_atomic_snapshot", return_value=None) - @patch.object(AccessibilityClient, "get_screenshot") - @patch.object(AccessibilityClient, "get_hierarchy") - def test_get_screen_data_fallback(self, mock_get_hierarchy, mock_get_screenshot, _mock_atomic): - from PIL import Image - - mock_get_screenshot.return_value = Image.new("RGB", (1080, 2400)) - mock_get_hierarchy.return_value = { - "success": True, - "xml": '', - "elements": [{"text": "Fallback"}], - } - - screen_data = self.client.get_screen_data() - self.assertEqual(screen_data.width, 1080) - self.assertEqual(screen_data.height, 2400) - self.assertIn("Fallback", screen_data.hierarchy_xml) - self.assertEqual(len(screen_data.elements), 1) + ] -if __name__ == "__main__": - unittest.main() +def test_empty_hierarchy_is_an_error_not_a_blank_screen(client): + c, _ = client + answer = {"success": False, "error": "No active window or root node found", "xml": ""} + with _urlopen() as urlopen: + urlopen.return_value = _response(json.dumps(answer).encode()) + with pytest.raises(HelperEmptyHierarchy, match="No active window"): + c.get_screen_data() + + +def test_screenshot_without_dimensions_is_rejected(client): + c, _ = client + answer = {"success": True, "has_screenshot": True, "screenshot_base64": "QUJD", "xml": XML} + with _urlopen() as urlopen: + urlopen.return_value = _response(json.dumps(answer).encode()) + with pytest.raises(HelperRequestError, match="without dimensions"): + c.get_screen_data() + + +# --------------------------------------------------------------------------- # +# Input +# --------------------------------------------------------------------------- # + + +def test_set_clipboard_sends_raw_utf8(client): + c, _ = client + with _urlopen() as urlopen: + urlopen.return_value = _response(b'{"success": true}') + assert c.set_clipboard("héllo\n你好") is True + request = urlopen.call_args.args[0] + assert request.full_url == "http://127.0.0.1:41000/action" + assert request.data == '{"cmd": "clipboard", "text": "héllo\\n你好"}'.encode() + assert request.get_header("Content-type") == "application/json; charset=utf-8" + + +def test_send_text_appends_like_an_ime(client): + c, _ = client + with _urlopen() as urlopen: + urlopen.return_value = _response(b'{"success": true}') + assert c.send_text("abc") is True + assert json.loads(urlopen.call_args.args[0].data) == { + "cmd": "type", + "text": "abc", + "append": True, + } + + +def test_rpc_failure_returns_false_instead_of_raising(client): + c, manager = client + manager.fail_reattach = True + with _urlopen() as urlopen: + urlopen.side_effect = urllib.error.URLError("refused") + assert c.clear_text() is False + with _urlopen() as urlopen: + urlopen.side_effect = _http_error(500) + assert c.clear_text() is False + + +def test_press_key_uses_global_action_for_navigation_keys(client): + c, _ = client + with _urlopen() as urlopen: + urlopen.return_value = _response(b'{"success": true}') + assert c.press_key("BACK") is True + assert json.loads(urlopen.call_args.args[0].data) == {"cmd": "global", "action": "back"} + + +def test_disconnect_detaches(client): + c, manager = client + c.connect() + c.disconnect() + assert manager.detached and c.active_backend is None + + +def test_normalize_helper_elements_tolerates_bad_input(): + assert normalize_helper_elements(None) == [] + assert normalize_helper_elements("x") == [] + assert normalize_helper_elements({"clickable": True}) == [{"clickable": "true"}] diff --git a/tests/unit/clients/test_screen_client_factory.py b/tests/unit/clients/test_screen_client_factory.py new file mode 100644 index 0000000..836af3b --- /dev/null +++ b/tests/unit/clients/test_screen_client_factory.py @@ -0,0 +1,227 @@ +"""Backend selection and the helper-first / UIAutomator-fallback composite.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from artemis.clients.accessibility_client import AccessibilityClient, HelperUnavailable +from artemis.clients.screen_client_factory import ( + DeviceOfflineError, + FallbackScreenClient, + HierarchyBackend, + create_screen_client, + describe_backend, + hierarchy_backend_sentence, + hierarchy_backend_summary, + resolve_backend, +) +from artemis.clients.ui_automator_client import UIAutomatorClient +from artemis.config.constants import ENV_ARTEMIS_HIERARCHY_BACKEND + + +# --------------------------------------------------------------------------- # +# resolve_backend / create_screen_client +# --------------------------------------------------------------------------- # + + +def test_resolve_backend_prefers_explicit_then_env_then_settings(monkeypatch): + monkeypatch.delenv(ENV_ARTEMIS_HIERARCHY_BACKEND, raising=False) + from artemis.config import settings + + monkeypatch.setattr(settings, "ARTEMIS_HIERARCHY_BACKEND", "uiautomator", raising=False) + assert resolve_backend() is HierarchyBackend.UIAUTOMATOR + + monkeypatch.setenv(ENV_ARTEMIS_HIERARCHY_BACKEND, "Helper") + assert resolve_backend() is HierarchyBackend.HELPER + + assert resolve_backend("auto") is HierarchyBackend.AUTO + assert resolve_backend(HierarchyBackend.HELPER) is HierarchyBackend.HELPER + + +def test_resolve_backend_unknown_value_falls_back_to_auto(monkeypatch): + monkeypatch.setenv(ENV_ARTEMIS_HIERARCHY_BACKEND, "maestro") + assert resolve_backend() is HierarchyBackend.AUTO + + +def test_create_screen_client_per_backend(): + assert isinstance(create_screen_client("d", "uiautomator"), UIAutomatorClient) + assert isinstance(create_screen_client("d", "helper"), AccessibilityClient) + composite = create_screen_client("d", "auto") + assert isinstance(composite, FallbackScreenClient) + assert describe_backend(composite) is None # nothing served yet + assert describe_backend(UIAutomatorClient("d")) == "uiautomator" + assert describe_backend(AccessibilityClient("d")) == "helper" + + +# --------------------------------------------------------------------------- # +# FallbackScreenClient +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def composite(): + helper = MagicMock(spec=AccessibilityClient) + u2 = MagicMock(spec=UIAutomatorClient) + factory = MagicMock(return_value=u2) + clock = {"now": 0.0, "state": "device"} + client = FallbackScreenClient( + "dev", + helper=helper, + uiautomator_factory=factory, + retry_after=30.0, + clock=lambda: clock["now"], + state_probe=lambda _serial: clock["state"], + ) + return client, helper, u2, factory, clock + + +def test_healthy_helper_never_constructs_uiautomator(composite): + client, helper, _, factory, _ = composite + helper.get_hierarchy.return_value = "" + client.connect() + assert client.get_hierarchy() == "" + assert client.active_backend == "helper" + factory.assert_not_called() + + +def test_connect_failure_degrades_to_uiautomator(composite): + client, helper, u2, factory, _ = composite + helper.connect.side_effect = HelperUnavailable("not installed") + client.connect() + factory.assert_called_once() + u2.connect.assert_called_once() + assert client.active_backend == "uiautomator" + + +def test_call_failure_falls_back_then_retries_helper_after_window(composite): + client, helper, u2, _, clock = composite + helper.get_hierarchy.side_effect = [RuntimeError("tunnel gone"), ""] + u2.get_hierarchy.return_value = "" + + assert client.get_hierarchy() == "" + assert client.active_backend == "uiautomator" + + # Inside the retry window the helper is not touched again. + clock["now"] = 10.0 + assert client.get_hierarchy() == "" + assert helper.get_hierarchy.call_count == 1 + + # After the window the helper is tried again and wins. + clock["now"] = 31.0 + assert client.get_hierarchy() == "" + assert client.active_backend == "helper" + + +def test_uiautomator_failure_propagates(composite): + client, helper, u2, _, _ = composite + helper.get_screen_data.side_effect = RuntimeError("helper down") + u2.get_screen_data.side_effect = RuntimeError("u2 down too") + with pytest.raises(RuntimeError, match="u2 down too"): + client.get_screen_data() + + +def test_set_clipboard_goes_to_helper_first(composite): + client, helper, u2, _, _ = composite + helper.set_clipboard.return_value = True + assert client.set_clipboard("x") is True + u2.set_clipboard.assert_not_called() + + +def test_disconnect_releases_both_when_uiautomator_was_used(composite): + client, helper, u2, _, _ = composite + helper.connect.side_effect = HelperUnavailable("nope") + client.connect() + client.disconnect() + helper.disconnect.assert_called_once() + u2.disconnect.assert_called_once() + assert client.active_backend is None + + +def test_strict_helper_backend_surfaces_errors(): + manager = MagicMock() + manager.attach.side_effect = HelperUnavailable("not installed") + client = AccessibilityClient("dev", manager=manager) + with pytest.raises(HelperUnavailable): + client.connect() + + +def test_offline_device_fails_fast_without_touching_uiautomator(composite): + client, helper, u2, factory, clock = composite + helper.get_screen_data.side_effect = RuntimeError("connection refused") + clock["state"] = "unauthorized" + with pytest.raises(DeviceOfflineError, match="unauthorized"): + client.get_screen_data() + factory.assert_not_called() + + clock["state"] = None + with pytest.raises(DeviceOfflineError, match="does not list it"): + client.get_screen_data() + factory.assert_not_called() + + +def test_backend_history_and_listeners_track_every_switch(composite): + client, helper, u2, _, clock = composite + seen = [] + client.add_backend_listener(lambda prev, new, reason: seen.append((prev, new, reason))) + helper.get_hierarchy.side_effect = [ + "", + RuntimeError("tunnel gone"), + "", + ] + u2.get_hierarchy.return_value = "" + + client.get_hierarchy() # helper + client.get_hierarchy() # helper fails -> uiautomator + clock["now"] = 60.0 + client.get_hierarchy() # helper recovers + + assert [(p, n) for p, n, _ in seen] == [ + (None, "helper"), + ("helper", "uiautomator"), + ("uiautomator", "helper"), + ] + assert seen[1][2] == "RuntimeError: tunnel gone" + assert seen[2][2] == "helper recovered" + assert [h["backend"] for h in client.backend_history] == ["helper", "uiautomator", "helper"] + + summary = hierarchy_backend_summary(client) + assert summary["backend"] == "helper" and len(summary["switches"]) == 2 + sentence = hierarchy_backend_sentence(client, relative_time=lambda t: "T+00:10") + assert sentence.startswith("UI hierarchy source: the Artemis accessibility helper") + assert "switched to UIAutomator2 at T+00:10 because RuntimeError: tunnel gone" in sentence + + +def test_listener_errors_do_not_break_the_client(composite): + client, helper, _, _, _ = composite + client.add_backend_listener(lambda *_: (_ for _ in ()).throw(RuntimeError("boom"))) + helper.get_hierarchy.return_value = "" + assert client.get_hierarchy() == "" + + +def test_sentence_is_none_before_first_use(composite): + client, *_ = composite + assert hierarchy_backend_sentence(client) is None + assert hierarchy_backend_summary(client) is None + + +def test_helper_retry_releases_uiautomation_first(composite): + """While our UIAutomator2 server lives the helper cannot bind; stop it before retrying.""" + client, helper, u2, _, clock = composite + helper.get_hierarchy.side_effect = [RuntimeError("tunnel gone"), ""] + u2.get_hierarchy.return_value = "" + assert client.get_hierarchy() == "" + u2.stop_server.assert_not_called() # inside the retry window nothing is touched + + clock["now"] = 31.0 + assert client.get_hierarchy() == "" + u2.stop_server.assert_called_once() + + +def test_disconnect_stops_the_uiautomator_server_it_started(composite): + client, helper, u2, _, _ = composite + helper.connect.side_effect = HelperUnavailable("nope") + client.connect() + client.disconnect() + u2.disconnect.assert_called_once_with(stop_server=True) diff --git a/tests/unit/core/test_device_smoke.py b/tests/unit/core/test_device_smoke.py index 605d54b..8fd6e36 100644 --- a/tests/unit/core/test_device_smoke.py +++ b/tests/unit/core/test_device_smoke.py @@ -93,6 +93,7 @@ async def test_success_reports_bytes_elements_serial_and_elapsed(patch_controlle "elapsed_seconds", "screenshot_bytes", "element_count", + "hierarchy_backend", "error", "fix", } diff --git a/tests/unit/mcp/test_diagnose_tool.py b/tests/unit/mcp/test_diagnose_tool.py index 1efd6dd..cd4627f 100644 --- a/tests/unit/mcp/test_diagnose_tool.py +++ b/tests/unit/mcp/test_diagnose_tool.py @@ -279,10 +279,21 @@ def _run( cleanup=0, validate=None, smoke=None, + helper_status=None, + helper_provision=None, + backend="auto", **kwargs, ): """Run the tool with every side-effecting collaborator stubbed.""" with ExitStack() as stack: + fake_helper = MagicMock() + fake_helper.status = MagicMock( + side_effect=lambda serial: dict(helper_status or _helper_healthy(serial)) + ) + fake_helper.provision = helper_provision or MagicMock(return_value=_provision_ok()) + stack.enter_context(patch.object(diagnose, "helper_manager", fake_helper)) + stack.enter_context(patch.object(diagnose, "_hierarchy_backend", lambda: backend)) + kwargs["_fake_helper"] = fake_helper stack.enter_context( patch.object( diagnose.readiness_engine, @@ -342,7 +353,35 @@ def _run( smoke or AsyncMock(return_value=_smoke_ok()), ) ) - return asyncio.run(mobile_diagnose(**kwargs)) + fake_helper = kwargs.pop("_fake_helper") + result = asyncio.run(mobile_diagnose(**kwargs)) + result["_fake_helper"] = fake_helper + return result + + +def _helper_healthy(serial: str = "pixel-1") -> dict: + return { + "package": "com.artemis.helper", + "installed": True, + "installed_version": 2, + "bundled_version": 2, + "bundled_apk_present": True, + "outdated": False, + "enabled": True, + "forward_port": 41234, + "reachable": True, + "reported_version": 2, + "transport_id": "7", + "session": None, + } + + +def _provision_ok(): + from artemis.runtime.helper_manager import ProvisionResult + + return ProvisionResult( + ok=True, action="installed", installed_version=2, bundled_version=2, enabled=True + ) def _smoke_ok(serial: str = "pixel-1") -> dict: @@ -433,6 +472,7 @@ def test_ready_environment_reports_ready_with_slim_shape(temp_trace_env): "android_version": "15", "is_locked": False, "is_emulator": False, + "accessibility_helper": {**_helper_healthy(), "serial": "pixel-1", "backend": "auto"}, } assert result["host"] == { "server_python": "/proj/.venv/bin/python", @@ -946,6 +986,11 @@ def test_attempt_fix_heals_corrupted_keys_then_rechecks(temp_trace_env): patch.object(diagnose.DeviceExecutionLock, "get_active_owners", return_value={}), patch.object(diagnose.DeviceExecutionLock, "get_queued_tasks", return_value=[]), patch.object(IntegrationHostProbe, "probe", AsyncMock(return_value=_host())), + patch.object( + diagnose, + "_helper_status", + lambda serial: {**_helper_healthy(serial), "serial": serial, "backend": "auto"}, + ), ): result = asyncio.run(mobile_diagnose(attempt_fix=True)) @@ -1139,3 +1184,104 @@ def test_logs_surface_recent_errors_and_last_failed_task(temp_trace_env, monkeyp "Traceback (most recent call last):", "RuntimeError: device offline", ] + + +# --------------------------------------------------------------------------- # +# Accessibility helper (UI hierarchy backend) +# --------------------------------------------------------------------------- # + + +def test_missing_helper_in_auto_mode_degrades_with_optional_step(temp_trace_env): + status = { + **_helper_healthy(), + "installed": False, + "installed_version": None, + "reachable": False, + } + result = _run(_healthy_probes(), helper_status=status) + + assert result["verdict"] == "degraded" + helper = result["device"] is None or result["device"]["accessibility_helper"] + assert helper + optional = [s for s in result["next_steps"] if s.startswith("[OPTIONAL] Accessibility helper")] + assert len(optional) == 1 and "is not installed" in optional[0] + assert " Run: uv run artemis helper install --serial pixel-1" in result["next_steps"] + assert any("attempt_fix=true" in s for s in result["next_steps"]) + result["_fake_helper"].provision.assert_not_called() + + +def test_missing_helper_in_helper_mode_blocks(temp_trace_env): + status = {**_helper_healthy(), "installed": False, "reachable": False} + result = _run(_healthy_probes(), helper_status=status, backend="helper") + + assert result["verdict"] == "blocked" + assert any(s.startswith("[REQUIRED] Accessibility helper") for s in result["next_steps"]) + + +def test_uiautomator_backend_ignores_helper_state(temp_trace_env): + status = {**_helper_healthy(), "installed": False, "reachable": False} + result = _run(_healthy_probes(), helper_status=status, backend="uiautomator") + + assert result["verdict"] == "ready" + assert not any("Accessibility helper" in s for s in result["next_steps"]) + + +def test_outdated_helper_reports_versions(temp_trace_env): + status = {**_helper_healthy(), "installed_version": 1, "outdated": True} + result = _run(_healthy_probes(), helper_status=status) + + step = next(s for s in result["next_steps"] if "Accessibility helper" in s) + assert "device has version 1, bundled is 2" in step + + +def test_attempt_fix_provisions_missing_helper_on_idle_device(temp_trace_env): + status = { + **_helper_healthy(), + "installed": False, + "installed_version": None, + "reachable": False, + } + result = _run(_healthy_probes(), helper_status=status, attempt_fix=True) + + result["_fake_helper"].provision.assert_called_once_with("pixel-1") + fix = next(f for f in result["fixes_applied"] if f["fix"] == "install_accessibility_helper") + assert fix["success"] is True and fix["skipped"] is False + assert "installed" in fix["message"] + + +def test_attempt_fix_skips_helper_install_while_task_holds_device(temp_trace_env): + status = {**_helper_healthy(), "installed": False, "reachable": False} + result = _run( + _healthy_probes(), + helper_status=status, + attempt_fix=True, + active_owners={"pixel-1": _owner()}, + ) + + result["_fake_helper"].provision.assert_not_called() + fix = next(f for f in result["fixes_applied"] if f["fix"] == "install_accessibility_helper") + assert fix["skipped"] is True + + +def test_attempt_fix_leaves_healthy_helper_alone(temp_trace_env): + result = _run(_healthy_probes(), attempt_fix=True) + + result["_fake_helper"].provision.assert_not_called() + assert not any(f["fix"] == "install_accessibility_helper" for f in result["fixes_applied"]) + + +def test_disabled_helper_step_includes_the_manual_path(temp_trace_env): + from artemis.runtime.helper_manager import MANUAL_ENABLE_PATH + + status = {**_helper_healthy(), "enabled": False, "reachable": False} + result = _run(_healthy_probes(), helper_status=status) + step = next(s for s in result["next_steps"] if "Accessibility helper" in s) + assert "service is disabled" in step + assert any(MANUAL_ENABLE_PATH in s for s in result["next_steps"]) + + +def test_newer_helper_than_bundle_is_not_a_finding(temp_trace_env): + status = {**_helper_healthy(), "installed_version": 9, "newer_than_bundled": True} + result = _run(_healthy_probes(), helper_status=status) + assert result["verdict"] == "ready" + assert not any("Accessibility helper" in s for s in result["next_steps"]) diff --git a/tests/unit/runtime/test_helper_bundle.py b/tests/unit/runtime/test_helper_bundle.py new file mode 100644 index 0000000..45a2c3c --- /dev/null +++ b/tests/unit/runtime/test_helper_bundle.py @@ -0,0 +1,52 @@ +"""The bundled helper APK, its manifest, and the Gradle version must agree. + +``helper_manager`` decides installs and upgrades from ``helper_manifest.json``, +so a rebuilt APK without a regenerated manifest (or a version bump in Gradle +without a rebuild) would silently ship the wrong version gate. +""" + +from __future__ import annotations + +import hashlib +import json +import re + +import pytest + +from artemis.runtime.helper_manager import ( + BUNDLED_APK_PATH, + BUNDLED_MANIFEST_PATH, + HELPER_PACKAGE_DIR, + PACKAGE_NAME, + load_bundled_helper, +) + +pytestmark = pytest.mark.skipif( + not BUNDLED_APK_PATH.is_file(), reason="bundled helper APK is not present in this checkout" +) + + +def test_manifest_matches_apk_and_gradle_version(): + manifest = json.loads(BUNDLED_MANIFEST_PATH.read_text(encoding="utf-8")) + assert manifest["package"] == PACKAGE_NAME + assert manifest["sha256"] == hashlib.sha256(BUNDLED_APK_PATH.read_bytes()).hexdigest(), ( + "ArtemisAccessibilityHelper.apk was rebuilt without regenerating helper_manifest.json; " + "run packages/artemis-accessibility-helper/build_apk.sh" + ) + + gradle = (HELPER_PACKAGE_DIR / "app" / "build.gradle.kts").read_text(encoding="utf-8") + version_code = int(re.search(r"versionCode\s*=\s*(\d+)", gradle).group(1)) + version_name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1) + assert manifest["version_code"] == version_code + assert manifest["version_name"] == version_name + + +def test_load_bundled_helper_reads_manifest(): + bundled = load_bundled_helper() + assert bundled is not None + assert bundled.apk_path == BUNDLED_APK_PATH + assert bundled.version_code >= 2 # first version that reports version_code on /ping + + +def test_load_bundled_helper_returns_none_when_missing(tmp_path): + assert load_bundled_helper(tmp_path / "x.apk", tmp_path / "x.json") is None diff --git a/tests/unit/runtime/test_helper_manager.py b/tests/unit/runtime/test_helper_manager.py new file mode 100644 index 0000000..6acc976 --- /dev/null +++ b/tests/unit/runtime/test_helper_manager.py @@ -0,0 +1,716 @@ +"""Lifecycle of the Accessibility Helper: provision, attach, reattach, detach. + +Every adb call goes through a scripted fake so the tests assert the exact +command sequence the manager issues, and the HTTP ping is a stub keyed by host +port so "the service answers" is a switch the test flips. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import subprocess + +import pytest + +from artemis.runtime import helper_manager as hm +from artemis.runtime.helper_manager import ( + DEVICE_PORT, + SERVICE_NAME, + AccessibilityHelperManager, + BundledHelper, + HelperUnavailable, +) + +SERIAL = "pixel-1" +OTHER = "pixel-2" + + +class FakeAdb: + """Scripted adb: records commands and answers from a small device model.""" + + def __init__(self): + self.calls: list[list[str]] = [] + self.installed: dict[str, int | None] = {SERIAL: None, OTHER: None} + self.enabled_services: dict[str, str] = {SERIAL: "", OTHER: ""} + self.transport: dict[str, str] = {SERIAL: "3", OTHER: "4"} + self.forwards: list[tuple[str, int, int]] = [] # (serial, local, remote) + self.next_port = 40000 + self.install_result = "Success" + # Number of times the system "prunes" a freshly written service setting. + self.prune_writes = 0 + # Serials whose dead service was re-bound by a settings toggle. + self.revived: set[str] = set() + # HTTP protocol the installed helper speaks (the bundled build speaks 2). + self.protocol: dict[str, int] = {SERIAL: 2, OTHER: 2} + # Last token delivered by `am broadcast`, per serial. + self.tokens: dict[str, str] = {} + # Process table lines (PID ARGS) of UiAutomation holders, per serial. + self.holders: dict[str, list[str]] = {SERIAL: [], OTHER: []} + self.killed: list[tuple[str, int]] = [] + + def __call__(self, args: list[str]) -> subprocess.CompletedProcess: + self.calls.append(list(args)) + out, code = "", 0 + if args[:2] == ["devices", "-l"]: + lines = ["List of devices attached"] + for serial, tid in self.transport.items(): + lines.append(f"{serial} device product:p model:m device:d transport_id:{tid}") + out = "\n".join(lines) + "\n" + elif args[:2] == ["forward", "--list"]: + out = "".join(f"{s} tcp:{local} tcp:{r}\n" for s, local, r in self.forwards) + elif args[0] == "-s": + serial, rest = args[1], args[2:] + if rest[:3] == ["shell", "dumpsys", "package"]: + version = self.installed.get(serial) + out = ( + f"Package [{rest[3]}]\n versionCode={version} minSdk=24\n" + if version is not None + else f"Unable to find package: {rest[3]}\n" + ) + elif rest[:4] == ["shell", "settings", "get", "secure"]: + key = rest[4] + out = ( + (self.enabled_services.get(serial) or "null") + "\n" + if key == "enabled_accessibility_services" + else "1\n" + ) + elif rest[:4] == ["shell", "settings", "put", "secure"]: + if rest[4] == "enabled_accessibility_services": + if self.prune_writes > 0: + self.prune_writes -= 1 + self.enabled_services[serial] = "" + else: + before = self.enabled_services.get(serial, "") + self.enabled_services[serial] = rest[5] + # AccessibilityManager binds the service when it is (re)added. + if SERVICE_NAME in rest[5] and SERVICE_NAME not in before: + self.revived.add(serial) + elif rest[0] == "install": + if self.install_result == "Success": + self.installed[serial] = self.bundled_version + self.protocol[serial] = 2 + out = "Success\n" + else: + out, code = self.install_result, 1 + elif rest[0] == "uninstall": + self.installed[serial] = None + out = "Success\n" + elif rest[:2] == ["forward", "--no-rebind"]: + port = self.next_port + self.next_port += 1 + self.forwards.append((serial, port, int(rest[3].split(":")[1]))) + out = f"{port}\n" + elif rest[:2] == ["forward", "--remove"]: + port = int(rest[2].split(":")[1]) + self.forwards = [f for f in self.forwards if not (f[0] == serial and f[1] == port)] + elif rest[:2] == ["shell", "input"]: + pass + elif rest[:3] == ["shell", "am", "broadcast"]: + assert rest[3:8] == ["-n", hm.TOKEN_RECEIVER, "-a", hm.TOKEN_ACTION, "--es"] + self.tokens[serial] = rest[9] + out = "Broadcasting: Intent { act=com.artemis.helper.SET_TOKEN }\nBroadcast completed: result=0\n" + elif rest[:3] == ["shell", "am", "start"]: + self.settings_opened = getattr(self, "settings_opened", 0) + 1 + elif rest[:2] == ["shell", "ps"]: + out = "PID ARGS" + "".join(chr(10) + line for line in self.holders.get(serial, [])) + elif rest[:2] == ["shell", "kill"]: + pid = int(rest[2]) + self.killed.append((serial, pid)) + self.holders[serial] = [ + line for line in self.holders.get(serial, []) if not line.startswith(f"{pid} ") + ] + elif rest[:3] == ["shell", "am", "force-stop"]: + pass + else: + raise AssertionError(f"unexpected adb call: {args}") + else: + raise AssertionError(f"unexpected adb call: {args}") + return subprocess.CompletedProcess(args, code, out, "") + + bundled_version = 2 + + +class FakePing: + """Answers on host ports whose forward targets a device running the service.""" + + def __init__(self, adb: FakeAdb): + self.adb = adb + self.dead: set[str] = set() # serials whose service is down + self.count = 0 + + def __call__(self, port: int): + self.count += 1 + for serial, local, remote in self.adb.forwards: + if local == port and remote == DEVICE_PORT: + if serial in self.dead and serial not in self.adb.revived: + return None + if any( + not line.split(None, 1)[1].startswith("sh ") + for line in self.adb.holders.get(serial, []) + ): + return None # suppressed by UiAutomation + if self.adb.installed.get(serial) is None: + return None + if SERVICE_NAME not in (self.adb.enabled_services.get(serial) or ""): + return None + return { + "success": True, + "version_code": self.adb.installed[serial], + "version_name": "1.1.0", + "protocol_version": self.adb.protocol.get(serial, 2), + "auth_required": self.adb.protocol.get(serial, 2) >= 2, + "token_set": serial in self.adb.tokens, + } + return None + + +@pytest.fixture +def bundled(tmp_path: Path) -> BundledHelper: + apk = tmp_path / "helper.apk" + apk.write_bytes(b"apk") + return BundledHelper(apk_path=apk, version_code=2, version_name="1.1.0", sha256="x") + + +@pytest.fixture +def env(tmp_path: Path, monkeypatch, bundled): + monkeypatch.setattr(hm, "get_temp_dir", lambda _name: tmp_path / "mutex") + adb = FakeAdb() + ping = FakePing(adb) + auto = {"install": True} + manager = AccessibilityHelperManager( + run_adb=adb, + ping=ping, + bundled=bundled, + sleep=lambda _s: None, + token_path=tmp_path / "token" / "session.token", + auto_install=lambda: auto["install"], + ) + manager.auto_install_switch = auto # test handle + return adb, ping, manager + + +def _calls(adb: FakeAdb, prefix: list[str]) -> list[list[str]]: + return [c for c in adb.calls if c[: len(prefix)] == prefix] + + +def test_status_and_session_repr_do_not_expose_token(env): + _, _, manager = env + session = manager.attach(SERIAL) + status = manager.status(SERIAL) + assert session.token + assert "token" not in status["session"] + assert session.token not in str(status) + assert session.token not in repr(session) + + +def test_concurrent_managers_share_the_first_host_token(tmp_path, monkeypatch): + monkeypatch.setattr(hm, "get_temp_dir", lambda _name: tmp_path / "mutex") + token_path = tmp_path / "token" / "session.token" + managers = [AccessibilityHelperManager(token_path=token_path) for _ in range(8)] + with ThreadPoolExecutor(max_workers=8) as executor: + tokens = list(executor.map(lambda manager: manager.host_token(), managers)) + assert len(set(tokens)) == 1 + assert token_path.read_text() == tokens[0] + + +# --------------------------------------------------------------------------- # +# Provision +# --------------------------------------------------------------------------- # + + +def test_provision_installs_and_enables_when_missing(env): + adb, _, manager = env + result = manager.provision(SERIAL) + + assert result.ok and result.action == "installed" + assert result.installed_version == 2 and result.bundled_version == 2 + install = _calls(adb, ["-s", SERIAL, "install"]) + assert install == [["-s", SERIAL, "install", "-r", "-g", str(manager.bundled.apk_path)]] + assert adb.enabled_services[SERIAL] == SERVICE_NAME + + +def test_provision_upgrades_outdated_and_leaves_newer_alone(env): + adb, _, manager = env + adb.installed[SERIAL] = 1 + assert manager.provision(SERIAL).action == "upgraded" + assert adb.installed[SERIAL] == 2 + + adb.installed[SERIAL] = 5 + adb.calls.clear() + result = manager.provision(SERIAL) + assert result.action == "up_to_date" and result.installed_version == 5 + assert not _calls(adb, ["-s", SERIAL, "install"]) + + +def test_provision_up_to_date_is_cheap_and_keeps_other_services(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = "com.android.talkback/.TalkBackService" + result = manager.provision(SERIAL) + + assert result.ok and result.action == "up_to_date" + assert adb.enabled_services[SERIAL] == f"com.android.talkback/.TalkBackService:{SERVICE_NAME}" + assert not _calls(adb, ["-s", SERIAL, "install"]) + + +def test_provision_without_bundled_apk_fails_only_when_not_installed(env): + adb, ping, _ = env + manager = AccessibilityHelperManager( + run_adb=adb, ping=ping, bundled=None, sleep=lambda _s: None + ) + result = manager.provision(SERIAL) + assert not result.ok and result.action == "failed" and "no bundled APK" in result.error + + adb.installed[SERIAL] = 1 + result = manager.provision(SERIAL) + assert result.ok and result.action == "up_to_date" + + +def test_provision_reports_install_failure(env): + adb, _, manager = env + adb.install_result = "Failure [INSTALL_FAILED_VERSION_DOWNGRADE]" + result = manager.provision(SERIAL) + assert not result.ok and result.action == "failed" + assert "INSTALL_FAILED_VERSION_DOWNGRADE" in result.error + + +def test_provision_mutex_is_released_and_stale_lock_is_broken(env, tmp_path): + adb, _, manager = env + manager.provision(SERIAL) + mutex_dir = tmp_path / "mutex" + assert not list(mutex_dir.glob("*.mutex")) + + # A crashed process left its mutex behind long ago: provisioning proceeds. + import hashlib + import os + import time + + digest = hashlib.sha256(SERIAL.encode()).hexdigest()[:16] + stale = mutex_dir / f"{digest}.mutex" + stale.write_text("1") + old = time.time() - 1000 + os.utime(stale, (old, old)) + assert manager.provision(SERIAL).ok + assert not stale.exists() + + +# --------------------------------------------------------------------------- # +# Attach / detach +# --------------------------------------------------------------------------- # + + +def test_attach_provisions_forwards_and_records_transport(env): + adb, _, manager = env + session = manager.attach(SERIAL) + + assert session.local_port == 40000 and session.owns_forward + assert session.transport_id == "3" and session.version_code == 2 + assert adb.forwards == [(SERIAL, 40000, DEVICE_PORT)] + forward_calls = _calls(adb, ["-s", SERIAL, "forward"]) + assert forward_calls == [ + ["-s", SERIAL, "forward", "--no-rebind", "tcp:0", f"tcp:{DEVICE_PORT}"] + ] + + +def test_two_devices_get_distinct_host_ports(env): + adb, _, manager = env + first = manager.attach(SERIAL) + second = manager.attach(OTHER) + + assert first.local_port != second.local_port + assert {(s, local) for s, local, _ in adb.forwards} == {(SERIAL, 40000), (OTHER, 40001)} + assert manager.session(SERIAL) is first and manager.session(OTHER) is second + + +def test_attach_reuses_forward_created_by_another_process(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + adb.forwards.append((SERIAL, 39999, DEVICE_PORT)) + + session = manager.attach(SERIAL) + assert session.local_port == 39999 and not session.owns_forward + assert not _calls(adb, ["-s", SERIAL, "forward", "--no-rebind"]) + + manager.detach(SERIAL) + assert adb.forwards == [(SERIAL, 39999, DEVICE_PORT)] # not ours to remove + + +def test_attach_is_idempotent_while_session_is_healthy(env): + adb, ping, manager = env + first = manager.attach(SERIAL) + adb.calls.clear() + again = manager.attach(SERIAL) + assert again is first + assert not _calls(adb, ["-s", SERIAL, "install"]) + assert not _calls(adb, ["-s", SERIAL, "forward"]) + + +def test_lazy_attach_never_installs(env): + adb, _, manager = env + with pytest.raises(HelperUnavailable): + manager.attach(SERIAL, provision=False) + assert not _calls(adb, ["-s", SERIAL, "install"]) + assert not _calls(adb, ["-s", SERIAL, "shell", "settings", "put"]) + # The dead forward it created for the probe is cleaned up again. + assert adb.forwards == [] + assert manager.session(SERIAL) is None + + +def test_lazy_attach_connects_to_running_helper(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + session = manager.attach(SERIAL, provision=False) + assert session.local_port == 40000 + assert not _calls(adb, ["-s", SERIAL, "install"]) + + +def test_replug_changes_transport_and_rebuilds_tunnel(env): + adb, _, manager = env + first = manager.attach(SERIAL) + + # Unplug: adb drops the forward and assigns a new transport on replug. + adb.forwards.clear() + adb.transport[SERIAL] = "9" + adb.calls.clear() + + second = manager.attach(SERIAL) + assert second is not first + assert second.transport_id == "9" and second.local_port == 40001 + # The vanished forward is not "removed" (adb already dropped it). + assert not _calls(adb, ["-s", SERIAL, "forward", "--remove"]) + assert adb.forwards == [(SERIAL, 40001, DEVICE_PORT)] + + +def test_reattach_after_request_failure_rebuilds_without_install(env): + adb, ping, manager = env + first = manager.attach(SERIAL) + adb.calls.clear() + + session = manager.reattach(SERIAL) + assert session.local_port != first.local_port + assert _calls(adb, ["-s", SERIAL, "forward", "--remove"]) == [ + ["-s", SERIAL, "forward", "--remove", f"tcp:{first.local_port}"] + ] + assert not _calls(adb, ["-s", SERIAL, "install"]) + + +def test_reattach_revives_a_killed_service_without_installing(env): + adb, ping, manager = env + first = manager.attach(SERIAL) + ping.dead.add(SERIAL) # force-stopped: enabled in settings, but not running + adb.revived.discard(SERIAL) + adb.calls.clear() + + session = manager.reattach(SERIAL) + assert session.local_port != first.local_port + assert not _calls(adb, ["-s", SERIAL, "install"]) + puts = _calls( + adb, ["-s", SERIAL, "shell", "settings", "put", "secure", "enabled_accessibility_services"] + ) + assert [p[-1] for p in puts] == ["null", SERVICE_NAME] # removed, then re-added + + +def test_reattach_raises_when_revive_does_not_help(env): + adb, ping, manager = env + manager.attach(SERIAL) + adb.calls.clear() + ping.dead.add(SERIAL) + adb.revived.discard(SERIAL) + adb.installed[SERIAL] = None # package vanished: nothing to re-bind + with pytest.raises(HelperUnavailable): + manager.reattach(SERIAL) + assert manager.session(SERIAL) is None + assert not _calls(adb, ["-s", SERIAL, "install"]) + + +def test_lazy_first_attach_does_not_toggle_settings(env): + adb, ping, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + ping.dead.add(SERIAL) + adb.revived.discard(SERIAL) + with pytest.raises(HelperUnavailable): + manager.attach(SERIAL, provision=False) + assert not _calls(adb, ["-s", SERIAL, "shell", "settings", "put"]) + + +def test_detach_removes_only_owned_forward(env): + adb, _, manager = env + manager.attach(SERIAL) + manager.attach(OTHER) + manager.detach(SERIAL) + assert adb.forwards == [(OTHER, 40001, DEVICE_PORT)] + assert manager.session(SERIAL) is None and manager.session(OTHER) is not None + + +def test_status_is_read_only(env): + adb, _, manager = env + adb.installed[SERIAL] = 1 + status = manager.status(SERIAL) + assert status["installed"] and status["installed_version"] == 1 + assert status["bundled_version"] == 2 and status["outdated"] is True + assert status["enabled"] is False and status["reachable"] is False + assert status["transport_id"] == "3" + assert not _calls(adb, ["-s", SERIAL, "install"]) + assert not _calls(adb, ["-s", SERIAL, "forward"]) + + +def test_uninstall_disables_service_and_removes_package(env): + adb, _, manager = env + manager.attach(SERIAL) + assert manager.uninstall(SERIAL) + assert adb.installed[SERIAL] is None + assert SERVICE_NAME not in adb.enabled_services[SERIAL] + assert adb.forwards == [] + + +def test_transport_id_parsing_handles_missing_device(): + adb = FakeAdb() + manager = AccessibilityHelperManager(run_adb=adb, ping=lambda _p: None, bundled=None) + assert manager.transport_id("nope") is None + assert manager.transport_id(SERIAL) == "3" + + +def test_enable_retries_when_the_system_prunes_the_setting(env): + adb, _, manager = env + adb.prune_writes = 2 # AccessibilityManager drops the first two writes + result = manager.provision(SERIAL) + assert result.ok and result.enabled + puts = _calls( + adb, ["-s", SERIAL, "shell", "settings", "put", "secure", "enabled_accessibility_services"] + ) + assert len(puts) == 3 + assert adb.enabled_services[SERIAL] == SERVICE_NAME + + +def test_enable_gives_up_after_bounded_attempts(env): + adb, _, manager = env + adb.prune_writes = 99 + result = manager.provision(SERIAL) + assert not result.ok and result.action == "installed" + # The person gets the exact manual path and the settings screen is opened for them. + assert hm.MANUAL_ENABLE_PATH in result.error + assert adb.settings_opened == 1 + puts = _calls( + adb, ["-s", SERIAL, "shell", "settings", "put", "secure", "enabled_accessibility_services"] + ) + assert len(puts) == hm._ENABLE_ATTEMPTS + + +def test_provision_emits_install_and_upgrade_events_before_the_install(env): + adb, _, manager = env + events: list[tuple[str, dict]] = [] + + def on_event(name, details): + # Fired before adb install ran: nothing installed yet at this point. + events.append((name, details, adb.installed[SERIAL])) + + manager.provision(SERIAL, on_event=on_event) + assert [(e[0], e[2]) for e in events] == [("installing", None)] + assert events[0][1]["to_version"] == 2 and events[0][1]["serial"] == SERIAL + + events.clear() + adb.installed[SERIAL] = 1 + manager.provision(SERIAL, on_event=on_event) + assert [(e[0], e[1]["from_version"], e[2]) for e in events] == [("upgrading", 1, 1)] + + events.clear() + manager.provision(SERIAL, on_event=on_event) # up to date: silent + assert events == [] + + +def test_attach_forwards_events_to_provision(env): + adb, _, manager = env + seen = [] + manager.attach(SERIAL, on_event=lambda name, details: seen.append(name)) + assert seen == ["installing"] + + +def test_status_probes_with_a_temporary_forward_when_no_tunnel_exists(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + status = manager.status(SERIAL) + assert status["reachable"] is True and status["tunnel"] == "probe" + assert status["forward_port"] is None + assert adb.forwards == [] # the probe forward was removed again + assert _calls(adb, ["-s", SERIAL, "forward", "--remove"]) == [ + ["-s", SERIAL, "forward", "--remove", "tcp:40000"] + ] + + +def test_status_reports_session_and_shared_tunnels(env): + adb, _, manager = env + manager.attach(SERIAL) + assert manager.status(SERIAL)["tunnel"] == "session" + manager.detach(SERIAL) + adb.forwards.append((SERIAL, 39999, DEVICE_PORT)) + status = manager.status(SERIAL) + assert status["tunnel"] == "shared" and status["forward_port"] == 39999 + + +def test_status_does_not_probe_when_not_installed_or_disabled(env): + adb, _, manager = env + status = manager.status(SERIAL) + assert status["reachable"] is False and status["tunnel"] is None + assert not _calls(adb, ["-s", SERIAL, "forward"]) + + +def test_status_flags_a_helper_newer_than_the_bundle(env): + adb, _, manager = env + adb.installed[SERIAL] = 7 + status = manager.status(SERIAL) + assert status["newer_than_bundled"] is True and status["outdated"] is False + + +# --------------------------------------------------------------------------- # +# Session token, protocol gate, auto-install switch, per-device locking +# --------------------------------------------------------------------------- # + + +def test_attach_pushes_the_host_token_and_records_it(env, tmp_path): + adb, _, manager = env + session = manager.attach(SERIAL) + token = manager.host_token() + assert len(token) == 48 and session.token == token and session.protocol_version == 2 + assert adb.tokens[SERIAL] == token + assert (tmp_path / "token" / "session.token").read_text() == token + # A second manager on the same host (another process) presents the same token. + other = AccessibilityHelperManager( + run_adb=adb, + ping=lambda _p: None, + bundled=None, + token_path=tmp_path / "token" / "session.token", + ) + assert other.host_token() == token + + +def test_push_token_is_repeated_on_demand(env): + adb, _, manager = env + manager.attach(SERIAL) + adb.tokens.clear() + assert manager.push_token(SERIAL) is True + assert adb.tokens[SERIAL] == manager.host_token() + + +def test_old_protocol_is_reinstalled_when_the_caller_may_provision(env): + adb, _, manager = env + adb.installed[SERIAL] = 9 # newer versionCode than the bundle, but the old contract + adb.protocol[SERIAL] = 1 + session = manager.attach(SERIAL) + assert _calls(adb, ["-s", SERIAL, "install"]) + assert session.protocol_version == 2 and session.version_code == 2 + + +def test_old_protocol_blocks_a_lazy_attach_without_installing(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + adb.protocol[SERIAL] = 1 + with pytest.raises(HelperUnavailable, match="protocol 1"): + manager.attach(SERIAL, provision=False) + assert not _calls(adb, ["-s", SERIAL, "install"]) + assert adb.forwards == [] + + +def test_auto_install_off_never_installs(env): + adb, _, manager = env + manager.auto_install_switch["install"] = False + with pytest.raises(HelperUnavailable, match="artemis helper install"): + manager.attach(SERIAL) + assert not _calls(adb, ["-s", SERIAL, "install"]) + + # An outdated but present helper is used as it is. + adb.installed[SERIAL] = 1 + session = manager.attach(SERIAL) + assert session.version_code == 1 + assert not _calls(adb, ["-s", SERIAL, "install"]) + assert manager.status(SERIAL)["auto_install"] is False + + +def test_provisioning_one_device_does_not_block_another(env): + import threading + + _, _, manager = env + lock = manager._device_lock(SERIAL) + lock.acquire() # simulate a slow install in progress on SERIAL + try: + done = threading.Event() + result: dict = {} + + def attach_other(): + result["session"] = manager.attach(OTHER) + done.set() + + threading.Thread(target=attach_other, daemon=True).start() + assert done.wait(2.0), "attach(OTHER) waited on SERIAL's lock" + assert result["session"].serial == OTHER + finally: + lock.release() + + +def test_status_reports_protocol_and_token(env): + adb, _, manager = env + manager.attach(SERIAL) + status = manager.status(SERIAL) + assert status["protocol_version"] == 2 and status["protocol_supported"] is True + assert status["token_set"] is True and status["auto_install"] is True + + +# --------------------------------------------------------------------------- # +# UiAutomation holders (uiautomator2 / Appium) mute the helper +# --------------------------------------------------------------------------- # + +U2_LINES = [ + "24633 sh -c CLASSPATH=/data/local/tmp/u2.jar app_process / com.wetest.uia2.Main -p 9008", + "24635 app_process / com.wetest.uia2.Main -p 9008", +] + + +def test_attach_stops_a_running_uiautomator2_server_and_binds(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + adb.holders[SERIAL] = list(U2_LINES) + session = manager.attach(SERIAL) + assert session.local_port == 40000 + assert adb.killed == [(SERIAL, 24635)] # the app_process, not its sh wrapper + assert ["-s", SERIAL, "shell", "am", "force-stop", "com.github.uiautomator"] in adb.calls + # No re-bind toggle was needed once UiAutomation released the services + # (provisioning's idempotent accessibility_enabled=1 write is fine). + assert not _calls( + adb, ["-s", SERIAL, "shell", "settings", "put", "secure", "enabled_accessibility_services"] + ) + + +def test_lazy_attach_names_uiautomator2_instead_of_killing_it(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + adb.holders[SERIAL] = list(U2_LINES) + with pytest.raises(HelperUnavailable, match="suppressed by uiautomator2's device server"): + manager.attach(SERIAL, provision=False) + assert adb.killed == [] + + +def test_appium_server_is_never_killed(env): + adb, _, manager = env + adb.installed[SERIAL] = 2 + adb.enabled_services[SERIAL] = SERVICE_NAME + adb.holders[SERIAL] = ["31000 io.appium.uiautomator2.server.test"] + with pytest.raises(HelperUnavailable, match="Appium.*disableSuppressAccessibilityServices"): + manager.attach(SERIAL) + assert adb.killed == [] + assert not _calls( + adb, ["-s", SERIAL, "shell", "settings", "put", "secure", "enabled_accessibility_services"] + ) + + +def test_uiautomation_holders_classifies_processes(env): + adb, _, manager = env + adb.holders[SERIAL] = U2_LINES + ["31000 io.appium.uiautomator2.server.test", "7 sh"] + holders = manager.uiautomation_holders(SERIAL) + assert holders == {"uiautomator2": [24635], "appium": [31000]} diff --git a/tests/unit/test_hierarchy_backend_visibility.py b/tests/unit/test_hierarchy_backend_visibility.py new file mode 100644 index 0000000..f6b332d --- /dev/null +++ b/tests/unit/test_hierarchy_backend_visibility.py @@ -0,0 +1,385 @@ +"""The user is told which UI-hierarchy backend serves a run, everywhere they look. + +Covers the agent's announcement fan-out (startup progress, log trace, session +device_info, status.json), the provisioning progress stage, the run_outcome +environment block, the `mobile_manage_task` field, the `artemis helper` CLI +(`--all`, status rendering) and the `artemis doctor` helper row. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from artemis.clients.screen_client_factory import FallbackScreenClient +from artemis.runtime import trace_store +from artemis.runtime.helper_manager import HelperSession, ProvisionResult +from artemis.sdk.agent import Agent + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # + + +def _composite(helper_version: str = "1.1.2") -> FallbackScreenClient: + helper = MagicMock() + helper.session = HelperSession( + serial="dev", + local_port=41000, + transport_id="1", + version_code=4, + version_name=helper_version, + owns_forward=True, + ) + helper.get_hierarchy.return_value = "" + client = FallbackScreenClient( + "dev", helper=helper, uiautomator_factory=MagicMock(), state_probe=lambda _s: "device" + ) + return client + + +def _agent_with(client) -> Agent: + agent = object.__new__(Agent) + agent._ui_adb_client = client + return agent + + +def _engine(): + engine = MagicMock() + engine.current_session_id = "sess-1" + engine.get_relative_time = lambda ts: "T+00:05" + return engine + + +# --------------------------------------------------------------------------- # +# Agent announcement fan-out +# --------------------------------------------------------------------------- # + + +def test_announce_first_backend_reaches_every_sink(tmp_path, monkeypatch): + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + trace_store.init_trace("sess-1", "goal", "flash") + client = _composite() + client.get_hierarchy() # helper becomes active + agent = _agent_with(client) + engine = _engine() + context = SimpleNamespace(data_engine=engine) + + with patch("artemis.sdk.agent.publish_startup_progress") as progress: + agent._announce_hierarchy_backend(context, "sess-1", None, "helper", None) + + stage, message = progress.call_args.args[:2] + assert stage == "hierarchy_backend" + assert message == "UI hierarchy source: Artemis accessibility helper v1.1.2" + assert progress.call_args.kwargs["backend"] == "helper" + + trace_call = engine.record_trace.call_args.kwargs + assert trace_call["name"] == "hierarchy_backend" and trace_call["type"] == "log" + assert trace_call["payload"]["helper_version"] == "1.1.2" + assert trace_call["payload"]["level"] == "INFO" + + info = engine.update_session_device_info.call_args.kwargs + assert info["hierarchy_backend"] == "helper" + assert info["hierarchy_backend_note"].startswith("UI hierarchy source: the Artemis") + + status = trace_store.read_status("sess-1") + assert status["hierarchy_backend"] == "helper" + assert "accessibility helper v1.1.2" in status["hierarchy_backend_note"] + + +def test_announce_switch_is_a_warning_with_the_reason(tmp_path, monkeypatch): + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + client = _composite() + agent = _agent_with(client) + engine = _engine() + context = SimpleNamespace(data_engine=engine) + + with patch("artemis.sdk.agent.publish_startup_progress") as progress: + agent._announce_hierarchy_backend( + context, "sess-1", "helper", "uiautomator", "HelperUnavailable: tunnel gone" + ) + + stage, message = progress.call_args.args[:2] + assert stage == "hierarchy_backend_changed" + assert message == ( + "UI hierarchy source switched from Artemis accessibility helper to UIAutomator2 " + "because HelperUnavailable: tunnel gone" + ) + assert engine.record_trace.call_args.kwargs["payload"]["level"] == "WARNING" + # No status.json for this session: silently skipped, nothing raised. + assert trace_store.read_status("sess-1") is None + + +def test_announce_survives_sink_failures(monkeypatch, tmp_path): + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + client = _composite() + agent = _agent_with(client) + engine = _engine() + engine.record_trace.side_effect = RuntimeError("db closed") + with patch("artemis.sdk.agent.publish_startup_progress", side_effect=OSError("no ipc")): + agent._announce_hierarchy_backend( + SimpleNamespace(data_engine=engine), "sess-1", None, "uiautomator", None + ) + + +@pytest.mark.asyncio +async def test_connect_publishes_install_stage_and_registers_listener(): + client = _composite() + + def fake_connect(on_event=None): + on_event("installing", {"serial": "dev", "version_name": "1.1.2", "to_version": 4}) + client._set_active("helper") + + client.connect = fake_connect # type: ignore[method-assign] + agent = _agent_with(client) + engine = _engine() + context = SimpleNamespace(data_engine=engine) + + with ( + patch("artemis.sdk.agent.publish_startup_progress") as progress, + patch.object(trace_store, "update_trace_fields"), + ): + await agent._connect_screen_client(context, "sess-1") + stages = [c.args[0] for c in progress.call_args_list] + assert stages == ["uiautomator", "helper_install", "uiautomator_ready", "hierarchy_backend"] + install_msg = progress.call_args_list[1].args[1] + assert ( + "first time" in install_msg and "v1.1.2" in install_msg and "3 seconds" in install_msg + ) + + # A later fallback goes through the registered listener. + progress.reset_mock() + client._set_active("uiautomator", "RuntimeError: tunnel gone") + assert progress.call_args.args[0] == "hierarchy_backend_changed" + + +@pytest.mark.asyncio +async def test_connect_without_provisioning_client_takes_no_callback(): + plain = MagicMock(spec=["connect", "get_hierarchy"]) + plain.connect = MagicMock() # signature without on_event + agent = _agent_with(plain) + with ( + patch("artemis.sdk.agent.publish_startup_progress"), + patch.object(trace_store, "update_trace_fields"), + ): + await agent._connect_screen_client(SimpleNamespace(data_engine=None), "sess-1") + plain.connect.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_reused_agent_records_backend_changes_only_for_current_session(): + client = _composite() + agent = _agent_with(client) + context = SimpleNamespace(data_engine=None) + with ( + patch("artemis.sdk.agent.publish_startup_progress") as progress, + patch.object(trace_store, "update_trace_fields"), + ): + await agent._connect_screen_client(context, "sess-1") + client._set_active("uiautomator", "tunnel gone") + client.disconnect() + await agent._connect_screen_client(context, "sess-2") + assert len(client.backend_history) == 1 + progress.reset_mock() + client._set_active("uiautomator", "tunnel gone again") + assert progress.call_count == 1 + assert progress.call_args.kwargs["session_id"] == "sess-2" + + +# --------------------------------------------------------------------------- # +# trace_store / engine / task manager +# --------------------------------------------------------------------------- # + + +def test_update_trace_fields_merges_and_ignores_missing(tmp_path, monkeypatch): + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + assert trace_store.update_trace_fields("nope", hierarchy_backend="helper") is None + trace_store.init_trace("t1", "goal", "pro") + data = trace_store.update_trace_fields("t1", hierarchy_backend="helper", extra=1) + assert data["hierarchy_backend"] == "helper" and data["extra"] == 1 + assert data["task_desc"] == "goal" + + +def test_update_session_device_info_merges_fields(): + from artemis.data_engine.engine import DataEngine + + engine = object.__new__(DataEngine) + engine.current_session_id = "s" + session = SimpleNamespace(device_info={"device_id": "dev"}) + engine.storage = MagicMock() + engine.storage.get_session.return_value = session + engine.update_session_device_info(hierarchy_backend="helper", hierarchy_backend_note="n") + engine.storage.update_session.assert_called_once_with(session) + assert session.device_info == { + "device_id": "dev", + "hierarchy_backend": "helper", + "hierarchy_backend_note": "n", + } + + +def test_manage_task_status_exposes_hierarchy_backend(tmp_path, monkeypatch): + from mcp_server.tools import task_manager + + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + trace_store.init_trace("t9", "goal", "flash", device_serial="dev") + trace_store.update_trace_fields( + "t9", hierarchy_backend="uiautomator", hierarchy_backend_note="switched" + ) + with patch.object(task_manager, "_reconcile_status", lambda *a, **k: None, create=True): + result = task_manager.mobile_manage_task(action="status", trace_id="t9") + assert result["hierarchy_backend"] == "uiautomator" + assert result["hierarchy_backend_note"] == "switched" + + +# --------------------------------------------------------------------------- # +# run_outcome environment block +# --------------------------------------------------------------------------- # + + +def test_run_outcome_extra_carries_environment(tmp_path): + from artemis.graph.checkpoints import read_run_outcome, write_run_outcome + from artemis.clients.screen_client_factory import hierarchy_backend_summary + + client = _composite() + client.get_hierarchy() + client._set_active("uiautomator", "RuntimeError: gone") + outcome = MagicMock() + outcome.model_dump.return_value = {"task_status": "completed"} + write_run_outcome(tmp_path, outcome, {"environment": hierarchy_backend_summary(client)}) + data = read_run_outcome(tmp_path) + assert data["environment"]["backend"] == "uiautomator" + assert data["environment"]["helper_version"] == "1.1.2" + assert data["environment"]["switches"][0]["reason"] == "RuntimeError: gone" + + +# --------------------------------------------------------------------------- # +# CLI: artemis helper +# --------------------------------------------------------------------------- # + + +def _status(**overrides): + base = { + "package": "com.artemis.helper", + "installed": True, + "installed_version": 4, + "bundled_version": 4, + "bundled_apk_present": True, + "outdated": False, + "newer_than_bundled": False, + "enabled": True, + "forward_port": None, + "tunnel": "probe", + "reachable": True, + "reported_version": 4, + "transport_id": "1", + "session": None, + } + base.update(overrides) + return base + + +def test_helper_status_explains_probe_tunnel_and_newer_build(monkeypatch): + from artemis.interfaces.cli.commands import helper as helper_cli + + monkeypatch.setattr( + helper_cli.helper_manager, + "status", + lambda serial: _status(installed_version=9, newer_than_bundled=True), + ) + result = CliRunner().invoke(helper_cli.helper_app, ["status", "--serial", "dev"]) + assert result.exit_code == 0, result.output + assert "temporary forward" in result.output + assert "newer than the bundled v4" in result.output + + +def test_helper_install_all_targets_idle_devices_only(monkeypatch): + from artemis.interfaces.cli.commands import helper as helper_cli + + devices = [ + SimpleNamespace(serial="a", is_busy=False), + SimpleNamespace(serial="b", is_busy=True), + SimpleNamespace(serial="c", is_busy=False), + ] + monkeypatch.setattr(helper_cli.device_pool, "get_ready_devices", lambda: devices) + calls = [] + + def provision(serial, force=False, on_event=None): + calls.append(serial) + on_event("installing", {"version_name": "1.1.2"}) + return ProvisionResult( + ok=True, action="installed", installed_version=4, bundled_version=4, enabled=True + ) + + monkeypatch.setattr(helper_cli.helper_manager, "provision", provision) + result = CliRunner().invoke(helper_cli.helper_app, ["install", "--all"]) + assert result.exit_code == 0, result.output + assert calls == ["a", "c"] + assert "a: installing v1.1.2..." in result.output + + +def test_helper_install_prints_manual_path_on_enable_failure(monkeypatch): + from artemis.interfaces.cli.commands import helper as helper_cli + from artemis.runtime.helper_manager import MANUAL_ENABLE_PATH + + monkeypatch.setattr( + helper_cli.helper_manager, + "provision", + lambda serial, force=False, on_event=None: ProvisionResult( + ok=False, + action="installed", + installed_version=4, + bundled_version=4, + enabled=False, + error=f"This device rejected enabling the accessibility service from adb. {MANUAL_ENABLE_PATH}.", + ), + ) + result = CliRunner().invoke(helper_cli.helper_app, ["install", "--serial", "dev"]) + assert result.exit_code == 1 + assert "Settings > Accessibility" in result.output + + +# --------------------------------------------------------------------------- # +# CLI: artemis doctor row +# --------------------------------------------------------------------------- # + + +def _adb_probe(serials): + return SimpleNamespace( + id="android_adb", + metadata={"devices": [{"serial": s, "state": "device"} for s in serials]}, + ) + + +def test_doctor_row_suggests_preinstall_when_missing(monkeypatch): + from artemis.interfaces.cli.commands import doctor + + with patch( + "artemis.runtime.helper_manager.helper_manager.status", + return_value=_status(installed=False, reachable=False), + ): + row = doctor._helper_row([_adb_probe(["dev"])]) + assert row.status == "missing" and row.summary == "Not installed" + assert "artemis helper install --serial dev" in row.detail + assert "about 3 s" in row.detail + + +def test_doctor_row_ready_mentions_uninstall(monkeypatch): + from artemis.interfaces.cli.commands import doctor + + with patch("artemis.runtime.helper_manager.helper_manager.status", return_value=_status()): + row = doctor._helper_row([_adb_probe(["dev"])]) + assert row.status == "pass" and "v4 on dev" in row.summary + assert "artemis helper uninstall --serial dev" in row.detail + + +def test_doctor_row_absent_for_multiple_devices_or_uiautomator_backend(monkeypatch): + from artemis.interfaces.cli.commands import doctor + + assert doctor._helper_row([_adb_probe(["a", "b"])]) is None + monkeypatch.setenv("ARTEMIS_HIERARCHY_BACKEND", "uiautomator") + assert doctor._helper_row([_adb_probe(["a"])]) is None diff --git a/tests/unit/test_hierarchy_fidelity.py b/tests/unit/test_hierarchy_fidelity.py new file mode 100644 index 0000000..bda15ea --- /dev/null +++ b/tests/unit/test_hierarchy_fidelity.py @@ -0,0 +1,156 @@ +"""What the agent sees must be what is on screen, whichever backend dumped it. + +Covers the host-side half of the UIAutomator parity work: negative bounds no +longer lose an element's coordinates, hints and errors reach the element list +the model reads, an empty helper answer degrades to UIAutomator2 in ``auto`` +mode, and the parity comparison used by ``artemis helper parity`` flags the +regressions it exists for. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from artemis.clients.accessibility_client import AccessibilityClient, HelperEmptyHierarchy +from artemis.clients.screen_client_factory import FallbackScreenClient +from artemis.clients.ui_automator_client import UIAutomatorClient, _parse_hierarchy_xml_to_elements +from artemis.core.diagnostics.hierarchy_parity import compare_dumps +from artemis.utils import ui_filter +from artemis.utils.visualization import format_minimal_list_with_elements + + +def _node(**attrs: str) -> str: + base = { + "index": "0", + "text": "", + "resource-id": "", + "class": "android.widget.TextView", + "package": "app", + "content-desc": "", + "clickable": "false", + "enabled": "true", + "bounds": "[0,0][100,100]", + } + base.update(attrs) + return "" + + +def _xml(*nodes: str) -> str: + return '' + "".join(nodes) + "" + + +# --------------------------------------------------------------------------- # +# Negative bounds +# --------------------------------------------------------------------------- # + + +def test_negative_bounds_keep_their_coordinates_and_get_clipped(): + elements = _parse_hierarchy_xml_to_elements(_xml(_node(text="Row", bounds="[-20,-40][300,60]"))) + row = elements[1] + assert row["parsed_bounds"] == {"left": -20, "top": -40, "right": 300, "bottom": 60} + + assert ui_filter._parse_bounds("[-20,-40][300,60]") == { + "left": -20, + "top": -40, + "right": 300, + "bottom": 60, + } + filtered = ui_filter.filter_ui_hierarchy([row], screen_width=1080, screen_height=2400) + assert len(filtered) == 1 + assert filtered[0]["parsed_bounds"] == {"left": 0, "top": 0, "right": 300, "bottom": 60} + + +# --------------------------------------------------------------------------- # +# Hints and errors reach the model +# --------------------------------------------------------------------------- # + + +def test_minimal_list_shows_hint_for_empty_inputs_and_errors(): + elements = _parse_hierarchy_xml_to_elements( + _xml( + _node(text="", hint="Search settings", **{"class": "android.widget.EditText"}), + _node(text="abc", error="Password too short", bounds="[0,200][100,300]"), + _node(text="", bounds="[0,400][100,500]"), # nothing to say: stays out + ) + ) + text, items, labels = format_minimal_list_with_elements(elements[1:], 1000, 1000) + lines = text.splitlines() + assert lines[0] == "[1] Hint: 'Search settings' | Bounds: [0,0][100,100]" + assert lines[1] == "[2] Text: 'abc' | Bounds: [0,200][100,300] | Error: 'Password too short'" + assert len(lines) == 2 and labels == ["1", "2"] + assert items[0]["is_hint"] is True and items[0]["text"] == "Search settings" + assert items[1]["error"] == "Password too short" + + +# --------------------------------------------------------------------------- # +# Empty helper answer degrades instead of showing a blank screen +# --------------------------------------------------------------------------- # + + +def test_empty_helper_hierarchy_falls_back_to_uiautomator(): + helper = MagicMock(spec=AccessibilityClient) + u2 = MagicMock(spec=UIAutomatorClient) + helper.get_screen_data.side_effect = HelperEmptyHierarchy("no window") + u2.get_screen_data.return_value = "from-u2" + client = FallbackScreenClient( + "dev", helper=helper, uiautomator_factory=lambda: u2, state_probe=lambda _s: "device" + ) + assert client.get_screen_data() == "from-u2" + assert client.active_backend == "uiautomator" + assert "HelperEmptyHierarchy" in client.backend_history[-1]["reason"] + + +# --------------------------------------------------------------------------- # +# Parity comparison +# --------------------------------------------------------------------------- # + + +def test_parity_passes_when_both_backends_describe_the_same_screen(): + u2 = _xml( + _node(text="Wi-Fi", bounds="[0,100][1080,200]"), + _node(**{"content-desc": "Back", "bounds": "[0,0][120,120]"}), + ) + helper = _xml( + _node(text="Wi-Fi", bounds="[2,101][1078,199]"), + _node(**{"content-desc": "Back", "bounds": "[0,0][120,120]"}), + ) + report = compare_dumps(helper, u2, 1080, 2400) + assert report["ok"], report["problems"] + assert report["match"]["recall"] == 1.0 and report["match"]["precision"] == 1.0 + + +@pytest.mark.parametrize( + "helper_extra, expected", + [ + (_node(text="Hidden drawer item", bounds="[-1080,300][0,400]"), "negative bounds"), + (_node(text="Next page", bounds="[1080,300][2160,400]"), "outside the screen"), + ( + "".join( + _node(text=f"Ghost {i}", bounds=f"[0,{i * 50}][100,{i * 50 + 40}]") + for i in range(8) + ), + "not on screen", + ), + ], +) +def test_parity_flags_elements_uiautomator_would_not_show(helper_extra, expected): + u2 = _xml(_node(text="Wi-Fi", bounds="[0,100][1080,200]")) + helper = _xml(_node(text="Wi-Fi", bounds="[0,100][1080,200]"), helper_extra) + report = compare_dumps(helper, u2, 1080, 2400) + assert not report["ok"] + assert any(expected in problem for problem in report["problems"]), report["problems"] + + +def test_parity_flags_missing_elements(): + u2 = _xml( + _node(text="Wi-Fi", bounds="[0,100][1080,200]"), + _node(text="Bluetooth", bounds="[0,200][1080,300]"), + ) + helper = _xml(_node(text="Wi-Fi", bounds="[0,100][1080,200]")) + report = compare_dumps(helper, u2, 1080, 2400) + assert not report["ok"] + assert report["match"]["missing_in_helper"] == [ + "'Bluetooth' at {'left': 0, 'top': 200, 'right': 1080, 'bottom': 300}" + ]