diff --git a/artemis/clients/accessibility_client.py b/artemis/clients/accessibility_client.py
new file mode 100644
index 0000000..6faa40d
--- /dev/null
+++ b/artemis/clients/accessibility_client.py
@@ -0,0 +1,247 @@
+# 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 Accessibility Helper client for Android device automation.
+
+Provides a lightweight, conflict-free alternative to UIAutomator2.
+Runs concurrently with Mobly, Appium, and Espresso without monopolizing
+the singleton UiAutomationConnection.
+"""
+
+from io import BytesIO
+import json
+import os
+import subprocess
+import time
+import urllib.error
+import urllib.request
+
+from PIL import Image
+
+from artemis.clients.ui_automator_client import UIAutomatorScreenData, _pil_to_base64
+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"
+DEFAULT_PORT = 18888
+
+
+class AccessibilityClient:
+ """Non-exclusive accessibility client for Artemis UI automation."""
+
+ def __init__(self, device_id: str, local_port: int = DEFAULT_PORT):
+ self._device_id = device_id
+ self._local_port = local_port
+ self._base_url = f"http://127.0.0.1:{local_port}"
+
+ 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
+
+ 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."
+ )
+
+ # 2. Ensure accessibility service is enabled via secure settings
+ self._enable_accessibility_service()
+
+ # 3. Setup port forward
+ self._setup_port_forward()
+
+ # 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)
+
+ logger.warning(f"Failed to connect to ArtemisAccessibilityHelper on port {self._local_port}")
+ return False
+
+ 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:
+ return False
+
+ 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"]),
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ timeout=10,
+ check=True,
+ )
+ return Image.open(BytesIO(result.stdout))
+ except Exception as e:
+ logger.error(f"Failed to capture screenshot via adb: {e}")
+ 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_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
+
+ 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:
+ 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),
+ )
+
+ # 2. Fallback: separate screenshot and hierarchy dump
+ 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,
+ 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})
+
+ 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 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
+
+ def clear_text(self) -> bool:
+ """Clear text of focused input."""
+ return self._send_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(["shell", "settings", "put", "secure", "enabled_accessibility_services", new_services])
+ 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,
+ )
diff --git a/packages/artemis-accessibility-helper/.gitignore b/packages/artemis-accessibility-helper/.gitignore
new file mode 100644
index 0000000..afc00f5
--- /dev/null
+++ b/packages/artemis-accessibility-helper/.gitignore
@@ -0,0 +1,5 @@
+build/
+*.idsig
+.gradle/
+*.class
+.DS_Store
diff --git a/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk b/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk
new file mode 100644
index 0000000..d8f9b63
Binary files /dev/null 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
new file mode 100644
index 0000000..eda8fd9
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/build.gradle.kts
@@ -0,0 +1,35 @@
+plugins {
+ id("com.android.application")
+}
+
+android {
+ namespace = "com.artemis.helper"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.artemis.helper"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ // Pure standard Android SDK APIs (android.accessibilityservice, android.view.accessibility, org.json)
+ // Zero external dependencies to maximize stability, guarantee compatibility, and keep APK under 30KB.
+}
diff --git a/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml b/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..650b6cf
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..9dc260b
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java
@@ -0,0 +1,264 @@
+package com.artemis.helper;
+
+import android.graphics.Rect;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * In-memory snapshot of an Accessibility UI element node.
+ * 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.
+ */
+public final class A11yNode {
+
+ public int index = 0;
+ public String text = "";
+ public String resourceId = "";
+ public String className = "";
+ public String packageName = "";
+ public String contentDesc = "";
+
+ public boolean checkable = false;
+ public boolean checked = false;
+ public boolean clickable = false;
+ public boolean enabled = true;
+ public boolean focusable = false;
+ public boolean focused = false;
+ public boolean scrollable = false;
+ public boolean longClickable = false;
+ public boolean password = false;
+ public boolean selected = false;
+
+ // Window metadata for multi-window awareness
+ public int windowId = -1;
+ public String windowType = "";
+ public int windowLayer = 0;
+ public boolean windowActive = false;
+ public boolean windowFocused = false;
+
+ // Advanced semantics for Agent perception & verification
+ public boolean editable = false;
+ public boolean isHeading = false;
+ public boolean screenReaderFocusable = false;
+ public String stateDescription = "";
+ public String errorText = "";
+ public String paneTitle = "";
+ public String tooltip = "";
+
+ public int left = 0;
+ public int top = 0;
+ public int right = 0;
+ public int bottom = 0;
+
+ public int drawingOrder = 0;
+ public String hint = "";
+
+ public final List children = new ArrayList<>(4);
+
+ public A11yNode() {}
+
+ public int getWidth() {
+ return right - left;
+ }
+
+ public int getHeight() {
+ return bottom - top;
+ }
+
+ public String getBoundsString() {
+ return "[" + left + "," + top + "][" + right + "," + bottom + "]";
+ }
+
+ /**
+ * Serializes this node and all of its descendants into standard UIAutomator XML.
+ */
+ public void writeXml(StringBuilder sb) {
+ sb.append("= 0) {
+ XmlUtils.appendIntAttribute(sb, "window-id", windowId);
+ if (!windowType.isEmpty()) {
+ XmlUtils.appendAttribute(sb, "window-type", windowType);
+ }
+ XmlUtils.appendIntAttribute(sb, "window-layer", windowLayer);
+ if (windowActive) {
+ XmlUtils.appendBooleanAttribute(sb, "window-active", true);
+ }
+ if (windowFocused) {
+ XmlUtils.appendBooleanAttribute(sb, "window-focused", true);
+ }
+ }
+
+ // Extended semantic attributes
+ if (editable) {
+ XmlUtils.appendBooleanAttribute(sb, "editable", true);
+ }
+ if (isHeading) {
+ XmlUtils.appendBooleanAttribute(sb, "heading", true);
+ }
+ if (screenReaderFocusable) {
+ XmlUtils.appendBooleanAttribute(sb, "screen-reader-focusable", true);
+ }
+ if (!stateDescription.isEmpty()) {
+ XmlUtils.appendAttribute(sb, "state-description", stateDescription);
+ }
+ if (!errorText.isEmpty()) {
+ XmlUtils.appendAttribute(sb, "error", errorText);
+ }
+ if (!paneTitle.isEmpty()) {
+ XmlUtils.appendAttribute(sb, "pane-title", paneTitle);
+ }
+ if (!tooltip.isEmpty()) {
+ XmlUtils.appendAttribute(sb, "tooltip", tooltip);
+ }
+
+ if (children.isEmpty()) {
+ sb.append(" />");
+ } else {
+ sb.append('>');
+ for (int i = 0; i < children.size(); i++) {
+ children.get(i).writeXml(sb);
+ }
+ sb.append("");
+ }
+ }
+
+ /**
+ * Serializes this node into a hierarchical JSON tree object.
+ */
+ public JSONObject toTreeJson() {
+ JSONObject obj = toBaseJson();
+ try {
+ if (!children.isEmpty()) {
+ JSONArray childrenArr = new JSONArray();
+ for (int i = 0; i < children.size(); i++) {
+ childrenArr.put(children.get(i).toTreeJson());
+ }
+ obj.put("children", childrenArr);
+ }
+ } catch (Throwable ignored) {}
+ return obj;
+ }
+
+ /**
+ * Serializes this node into an element JSON object for the flat list.
+ * Does NOT include child references.
+ */
+ public JSONObject toFlatElementJson() {
+ return toBaseJson();
+ }
+
+ private JSONObject toBaseJson() {
+ JSONObject obj = new JSONObject();
+ try {
+ obj.put("class", className);
+ obj.put("package", packageName);
+ obj.put("resource-id", resourceId);
+ obj.put("text", text);
+ obj.put("content-desc", contentDesc);
+ obj.put("bounds", getBoundsString());
+
+ JSONObject bounds = new JSONObject();
+ bounds.put("left", left);
+ bounds.put("top", top);
+ bounds.put("right", right);
+ bounds.put("bottom", bottom);
+ obj.put("parsed_bounds", bounds);
+
+ obj.put("clickable", clickable);
+ obj.put("scrollable", scrollable);
+ obj.put("checkable", checkable);
+ obj.put("checked", checked);
+ obj.put("enabled", enabled);
+ obj.put("focusable", focusable);
+ obj.put("focused", focused);
+ obj.put("selected", selected);
+ obj.put("password", password);
+ obj.put("long-clickable", longClickable);
+ obj.put("drawing-order", drawingOrder);
+ if (!hint.isEmpty()) {
+ obj.put("hint", hint);
+ }
+
+ // Window metadata
+ if (windowId >= 0) {
+ obj.put("window_id", windowId);
+ obj.put("window_type", windowType);
+ obj.put("window_layer", windowLayer);
+ if (windowActive) obj.put("window_active", true);
+ if (windowFocused) obj.put("window_focused", true);
+ }
+
+ // Extended semantics
+ if (editable) {
+ obj.put("editable", true);
+ }
+ if (isHeading) {
+ obj.put("is_heading", true);
+ }
+ if (screenReaderFocusable) {
+ obj.put("screen_reader_focusable", true);
+ }
+ if (!stateDescription.isEmpty()) {
+ obj.put("state_description", stateDescription);
+ }
+ if (!errorText.isEmpty()) {
+ obj.put("error", errorText);
+ }
+ if (!paneTitle.isEmpty()) {
+ obj.put("pane_title", paneTitle);
+ }
+ if (!tooltip.isEmpty()) {
+ obj.put("tooltip", tooltip);
+ }
+ } catch (Throwable ignored) {}
+ return obj;
+ }
+
+ /**
+ * Determines whether this node contains useful information or interactivity
+ * and should be included in the flat elements array for agent perception.
+ */
+ public boolean isInformativeOrInteractive() {
+ boolean hasContent = !text.isEmpty() || !contentDesc.isEmpty() || !resourceId.isEmpty()
+ || !stateDescription.isEmpty() || !errorText.isEmpty() || !paneTitle.isEmpty();
+ boolean isInteractive = clickable || scrollable || checkable || focusable || longClickable || editable;
+ return hasContent || isInteractive || isHeading;
+ }
+
+ /**
+ * Recursively collects all informative or interactive nodes into a flat list.
+ */
+ public void collectFlatElements(List flatList) {
+ if (isInformativeOrInteractive()) {
+ flatList.add(toFlatElementJson());
+ }
+ for (int i = 0; i < children.size(); i++) {
+ children.get(i).collectFlatElements(flatList);
+ }
+ }
+}
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
new file mode 100644
index 0000000..a2067b2
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java
@@ -0,0 +1,172 @@
+package com.artemis.helper;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.AccessibilityServiceInfo;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.util.Log;
+import android.view.accessibility.AccessibilityEvent;
+
+/**
+ * Artemis non-exclusive Accessibility Service with foreground keep-alive support.
+ *
+ * Runs concurrently with any Android UI testing framework (Mobly, Appium, Espresso)
+ * without monopolizing Android's singleton UiAutomationService connection.
+ */
+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";
+ private static final int NOTIFICATION_ID = 18888;
+
+ private static volatile ArtemisAccessibilityService instance;
+
+ private volatile String currentPackageName = "";
+ private volatile String currentActivityName = "";
+ private CommandServer server;
+
+ public static ArtemisAccessibilityService getInstance() {
+ return instance;
+ }
+
+ public String getCurrentPackageName() {
+ return currentPackageName;
+ }
+
+ public String getCurrentActivityName() {
+ return currentActivityName;
+ }
+
+ @Override
+ public void onServiceConnected() {
+ super.onServiceConnected();
+ instance = this;
+ Log.i(TAG, "ArtemisAccessibilityService connected");
+
+ // 1. Start foreground service to resist low-memory killer on aggressive custom ROMs
+ startForegroundNotification();
+
+ // 2. Dynamically enforce flags to guarantee compatibility across custom OEM ROMs
+ try {
+ AccessibilityServiceInfo info = getServiceInfo();
+ if (info == null) {
+ info = new AccessibilityServiceInfo();
+ }
+ info.eventTypes = AccessibilityEvent.TYPES_ALL_MASK;
+ info.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC;
+ info.notificationTimeout = 50;
+ info.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
+ | AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
+ | AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS;
+ setServiceInfo(info);
+ Log.i(TAG, "AccessibilityServiceInfo flags dynamically enforced");
+ } catch (Throwable t) {
+ Log.w(TAG, "Failed to dynamically configure AccessibilityServiceInfo", t);
+ }
+
+ // 3. Start local loopback command server
+ if (server != null) {
+ server.shutdown();
+ server = null;
+ }
+
+ server = new CommandServer(this, DEFAULT_PORT);
+ server.setDaemon(true);
+ server.start();
+ Log.i(TAG, "CommandServer started on port " + DEFAULT_PORT);
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ return START_STICKY;
+ }
+
+ @SuppressWarnings("deprecation")
+ private void startForegroundNotification() {
+ try {
+ NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) return;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ "Artemis Helper Service",
+ NotificationManager.IMPORTANCE_LOW
+ );
+ channel.setDescription("Keeps Artemis Accessibility Helper active for automation testing");
+ channel.setShowBadge(false);
+ nm.createNotificationChannel(channel);
+ }
+
+ Notification.Builder builder;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ builder = new Notification.Builder(this, CHANNEL_ID);
+ } else {
+ builder = new Notification.Builder(this);
+ }
+
+ builder.setContentTitle("Artemis Accessibility Helper")
+ .setContentText("Active on 127.0.0.1:" + DEFAULT_PORT)
+ .setSmallIcon(android.R.drawable.stat_notify_sync)
+ .setOngoing(true);
+
+ if (Build.VERSION.SDK_INT >= 34) {
+ startForeground(NOTIFICATION_ID, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, builder.build(), 0);
+ } else {
+ startForeground(NOTIFICATION_ID, builder.build());
+ }
+ Log.i(TAG, "Foreground keep-alive notification active");
+ } catch (Throwable t) {
+ Log.w(TAG, "Foreground notification start skipped or deferred: " + t.getMessage());
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private void stopForegroundNotification() {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE);
+ } else {
+ stopForeground(true);
+ }
+ } catch (Throwable ignored) {}
+ }
+
+ @Override
+ public void onAccessibilityEvent(AccessibilityEvent event) {
+ if (event == null) return;
+ if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
+ CharSequence pkg = event.getPackageName();
+ CharSequence cls = event.getClassName();
+ if (pkg != null) currentPackageName = pkg.toString();
+ if (cls != null) currentActivityName = cls.toString();
+ }
+ }
+
+ @Override
+ public void onInterrupt() {
+ Log.w(TAG, "ArtemisAccessibilityService interrupted");
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ Log.i(TAG, "ArtemisAccessibilityService destroyed");
+ stopForegroundNotification();
+ if (server != null) {
+ server.shutdown();
+ server = null;
+ }
+ if (instance == this) {
+ instance = null;
+ }
+ }
+}
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
new file mode 100644
index 0000000..032a303
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java
@@ -0,0 +1,302 @@
+package com.artemis.helper;
+
+import android.util.Log;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.Closeable;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+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
+ */
+public class CommandServer extends Thread {
+
+ private static final String TAG = "ArtemisCommandServer";
+ private final ArtemisAccessibilityService service;
+ private final int port;
+ private volatile boolean isRunning = true;
+ private ServerSocket serverSocket;
+ private final ExecutorService clientExecutor = Executors.newCachedThreadPool();
+
+ public CommandServer(ArtemisAccessibilityService service, int port) {
+ super("ArtemisCommandServer");
+ this.service = service;
+ this.port = port;
+ }
+
+ @Override
+ public void run() {
+ try {
+ serverSocket = new ServerSocket();
+ serverSocket.setReuseAddress(true);
+ serverSocket.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 50);
+ Log.i(TAG, "CommandServer listening on 127.0.0.1:" + port);
+
+ while (isRunning) {
+ final Socket socket;
+ try {
+ socket = serverSocket.accept();
+ } catch (Exception e) {
+ if (!isRunning) break;
+ continue;
+ }
+
+ clientExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ handleClient(socket);
+ }
+ });
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Server error", e);
+ } finally {
+ closeQuietly(serverSocket);
+ }
+ }
+
+ private void handleClient(Socket socket) {
+ try {
+ socket.setSoTimeout(10000);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
+ OutputStream output = socket.getOutputStream();
+
+ String firstLine = reader.readLine();
+ if (firstLine == null) return;
+ String trimmed = firstLine.trim();
+
+ if (trimmed.startsWith("GET ") || trimmed.startsWith("POST ")) {
+ handleHttp(trimmed, reader, output);
+ } else if (trimmed.startsWith("{")) {
+ handleJsonRpc(trimmed, output);
+ } else {
+ JSONObject err = new JSONObject();
+ err.put("success", false);
+ err.put("error", "Unsupported protocol");
+ sendJsonResponse(output, err.toString());
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Client handling error: " + e.getMessage());
+ } finally {
+ closeQuietly(socket);
+ }
+ }
+
+ private void handleHttp(String requestLine, BufferedReader reader, OutputStream output) {
+ try {
+ String[] parts = requestLine.split(" ");
+ String path = parts.length > 1 ? parts[1] : "/";
+
+ int contentLength = 0;
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isEmpty()) break;
+ String lower = line.toLowerCase();
+ if (lower.startsWith("content-length:")) {
+ try {
+ contentLength = Integer.parseInt(lower.substring("content-length:".length()).trim());
+ } catch (Exception ignored) {}
+ }
+ }
+
+ 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);
+ }
+
+ // 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")) {
+ JSONObject json = body.isEmpty() ? new JSONObject() : new JSONObject(body);
+ JSONObject resp = executeCommand(json.optString("cmd", ""), json);
+ sendJsonResponse(output, resp.toString());
+ } else {
+ JSONObject r = new JSONObject();
+ r.put("success", false);
+ r.put("error", "Unknown endpoint: " + path);
+ sendJsonResponse(output, r.toString());
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "HTTP error", e);
+ }
+ }
+
+ private void handleJsonRpc(String line, OutputStream output) {
+ try {
+ JSONObject json = new JSONObject(line);
+ String cmd = json.optString("cmd", "");
+ JSONObject 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());
+ output.write(err.toString().getBytes(StandardCharsets.UTF_8));
+ output.write('\n');
+ output.flush();
+ } catch (Exception ignored) {}
+ }
+ }
+
+ private JSONObject executeCommand(String cmd, JSONObject params) {
+ JSONObject resp = new JSONObject();
+ try {
+ switch (cmd.toLowerCase()) {
+ case "ping":
+ resp.put("success", true);
+ resp.put("service", "ArtemisAccessibilityService");
+ resp.put("package", service.getCurrentPackageName());
+ resp.put("activity", service.getCurrentActivityName());
+ break;
+ case "dump":
+ case "dump_ui":
+ return HierarchyDumper.dump(service);
+ case "snapshot":
+ return HierarchyDumper.dumpAtomicSnapshot(service);
+ case "dump_xml":
+ resp.put("success", true);
+ resp.put("xml", HierarchyDumper.dumpXml(service));
+ break;
+ case "tap":
+ float x = (float) params.optDouble("x", -1.0);
+ float y = (float) params.optDouble("y", -1.0);
+ long tapTimeout = params.optLong("timeout", 1500L);
+ if (x < 0 || y < 0) {
+ resp.put("success", false);
+ resp.put("error", "Invalid coordinates");
+ } else {
+ resp.put("success", GestureController.tap(service, x, y, tapTimeout));
+ }
+ break;
+ case "double_tap":
+ float dtx = (float) params.optDouble("x", -1.0);
+ float dty = (float) params.optDouble("y", -1.0);
+ long dtTimeout = params.optLong("timeout", 2000L);
+ if (dtx < 0 || dty < 0) {
+ resp.put("success", false);
+ resp.put("error", "Invalid coordinates");
+ } else {
+ resp.put("success", GestureController.doubleTap(service, dtx, dty, dtTimeout));
+ }
+ break;
+ case "long_press":
+ float lpx = (float) params.optDouble("x", -1.0);
+ float lpy = (float) params.optDouble("y", -1.0);
+ long lpDuration = params.optLong("duration", 1000L);
+ if (lpx < 0 || lpy < 0) {
+ resp.put("success", false);
+ resp.put("error", "Invalid coordinates");
+ } else {
+ resp.put("success", GestureController.longPress(service, lpx, lpy, lpDuration, 2500L));
+ }
+ break;
+ case "swipe":
+ float x1 = (float) params.optDouble("x1", -1.0);
+ float y1 = (float) params.optDouble("y1", -1.0);
+ float x2 = (float) params.optDouble("x2", -1.0);
+ float y2 = (float) params.optDouble("y2", -1.0);
+ long dur = params.optLong("duration", 300L);
+ resp.put("success", GestureController.swipe(service, x1, y1, x2, y2, dur, 3000L));
+ break;
+ case "type":
+ String text = params.optString("text", "");
+ resp.put("success", GestureController.setText(service, text));
+ break;
+ case "clear":
+ resp.put("success", GestureController.clearText(service));
+ break;
+ case "global":
+ String action = params.optString("action", "");
+ resp.put("success", GestureController.performGlobalAction(service, action));
+ break;
+ default:
+ resp.put("success", false);
+ resp.put("error", "Unknown command: " + cmd);
+ break;
+ }
+ } catch (Exception e) {
+ try {
+ resp.put("success", false);
+ resp.put("error", e.getMessage());
+ } catch (Exception ignored) {}
+ }
+ return resp;
+ }
+
+ private void sendJsonResponse(OutputStream output, String jsonStr) {
+ 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" +
+ "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 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) {}
+ }
+
+ public void shutdown() {
+ isRunning = false;
+ closeQuietly(serverSocket);
+ clientExecutor.shutdownNow();
+ }
+
+ private static void closeQuietly(Closeable c) {
+ if (c != null) {
+ try { c.close(); } catch (Throwable ignored) {}
+ }
+ }
+}
diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java
new file mode 100644
index 0000000..9c6731d
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java
@@ -0,0 +1,111 @@
+package com.artemis.helper;
+
+import android.accessibilityservice.AccessibilityService;
+import android.content.Context;
+import android.graphics.Point;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.Surface;
+import android.view.WindowManager;
+
+/**
+ * Universal display and orientation utilities compatible across all Android versions (API 24 - 37+).
+ */
+public final class DisplayUtils {
+
+ private DisplayUtils() {}
+
+ public static class DisplayInfo {
+ public final int rotation;
+ public final int width;
+ public final int height;
+
+ public DisplayInfo(int rotation, int width, int height) {
+ this.rotation = rotation;
+ this.width = width;
+ this.height = height;
+ }
+ }
+
+ /**
+ * Obtains the display orientation (0, 1, 2, 3) and screen dimensions (width, height)
+ * using the most reliable API for the current Android runtime.
+ */
+ @SuppressWarnings("deprecation")
+ public static DisplayInfo getDisplayInfo(AccessibilityService service) {
+ int rotation = 0;
+ int width = 1080;
+ int height = 2400;
+
+ // Baseline initialization from resources metrics (handles tablets, TVs, emulators dynamically)
+ try {
+ DisplayMetrics resDm = service.getResources().getDisplayMetrics();
+ if (resDm != null && resDm.widthPixels > 0 && resDm.heightPixels > 0) {
+ width = resDm.widthPixels;
+ height = resDm.heightPixels;
+ }
+ } catch (Throwable ignored) {}
+
+ try {
+ // Modern API 30+ window metrics for full physical screen dimensions
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ try {
+ WindowManager wm = (WindowManager) service.getSystemService(Context.WINDOW_SERVICE);
+ if (wm != null) {
+ android.graphics.Rect bounds = wm.getMaximumWindowMetrics().getBounds();
+ if (bounds.width() > 0 && bounds.height() > 0) {
+ width = bounds.width();
+ height = bounds.height();
+ }
+ }
+ } catch (Throwable ignored) {}
+ }
+
+ // Resolve Display instance for rotation and legacy metrics
+ Display display = null;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ try {
+ display = service.getDisplay();
+ } catch (Throwable ignored) {}
+ }
+
+ if (display == null) {
+ WindowManager wm = (WindowManager) service.getSystemService(Context.WINDOW_SERVICE);
+ if (wm != null) {
+ display = wm.getDefaultDisplay();
+ }
+ }
+
+ if (display != null) {
+ int r = display.getRotation();
+ switch (r) {
+ case Surface.ROTATION_0:
+ rotation = 0;
+ break;
+ case Surface.ROTATION_90:
+ rotation = 1;
+ break;
+ case Surface.ROTATION_180:
+ rotation = 2;
+ break;
+ case Surface.ROTATION_270:
+ rotation = 3;
+ break;
+ default:
+ rotation = 0;
+ break;
+ }
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ DisplayMetrics dm = new DisplayMetrics();
+ display.getRealMetrics(dm);
+ width = dm.widthPixels;
+ height = dm.heightPixels;
+ }
+ }
+ } catch (Throwable ignored) {}
+
+ return new DisplayInfo(rotation, width, height);
+ }
+}
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
new file mode 100644
index 0000000..3c29fd2
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java
@@ -0,0 +1,174 @@
+package com.artemis.helper;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.GestureDescription;
+import android.graphics.Path;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Synchronous gesture and action executor using native AccessibilityService APIs.
+ * Works without requiring UiAutomation or root privileges.
+ */
+public final class GestureController {
+
+ private static final String TAG = "ArtemisGestureCtrl";
+ private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
+
+ private GestureController() {}
+
+ public static boolean tap(AccessibilityService service, float x, float y, long timeoutMs) {
+ Path path = new Path();
+ path.moveTo(x, y);
+ GestureDescription.StrokeDescription stroke = new GestureDescription.StrokeDescription(path, 0L, 60L);
+ GestureDescription gesture = new GestureDescription.Builder().addStroke(stroke).build();
+ return dispatchSynchronous(service, gesture, timeoutMs);
+ }
+
+ public static boolean doubleTap(AccessibilityService service, float x, float y, long timeoutMs) {
+ if (!tap(service, x, y, timeoutMs)) return false;
+ try {
+ Thread.sleep(100L);
+ } catch (InterruptedException ignored) {}
+ return tap(service, x, y, timeoutMs);
+ }
+
+ public static boolean longPress(AccessibilityService service, float x, float y, long durationMs, long timeoutMs) {
+ Path path = new Path();
+ path.moveTo(x, y);
+ long pressDur = Math.max(500L, Math.min(durationMs, 5000L));
+ GestureDescription.StrokeDescription stroke = new GestureDescription.StrokeDescription(path, 0L, pressDur);
+ GestureDescription gesture = new GestureDescription.Builder().addStroke(stroke).build();
+ return dispatchSynchronous(service, gesture, timeoutMs + pressDur);
+ }
+
+ public static boolean swipe(
+ AccessibilityService service,
+ float x1, float y1, float x2, float y2,
+ long durationMs, long timeoutMs
+ ) {
+ Path path = new Path();
+ path.moveTo(x1, y1);
+ path.lineTo(x2, y2);
+ long safeDuration = Math.max(50L, Math.min(durationMs, 5000L));
+ GestureDescription.StrokeDescription stroke = new GestureDescription.StrokeDescription(path, 0L, safeDuration);
+ GestureDescription gesture = new GestureDescription.Builder().addStroke(stroke).build();
+ return dispatchSynchronous(service, gesture, timeoutMs + safeDuration);
+ }
+
+ public static boolean setText(AccessibilityService service, String text) {
+ AccessibilityNodeInfo inputNode = HierarchyDumper.findInputNode(service);
+ if (inputNode == null) {
+ Log.w(TAG, "No editable/focused input node found for setText");
+ return false;
+ }
+ try {
+ Bundle args = new Bundle();
+ args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text);
+ return inputNode.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
+ } catch (Throwable t) {
+ Log.w(TAG, "performAction ACTION_SET_TEXT failed", t);
+ return false;
+ } finally {
+ HierarchyDumper.safeRecycle(inputNode);
+ }
+ }
+
+ public static boolean clearText(AccessibilityService service) {
+ return setText(service, "");
+ }
+
+ public static boolean performGlobalAction(AccessibilityService service, String actionName) {
+ if (actionName == null) return false;
+ int actionId;
+ String lower = actionName.toLowerCase();
+ switch (lower) {
+ case "back":
+ actionId = AccessibilityService.GLOBAL_ACTION_BACK;
+ break;
+ case "home":
+ actionId = AccessibilityService.GLOBAL_ACTION_HOME;
+ break;
+ case "recents":
+ actionId = AccessibilityService.GLOBAL_ACTION_RECENTS;
+ break;
+ case "notifications":
+ actionId = AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS;
+ break;
+ case "quick_settings":
+ actionId = AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS;
+ break;
+ case "power_dialog":
+ actionId = AccessibilityService.GLOBAL_ACTION_POWER_DIALOG;
+ break;
+ case "toggle_split_screen":
+ actionId = AccessibilityService.GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN;
+ break;
+ case "lock_screen":
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ actionId = AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN;
+ } else {
+ return false;
+ }
+ break;
+ case "take_screenshot":
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ actionId = AccessibilityService.GLOBAL_ACTION_TAKE_SCREENSHOT;
+ } else {
+ return false;
+ }
+ break;
+ default:
+ return false;
+ }
+ return service.performGlobalAction(actionId);
+ }
+
+ private static boolean dispatchSynchronous(
+ final AccessibilityService service,
+ final GestureDescription gesture,
+ long timeoutMs
+ ) {
+ final CountDownLatch latch = new CountDownLatch(1);
+ final AtomicBoolean result = new AtomicBoolean(false);
+
+ MAIN_HANDLER.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ service.dispatchGesture(gesture, new AccessibilityService.GestureResultCallback() {
+ @Override
+ public void onCompleted(GestureDescription gestureDescription) {
+ result.set(true);
+ latch.countDown();
+ }
+
+ @Override
+ public void onCancelled(GestureDescription gestureDescription) {
+ result.set(false);
+ latch.countDown();
+ }
+ }, null);
+ } catch (Throwable t) {
+ Log.w(TAG, "dispatchGesture threw exception", t);
+ result.set(false);
+ latch.countDown();
+ }
+ }
+ });
+
+ try {
+ return latch.await(timeoutMs, TimeUnit.MILLISECONDS) && result.get();
+ } catch (InterruptedException e) {
+ return false;
+ }
+ }
+}
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
new file mode 100644
index 0000000..7b43e59
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java
@@ -0,0 +1,632 @@
+package com.artemis.helper;
+
+import android.accessibilityservice.AccessibilityService;
+import android.graphics.Bitmap;
+import android.graphics.ColorSpace;
+import android.graphics.Rect;
+import android.hardware.HardwareBuffer;
+import android.os.Build;
+import android.os.SystemClock;
+import android.util.Base64;
+import android.util.Log;
+import android.view.Display;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+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.AtomicReference;
+
+/**
+ * Universal, ultra-stable UI hierarchy dumper for Android (API 24 - 37+).
+ *
+ * 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.
+ */
+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();
+
+ private HierarchyDumper() {}
+
+ /**
+ * Dumps the complete hierarchy as a JSON response.
+ */
+ public static JSONObject dump(AccessibilityService service) {
+ long startTime = System.currentTimeMillis();
+ JSONObject result = new JSONObject();
+ JSONArray elementsJson = new JSONArray();
+
+ try {
+ DisplayUtils.DisplayInfo displayInfo = DisplayUtils.getDisplayInfo(service);
+ List rootSnapshots = captureRootSnapshots(service);
+
+ 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);
+ 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);
+ }
+ for (JSONObject elem : flatList) {
+ elementsJson.put(elem);
+ }
+
+ // 3. Build tree
+ JSONArray trees = new JSONArray();
+ for (A11yNode root : rootSnapshots) {
+ trees.put(root.toTreeJson());
+ }
+
+ 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);
+
+ if (service instanceof ArtemisAccessibilityService) {
+ ArtemisAccessibilityService s = (ArtemisAccessibilityService) service;
+ result.put("package", s.getCurrentPackageName());
+ result.put("activity", s.getCurrentActivityName());
+ }
+
+ } catch (Throwable t) {
+ Log.e(TAG, "Dump failed with exception", t);
+ try {
+ result.put("success", false);
+ result.put("error", "Dump failed: " + t.getMessage());
+ result.put("xml", "");
+ result.put("elements", elementsJson);
+ } 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.
+ */
+ public static JSONObject dumpAtomicSnapshot(AccessibilityService service) {
+ long startTime = System.currentTimeMillis();
+
+ // 1. Trigger hardware screenshot asynchronously
+ final AtomicReference bitmapRef = new AtomicReference<>(null);
+ 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();
+ }
+ } else {
+ screenshotLatch.countDown();
+ }
+
+ // 2. Concurrently capture the UI hierarchy
+ JSONObject dumpData = dump(service);
+
+ // 3. Wait for the screenshot with a strict 1.5s timeout
+ try {
+ screenshotLatch.await(1500L, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException ignored) {}
+
+ Bitmap bitmap = bitmapRef.get();
+ if (bitmap != null) {
+ try {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream(bitmap.getWidth() * bitmap.getHeight() / 4);
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
+ byte[] jpegBytes = baos.toByteArray();
+ String base64Str = Base64.encodeToString(jpegBytes, Base64.NO_WRAP);
+ dumpData.put("screenshot_base64", base64Str);
+ dumpData.put("has_screenshot", true);
+ } catch (Throwable t) {
+ Log.w(TAG, "Failed to compress screenshot to JPEG Base64", t);
+ try { dumpData.put("has_screenshot", false); } catch (Throwable ignored) {}
+ } finally {
+ bitmap.recycle();
+ }
+ } else {
+ try {
+ dumpData.put("has_screenshot", false);
+ dumpData.put("screenshot_error", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ ? "Screenshot capture timed out or failed"
+ : "takeScreenshot not supported on Android < 11");
+ } catch (Throwable ignored) {}
+ }
+
+ try {
+ dumpData.put("atomic_elapsed_ms", System.currentTimeMillis() - startTime);
+ } catch (Throwable ignored) {}
+
+ return dumpData;
+ }
+
+ /**
+ * Dumps the hierarchy directly as a raw standard UIAutomator XML string.
+ */
+ public static String dumpXml(AccessibilityService service) {
+ DisplayUtils.DisplayInfo displayInfo = DisplayUtils.getDisplayInfo(service);
+ List rootSnapshots = captureRootSnapshots(service);
+ if (rootSnapshots.isEmpty()) {
+ return "\n\n";
+ }
+ return buildXml(rootSnapshots, displayInfo.rotation, displayInfo.width, displayInfo.height);
+ }
+
+ /**
+ * Builds the standard Android UIAutomator XML string from root snapshots.
+ */
+ private static String buildXml(List roots, int rotation, int width, int height) {
+ StringBuilder sb = new StringBuilder(roots.size() * 1024 + 256);
+ sb.append("\n");
+ sb.append("");
+
+ for (int i = 0; i < roots.size(); i++) {
+ A11yNode root = roots.get(i);
+ root.index = i;
+ root.writeXml(sb);
+ }
+
+ sb.append("\n");
+ return sb.toString();
+ }
+
+ private static final class RawRootEntry {
+ final AccessibilityNodeInfo root;
+ final int windowId;
+ final String windowType;
+ final int windowLayer;
+ final boolean windowActive;
+ final boolean windowFocused;
+
+ RawRootEntry(
+ AccessibilityNodeInfo root,
+ int windowId,
+ String windowType,
+ int windowLayer,
+ boolean windowActive,
+ boolean windowFocused
+ ) {
+ this.root = root;
+ this.windowId = windowId;
+ this.windowType = windowType;
+ this.windowLayer = windowLayer;
+ this.windowActive = windowActive;
+ this.windowFocused = windowFocused;
+ }
+ }
+
+ /**
+ * 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) {
+ List rawRoots = Collections.emptyList();
+ long[] retryBackoff = new long[]{40L, 80L, 120L, 160L, 220L, 300L};
+
+ for (int attempt = 0; attempt <= retryBackoff.length; attempt++) {
+ rawRoots = getActiveRawRoots(service);
+ if (!rawRoots.isEmpty()) {
+ break;
+ }
+ if (attempt < retryBackoff.length) {
+ SystemClock.sleep(retryBackoff[attempt]);
+ }
+ }
+
+ 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);
+ if (snapshot != null) {
+ snapshot.windowId = entry.windowId;
+ snapshot.windowType = entry.windowType;
+ snapshot.windowLayer = entry.windowLayer;
+ snapshot.windowActive = entry.windowActive;
+ snapshot.windowFocused = entry.windowFocused;
+ snapshots.add(snapshot);
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "Failed to snapshot root window " + entry.windowId, t);
+ } finally {
+ safeRecycle(entry.root);
+ }
+ }
+
+ return snapshots;
+ }
+
+ /**
+ * Multi-tier hierarchy root discovery strategy:
+ * Tier 1: Multi-window enumeration (sorted by Z-layer descending).
+ * Tier 2: Active window direct fallback (if getWindows() is empty or missing foreground app).
+ * Tier 3: Focused input / accessibility node backtracking (walks up parent chain to top root).
+ */
+ private static List getActiveRawRoots(AccessibilityService service) {
+ List roots = new ArrayList<>();
+ Set seenHashes = new HashSet<>();
+ boolean hasAppWindow = false;
+
+ // Tier 1: Multi-window enumeration (App, Dialogs, Popups, Keyboards, Split-screen)
+ try {
+ List windows = service.getWindows();
+ if (windows != null && !windows.isEmpty()) {
+ List sortedWindows = new ArrayList<>(windows);
+ Collections.sort(sortedWindows, new Comparator() {
+ @Override
+ public int compare(AccessibilityWindowInfo w1, AccessibilityWindowInfo w2) {
+ return Integer.compare(w2.getLayer(), w1.getLayer());
+ }
+ });
+
+ for (AccessibilityWindowInfo window : sortedWindows) {
+ try {
+ AccessibilityNodeInfo root = window.getRoot();
+ if (root != null) {
+ int hash = root.hashCode();
+ if (seenHashes.add(hash)) {
+ String typeStr = resolveWindowType(window.getType());
+ if (window.getType() == AccessibilityWindowInfo.TYPE_APPLICATION) {
+ hasAppWindow = true;
+ }
+ roots.add(new RawRootEntry(
+ root,
+ window.getId(),
+ typeStr,
+ window.getLayer(),
+ window.isActive(),
+ window.isFocused()
+ ));
+ } else {
+ safeRecycle(root);
+ }
+ }
+ } catch (Throwable ignored) {}
+ }
+ }
+ } catch (Throwable ignored) {}
+
+ // Tier 2: Active window fallback (if getWindows() returned empty or lacked active app window)
+ if (!hasAppWindow) {
+ try {
+ AccessibilityNodeInfo activeRoot = service.getRootInActiveWindow();
+ if (activeRoot != null) {
+ int hash = activeRoot.hashCode();
+ if (seenHashes.add(hash)) {
+ roots.add(0, new RawRootEntry(
+ activeRoot,
+ activeRoot.getWindowId(),
+ "application",
+ 0,
+ true,
+ true
+ ));
+ hasAppWindow = true;
+ } else {
+ safeRecycle(activeRoot);
+ }
+ }
+ } catch (Throwable ignored) {}
+ }
+
+ // Tier 3: Focused node backtracking (recovers window tree during transient transitions)
+ if (roots.isEmpty()) {
+ AccessibilityNodeInfo focused = null;
+ try {
+ focused = service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT);
+ } catch (Throwable ignored) {}
+ if (focused == null) {
+ try {
+ focused = service.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY);
+ } catch (Throwable ignored) {}
+ }
+
+ if (focused != null) {
+ try {
+ AccessibilityNodeInfo current = focused;
+ AccessibilityNodeInfo parent = current.getParent();
+ while (parent != null) {
+ if (current != focused) {
+ safeRecycle(current);
+ }
+ current = parent;
+ parent = current.getParent();
+ }
+ int hash = current.hashCode();
+ if (seenHashes.add(hash)) {
+ roots.add(new RawRootEntry(
+ current,
+ current.getWindowId(),
+ "application",
+ 0,
+ true,
+ true
+ ));
+ } else {
+ safeRecycle(current);
+ }
+ } catch (Throwable ignored) {
+ } finally {
+ safeRecycle(focused);
+ }
+ }
+ }
+
+ return roots;
+ }
+
+ private static String resolveWindowType(int type) {
+ switch (type) {
+ case AccessibilityWindowInfo.TYPE_APPLICATION:
+ return "application";
+ case AccessibilityWindowInfo.TYPE_INPUT_METHOD:
+ return "input_method";
+ case AccessibilityWindowInfo.TYPE_SYSTEM:
+ return "system";
+ case AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY:
+ return "accessibility_overlay";
+ case AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER:
+ return "split_screen_divider";
+ default:
+ return "unknown";
+ }
+ }
+
+ /**
+ * Recursively snapshots an AccessibilityNodeInfo into an immutable A11yNode,
+ * extracting rich semantic properties (error, heading, editable, paneTitle, tooltip, stateDescription).
+ *
+ * Protected against recursion loops via MAX_DEPTH and MAX_NODES without flat hash collision drops.
+ */
+ @SuppressWarnings("deprecation")
+ private static A11yNode snapshotNode(
+ AccessibilityNodeInfo node,
+ int depth,
+ int childIndex,
+ int[] nodeCounter
+ ) {
+ if (node == null) return null;
+ if (depth > MAX_DEPTH || nodeCounter[0] >= MAX_NODES) {
+ return null;
+ }
+
+ nodeCounter[0]++;
+ A11yNode snapshot = new A11yNode();
+ snapshot.index = childIndex;
+
+ try {
+ Rect bounds = new Rect();
+ node.getBoundsInScreen(bounds);
+ snapshot.left = bounds.left;
+ snapshot.top = bounds.top;
+ snapshot.right = bounds.right;
+ snapshot.bottom = bounds.bottom;
+
+ CharSequence text = node.getText();
+ CharSequence desc = node.getContentDescription();
+ CharSequence pkg = node.getPackageName();
+ CharSequence cls = node.getClassName();
+ String resId = node.getViewIdResourceName();
+
+ snapshot.text = text != null ? text.toString() : "";
+ snapshot.contentDesc = desc != null ? desc.toString() : "";
+ snapshot.packageName = pkg != null ? pkg.toString() : "";
+ snapshot.className = cls != null ? cls.toString() : "";
+ snapshot.resourceId = resId != null ? resId : "";
+
+ snapshot.clickable = node.isClickable();
+ snapshot.checkable = node.isCheckable();
+ snapshot.checked = node.isChecked();
+ snapshot.enabled = node.isEnabled();
+ snapshot.focusable = node.isFocusable();
+ snapshot.focused = node.isFocused();
+ snapshot.scrollable = node.isScrollable();
+ snapshot.longClickable = node.isLongClickable();
+ snapshot.password = node.isPassword();
+ snapshot.selected = node.isSelected();
+
+ // 1. Editable property
+ snapshot.editable = node.isEditable();
+
+ // 2. Error message (crucial for form validation detection)
+ CharSequence err = node.getError();
+ if (err != null && err.length() > 0) {
+ snapshot.errorText = err.toString();
+ }
+
+ // 3. Drawing order (API 24+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ try {
+ snapshot.drawingOrder = node.getDrawingOrder();
+ } catch (Throwable ignored) {}
+ }
+
+ // 4. Hint text (API 26+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ try {
+ CharSequence hint = node.getHintText();
+ if (hint != null) snapshot.hint = hint.toString();
+ } catch (Throwable ignored) {}
+ }
+
+ // 5. Heading, PaneTitle, Tooltip, ScreenReaderFocusable (API 28+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ try {
+ snapshot.isHeading = node.isHeading();
+ } catch (Throwable ignored) {}
+
+ try {
+ snapshot.screenReaderFocusable = node.isScreenReaderFocusable();
+ } catch (Throwable ignored) {}
+
+ try {
+ CharSequence pt = node.getPaneTitle();
+ if (pt != null) snapshot.paneTitle = pt.toString();
+ } catch (Throwable ignored) {}
+
+ try {
+ CharSequence tt = node.getTooltipText();
+ if (tt != null) snapshot.tooltip = tt.toString();
+ } catch (Throwable ignored) {}
+ }
+
+ // 6. State Description (API 30+ Jetpack Compose semantics: expanded, collapsed, etc.)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ try {
+ CharSequence sd = node.getStateDescription();
+ if (sd != null) snapshot.stateDescription = sd.toString();
+ } catch (Throwable ignored) {}
+ }
+
+ // Recursively process children
+ int childCount = node.getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ AccessibilityNodeInfo childNode = null;
+ try {
+ childNode = node.getChild(i);
+ if (childNode != null) {
+ A11yNode childSnapshot = snapshotNode(childNode, depth + 1, i, nodeCounter);
+ if (childSnapshot != null) {
+ snapshot.children.add(childSnapshot);
+ }
+ }
+ } catch (Throwable ignored) {
+ } finally {
+ if (childNode != null) {
+ safeRecycle(childNode);
+ }
+ }
+ }
+
+ } catch (Throwable t) {
+ Log.w(TAG, "Error reading node properties", t);
+ }
+
+ return snapshot;
+ }
+
+ /**
+ * Safely recycles node info on Android API < 30 to prevent Binder pool exhaustion.
+ */
+ @SuppressWarnings("deprecation")
+ public static void safeRecycle(AccessibilityNodeInfo node) {
+ if (node == null) return;
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ try {
+ node.recycle();
+ } catch (Throwable ignored) {}
+ }
+ }
+
+ /**
+ * Finds the currently focused or editable input node for text entry.
+ */
+ 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) {}
+ }
+
+ 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;
+ try {
+ if (node.isEditable() && node.isFocusable() && node.isEnabled()) {
+ return node;
+ }
+ int count = node.getChildCount();
+ for (int i = 0; i < count; i++) {
+ AccessibilityNodeInfo child = node.getChild(i);
+ if (child != null) {
+ AccessibilityNodeInfo res = findFirstEditable(child);
+ if (res != null) return res;
+ safeRecycle(child);
+ }
+ }
+ } catch (Throwable ignored) {}
+ return null;
+ }
+}
diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java
new file mode 100644
index 0000000..4042254
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java
@@ -0,0 +1,102 @@
+package com.artemis.helper;
+
+/**
+ * High-performance XML serialization and character sanitization utilities.
+ * Ensures the output strictly conforms to the W3C XML 1.0 specification,
+ * preventing XML parsing errors (such as ET.ParseError in Python) caused by
+ * unprintable control characters, NUL bytes, or unescaped entities.
+ */
+public final class XmlUtils {
+
+ private XmlUtils() {}
+
+ /**
+ * Appends an escaped attribute to the StringBuilder: {@code name="value"}.
+ * If the value is null or empty, appends {@code name=""}.
+ */
+ public static void appendAttribute(StringBuilder sb, String name, CharSequence value) {
+ sb.append(' ').append(name).append("=\"");
+ if (value != null && value.length() > 0) {
+ escapeXmlAttr(sb, value);
+ }
+ sb.append('"');
+ }
+
+ /**
+ * Appends a boolean attribute: {@code name="true"} or {@code name="false"}.
+ */
+ public static void appendBooleanAttribute(StringBuilder sb, String name, boolean value) {
+ sb.append(' ').append(name).append(value ? "=\"true\"" : "=\"false\"");
+ }
+
+ /**
+ * Appends an integer attribute: {@code name="123"}.
+ */
+ public static void appendIntAttribute(StringBuilder sb, String name, int value) {
+ sb.append(' ').append(name).append("=\"").append(value).append('"');
+ }
+
+ /**
+ * Escapes and sanitizes an attribute value according to XML 1.0 rules.
+ * Drops illegal XML characters (e.g. ASCII control characters 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F)
+ * and escapes standard entities (&, <, >, ", ').
+ */
+ public static void escapeXmlAttr(StringBuilder sb, CharSequence text) {
+ final int len = text.length();
+ for (int i = 0; i < len; i++) {
+ char c = text.charAt(i);
+ switch (c) {
+ case '&':
+ sb.append("&");
+ break;
+ case '<':
+ sb.append("<");
+ break;
+ case '>':
+ sb.append(">");
+ break;
+ case '"':
+ sb.append(""");
+ break;
+ case '\'':
+ sb.append("'");
+ break;
+ case '\t':
+ sb.append(" ");
+ break;
+ case '\n':
+ sb.append("
");
+ break;
+ case '\r':
+ sb.append("
");
+ break;
+ default:
+ // XML 1.0 legal character range check:
+ // #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
+ if (c >= 0x20 && c <= 0xD7FF) {
+ sb.append(c);
+ } else if (c >= 0xE000 && c <= 0xFFFD) {
+ sb.append(c);
+ } else if (Character.isHighSurrogate(c)) {
+ if (i + 1 < len && Character.isLowSurrogate(text.charAt(i + 1))) {
+ sb.append(c).append(text.charAt(i + 1));
+ i++;
+ }
+ }
+ // Characters outside these ranges (e.g. \u0000 through \u0008, \u000B, \u000C, \u000E-\u001F,
+ // unpaired surrogates, \uFFFE, \uFFFF) are invalid XML 1.0 and silently omitted to guarantee well-formedness.
+ break;
+ }
+ }
+ }
+
+ /**
+ * Sanitizes a string for safe XML attribute inclusion, returning the escaped string.
+ */
+ public static String escapeXmlAttr(CharSequence text) {
+ if (text == null || text.length() == 0) return "";
+ StringBuilder sb = new StringBuilder(text.length() + 16);
+ escapeXmlAttr(sb, text);
+ return sb.toString();
+ }
+}
diff --git a/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml b/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..0d5195a
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+ Artemis Accessibility Helper
+ Lightweight non-exclusive accessibility helper for Artemis UI automation and testing.
+
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
new file mode 100644
index 0000000..359c504
--- /dev/null
+++ b/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/packages/artemis-accessibility-helper/build.gradle.kts b/packages/artemis-accessibility-helper/build.gradle.kts
new file mode 100644
index 0000000..c153e67
--- /dev/null
+++ b/packages/artemis-accessibility-helper/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ id("com.android.application") version "8.5.0" apply false
+}
diff --git a/packages/artemis-accessibility-helper/build_apk.sh b/packages/artemis-accessibility-helper/build_apk.sh
new file mode 100755
index 0000000..652ec80
--- /dev/null
+++ b/packages/artemis-accessibility-helper/build_apk.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+set -e
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$DIR"
+
+# 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="$candidate"
+ break
+ fi
+ done
+fi
+
+if [ ! -d "$SDK_ROOT" ]; then
+ echo "Error: Android SDK not found at $SDK_ROOT. Set ANDROID_HOME." >&2
+ exit 1
+fi
+
+BUILD_TOOLS="$(find "$SDK_ROOT/build-tools" -maxdepth 1 -mindepth 1 | sort -V | tail -n 1)"
+PLATFORM="$(find "$SDK_ROOT/platforms" -maxdepth 1 -mindepth 1 | sort -V | tail -n 1)"
+
+if [ -z "$BUILD_TOOLS" ] || [ ! -d "$BUILD_TOOLS" ]; then
+ echo "Error: No build-tools found under $SDK_ROOT/build-tools" >&2
+ exit 1
+fi
+
+if [ -z "$PLATFORM" ] || [ ! -f "$PLATFORM/android.jar" ]; then
+ echo "Error: android.jar not found under $SDK_ROOT/platforms" >&2
+ exit 1
+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
+fi
+if [ -n "$JAVA_HOME" ]; then
+ export PATH="$JAVA_HOME/bin:$PATH"
+fi
+
+if ! command -v javac >/dev/null 2>&1; then
+ echo "Error: javac not found in PATH or JAVA_HOME" >&2
+ exit 1
+fi
+
+echo "=================================================="
+echo " Building ArtemisAccessibilityHelper.apk"
+echo " Build Tools: $(basename "$BUILD_TOOLS")"
+echo " Platform: $(basename "$PLATFORM")"
+echo " Java: $(javac -version 2>&1)"
+echo "=================================================="
+
+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
+
+echo "-> Linking APK..."
+"$BUILD_TOOLS/aapt2" link \
+ -I "$PLATFORM/android.jar" \
+ --manifest app/src/main/AndroidManifest.xml \
+ --min-sdk-version 24 \
+ --target-sdk-version 35 \
+ --java build/gen \
+ -o build/apk/unaligned.apk \
+ build/res_compiled/res.zip
+
+echo "-> Compiling Java sources..."
+JAVA_FILES="$(find build/gen app/src/main/java -name "*.java")"
+javac -encoding UTF-8 \
+ -cp "$PLATFORM/android.jar" \
+ -d build/obj \
+ $JAVA_FILES
+
+echo "-> Dexing with D8..."
+CLASS_FILES="$(find build/obj -name "*.class")"
+"$BUILD_TOOLS/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"
+
+echo "-> Zipaligning..."
+"$BUILD_TOOLS/zipalign" -f -p 4 build/apk/unaligned.apk build/apk/aligned.apk
+
+echo "-> Signing APK..."
+if [ ! -f debug.keystore ]; then
+ echo "Creating persistent debug.keystore..."
+ keytool -genkey -v -keystore debug.keystore \
+ -storepass android -alias androiddebugkey -keypass android \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -dname "CN=Artemis Debug,O=Artemis,C=US"
+fi
+
+"$BUILD_TOOLS/apksigner" sign \
+ --ks debug.keystore \
+ --ks-pass pass:android \
+ --key-pass pass:android \
+ --ks-key-alias androiddebugkey \
+ --out ArtemisAccessibilityHelper.apk \
+ build/apk/aligned.apk
+
+echo "=================================================="
+echo " Build SUCCESSFUL!"
+echo " Artifact: $DIR/ArtemisAccessibilityHelper.apk"
+ls -lh ArtemisAccessibilityHelper.apk
+echo "=================================================="
diff --git a/packages/artemis-accessibility-helper/debug.keystore b/packages/artemis-accessibility-helper/debug.keystore
new file mode 100644
index 0000000..e1f4035
Binary files /dev/null and b/packages/artemis-accessibility-helper/debug.keystore differ
diff --git a/packages/artemis-accessibility-helper/gradle.properties b/packages/artemis-accessibility-helper/gradle.properties
new file mode 100644
index 0000000..3c5e113
--- /dev/null
+++ b/packages/artemis-accessibility-helper/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
diff --git a/packages/artemis-accessibility-helper/settings.gradle.kts b/packages/artemis-accessibility-helper/settings.gradle.kts
new file mode 100644
index 0000000..a68441d
--- /dev/null
+++ b/packages/artemis-accessibility-helper/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "artemis-accessibility-helper"
+include(":app")
diff --git a/tests/unit/clients/test_accessibility_client.py b/tests/unit/clients/test_accessibility_client.py
new file mode 100644
index 0000000..fa69ef2
--- /dev/null
+++ b/tests/unit/clients/test_accessibility_client.py
@@ -0,0 +1,88 @@
+import unittest
+from unittest.mock import MagicMock, patch
+from artemis.clients.accessibility_client import AccessibilityClient, DEFAULT_PORT
+
+
+class TestAccessibilityClient(unittest.TestCase):
+
+ def setUp(self):
+ self.client = AccessibilityClient(device_id="dummy_device", local_port=DEFAULT_PORT)
+
+ @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
+
+ self.assertTrue(self.client.ping())
+
+ @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
+
+ data = self.client.get_hierarchy()
+ self.assertTrue(data["success"])
+ self.assertEqual(data["elements"][0]["text"], "OK")
+
+ @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
+
+ @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
+
+ xml = self.client.get_hierarchy_xml()
+ self.assertIn("",
+ "elements": [{"text": "OK"}],
+ "width": 1080,
+ "height": 2400,
+ }
+ 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()
+