From bac31b44c13b2b0fd938c81c331c97f6124a4593 Mon Sep 17 00:00:00 2001 From: somew1nd Date: Thu, 10 Sep 2026 14:10:23 -0700 Subject: [PATCH] feat(accessibility): implement Artemis Accessibility Helper for robust UI hierarchy capture - Introduce ArtemisAccessibilityHelper APK (API 24 to 37+) as a conflict-free, high-performance replacement for UiAutomator: * Eliminates native UiAutomator waitForIdle() timeouts, reducing DOM dump latency from ~3s to ~15ms and maintaining resilience against infinite animations. * Implements a multi-tier fallback hierarchy discovery strategy (multi-window layer enumeration -> active window fallback -> input/accessibility focus backtracking) with adaptive progressive backoff for seamless page transition capture. * Resolves node drop defect caused by 32-bit hash collisions on recurring virtual view IDs in long lists and WebViews by utilizing bounded recursion (MAX_DEPTH=75, MAX_NODES=8000). * Strict W3C XML 1.0 character sanitization filtering invalid ASCII control codes and escaping attribute whitespace ( , , ) to preserve multiline text formatting. * Enriches semantic parsing with stateDescription (API 30+ Jetpack Compose), screenReaderFocusable, paneTitle, heading, error, and tooltip attributes. * Multi-window metadata propagation tagging each node with window-id, window-type, and window-layer. * Adds dual-path standalone build script (build_apk.sh) producing a clean 29KB APK without Gradle daemon overhead alongside standard Gradle Kotlin DSL. - Integrate AccessibilityClient into Artemis Python runtime with automatic bundled APK detection, self-healing installation, and port forwarding. - Add unit test suite covering AccessibilityClient endpoints and atomic snapshot parsing. --- artemis/clients/accessibility_client.py | 247 +++++++ .../artemis-accessibility-helper/.gitignore | 5 + .../ArtemisAccessibilityHelper.apk | Bin 0 -> 29185 bytes .../app/build.gradle.kts | 35 + .../app/src/main/AndroidManifest.xml | 37 + .../java/com/artemis/helper/A11yNode.java | 264 ++++++++ .../helper/ArtemisAccessibilityService.java | 172 +++++ .../com/artemis/helper/CommandServer.java | 302 +++++++++ .../java/com/artemis/helper/DisplayUtils.java | 111 +++ .../com/artemis/helper/GestureController.java | 174 +++++ .../com/artemis/helper/HierarchyDumper.java | 632 ++++++++++++++++++ .../java/com/artemis/helper/XmlUtils.java | 102 +++ .../app/src/main/res/values/strings.xml | 4 + .../res/xml/accessibility_service_config.xml | 10 + .../build.gradle.kts | 3 + .../artemis-accessibility-helper/build_apk.sh | 118 ++++ .../debug.keystore | Bin 0 -> 2666 bytes .../gradle.properties | 2 + .../settings.gradle.kts | 17 + .../unit/clients/test_accessibility_client.py | 88 +++ 20 files changed, 2323 insertions(+) create mode 100644 artemis/clients/accessibility_client.py create mode 100644 packages/artemis-accessibility-helper/.gitignore create mode 100644 packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk create mode 100644 packages/artemis-accessibility-helper/app/build.gradle.kts create mode 100644 packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml create mode 100644 packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml create mode 100644 packages/artemis-accessibility-helper/build.gradle.kts create mode 100755 packages/artemis-accessibility-helper/build_apk.sh create mode 100644 packages/artemis-accessibility-helper/debug.keystore create mode 100644 packages/artemis-accessibility-helper/gradle.properties create mode 100644 packages/artemis-accessibility-helper/settings.gradle.kts create mode 100644 tests/unit/clients/test_accessibility_client.py 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 0000000000000000000000000000000000000000..d8f9b63f985848e0bc174672380aba1a7091e399 GIT binary patch literal 29185 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 JnobW#{{bH@|Lgz& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1f40357537ab5f4dd457d2d75483e008c73baae GIT binary patch literal 2666 zcma)8XEYp&)}0wM7!1bf!9|D?WJYh%Yn0KVM2#3FN_5eJL3D8?dWjlFv}%c&Y=$kb!~1 zVEW}LXsooVRLbMm`IpC;h3jA}csM#^6rf%_g%3EY`H9}5Iii1Vt?Dpr5w9tzsWe>? z9=)+#l@$S%gadWVE9B zc1J%2-#HeEO&vj>WcoUB#4JF1Pd%V9_wyPRr<7@a4!HiXWQPmMSCoZ2a<0pUWbt>q zu1Xni(LXgkQMS;;o4~$8+aEN$FW()H!k zvMR{oX2GDXuU|vXZb{XYxwLBAE2_WCm%et`2n-SAFWVlIB-Mlg^!^Qt6Xf5OTQZDz zxG2SuT~C`g_;_~tzf%%6qJ^&ebR$F575c+JI_@>5Y`m5J`j0m}_BOsg5) z`m~EL+=&QK=TTc-AFiWwiQDQAC>)v@VJ|tAh@vjpni|YQ!Y@wUEHBV$#s|->wL*+(V{SJ{lo|M z+EU5d@PcXyql6hZo4yQL1?2C*R|j(EOxa&0Rti_j7hR{QC2vArqd+R+ZX8X_lT6f__3QaHJFAPlx-S?P&TjLrb!> zkYn*E)66xtv1S_bNj(EDz{JKll=NmBkbPrZk2>$x9(f9WgAhkf!)15D zYk$c4gv{8W!%SaRDh-h&>N}_GOp*Q7>=sD$GIh2a$3ODnWt6}KkQ5t`l5PXjk_i2j zml7JzbKqUxBq?24EwTE*YkiP3O!o-{1%8-lZme)vC-Uo%rucFF96LvC`p7 z&_ORq?I;tZ=A^(L2!oNHx4to?lgEG1BfiOn_{hX6WgDu-K%9-LVtQU(Khjk&}UWn7!L4R`xVs{ zhW5$?mqd#8`yafm7Tt+c$?XV#pG0B4iK$$tEDd|~FtY|V%*_AiI8~(sF291iB35R~ z&mf4ixRaqoOV`1cp@Jw^x3*)gFt2dl!{~Q*ZFmmtZJU286K3N`;A1Z8k+X=}?!wHoyw)qN~I`Gb^J zcwHn9d;0B&X0B3&fF#j@G@g1f%3?ewJcq+N_rl0u4MkCJMh{UsHA?>a17~r}TdBFW z^2AtIP$k^cr5G4borbNapv(<+l#vgcl`7~$&-WZ#L6juq-yPc3-){orkXK7Ox4 z4892(jt-8bufQEtl}XX^jHTr5j!fOU`T8<#BN?%$$B-Bj&KHtD3AP^Kh;P8F{<^hn z%!)K!*X21A>0%*4i&MB)Kgdi7X={AoB0}MleAndWoa&d7Q6n!C*0pS+t{KpJ1!@^d zNE}CdipS5gjxJ^Ni7RpC7v@)D-@y!l`Kg>aavSLUQJk(MJwvSmLq<*%IWi!|064c*thlG|F$FAVg+J*!SlNRe=?(9notV`2GbijSXpKTEXqU#b>bkS1T z@)E#&#YvDf)4Z8cPpdPZ&c~fl&FeX!&&H9Dve*s4&erWSgVb?RCB{GSJIt-0K{Xxx z>lQwUEU8{dXg}A|x{-tG2hl6UX-lDu;4vhj-`Mnu zB~Y{=XM5rn>P`0Zy+Y2Y*5rIVehqFEzk*J}kzq}v8zcu#U_j&--$ag0;^!cuE%Sh^`8fKkVfFifE zx!7O6NH>$qp@1`^jlk`y$F9zGqKm;uP#k4P3O5+je6Q&n@_X4LE9~0TP9T{ru~L uR%nernd6)dUvn#)?uw#z1~aZ5nV8uw%)5%df3Wy@w1;gb)|L2w5%Djzg2ko) literal 0 HcmV?d00001 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() +