
A11y Bridge
- 11 installs
- 6 repo stars
- Updated February 10, 2026
- 4ier/a11y-bridge
a11y-bridge is a Claude Code skill and Android Accessibility Service that lets an AI agent read and click any Android app's UI over an HTTP bridge on localhost:7333.
About
a11y-bridge lets an AI agent control an Android device through a small Accessibility Service that exposes the live UI tree over HTTP on localhost:7333. It reads a screen in about 50ms and clicks elements by text, resource id or content description instead of computing coordinates. A developer building an Android phone agent uses it to replace the slow screenshot, uiautomator dump and coordinate-tap cycle with fast semantic access over ADB.
- Reads the live Android UI tree over HTTP in ~50ms
- Clicks elements by text, id or content description (no coordinates)
- 16KB Accessibility Service exposing localhost:7333
A11y Bridge by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,469 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
a11y-bridge capabilities & compatibility
Free and open source (MIT); runs locally over ADB with no API keys.
- Capabilities
- android automation · ui automation · device control · phone agent
- Platforms
- macOS · Linux
- Pricing
- Free
What a11y-bridge says it does
Control Android devices via Accessibility Service HTTP bridge. 100x faster than screencap + uiautomator dump.
Control Android devices through a 16KB Accessibility Service that exposes the live UI tree over HTTP (`localhost:7333`).
npx skills add https://github.com/4ier/a11y-bridge --skill a11y-bridgeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 6 |
| Last updated | February 10, 2026 |
| Repository | 4ier/a11y-bridge ↗ |
What it does
Give an AI agent fast semantic read and click control of an Android device via an Accessibility Service HTTP bridge.
Who is it for?
Developers building AI phone agents that need fast semantic read and click control of Android apps.
Skip if: iOS automation or environments where you cannot install an Accessibility Service via ADB.
When should I use this skill?
Automating Android apps or controlling a phone via ADB and the screenshot-plus-coordinate cycle is too slow.
What you get
The agent reads the UI tree in ~50ms and clicks elements by text, id or description with no coordinate math.
- Live Android UI tree over HTTP
- Element clicks by text, id or description
By the numbers
- 16KB Accessibility Service
- Reads UI in ~50ms vs 3-5s for uiautomator dump
- HTTP API with 4 endpoints (/ping, /screen, /click, /tap)
Files
A11y Bridge — Android Accessibility HTTP Bridge
Control Android devices through a 16KB Accessibility Service that exposes the live UI tree over HTTP (localhost:7333). ~50ms to read any screen, click by text without coordinate math.
Prerequisites
- Android device connected via USB with USB debugging enabled
- ADB installed and accessible in PATH
- Android SDK (build-tools 34, platform android-34) — only needed to build from source
Setup
Install pre-built APK
Download the latest APK from Releases, then:
# Install
adb install openclaw-a11y.apk
# Enable accessibility service
adb shell settings put secure enabled_accessibility_services \
com.openclaw.a11y/.ClawAccessibilityService
adb shell settings put secure accessibility_enabled 1
# Forward port
adb forward tcp:7333 tcp:7333
# Verify
curl http://localhost:7333/pingBuild from source (optional)
chmod +x build.sh && ./build.shUsage
Read screen (~50ms)
# Full UI tree
curl http://localhost:7333/screen
# Compact mode (only interactive/text elements)
curl 'http://localhost:7333/screen?compact'Returns JSON with all UI elements: text, bounds, clickable, editable, etc.
Click by text
# Click element containing "Settings"
curl -X POST http://localhost:7333/click \
-H "Content-Type: application/json" \
-d '{"text": "Settings"}'
# Click by resource ID
curl -X POST http://localhost:7333/click -d '{"id": "com.app:id/send"}'
# Click by content description
curl -X POST http://localhost:7333/click -d '{"desc": "Navigate up"}'Tap coordinates
curl -X POST http://localhost:7333/tap -d '{"x": 540, "y": 960}'Health check
curl http://localhost:7333/pingWorkflow
1. Read: curl http://localhost:7333/screen → JSON with all UI elements 2. Find: Locate target element by text/role in the JSON response 3. Act: curl -X POST /click -d '{"text":"Send"}' → click by text, no coordinates 4. Repeat
Multi-device support
When multiple devices are connected, specify the target with -s <serial> for all ADB commands:
adb -s <serial> forward tcp:7333 tcp:7333API Reference
| Endpoint | Method | Description |
|---|---|---|
/ping | GET | Health check |
/screen | GET | Full UI tree as JSON. Add ?compact for interactive elements only |
/click | POST | Click by text, id, or desc (JSON body) |
/tap | POST | Tap coordinates x, y (JSON body) |
Performance
| uiautomator dump | A11y Bridge | |
|---|---|---|
| Read UI | 3-5 seconds | ~50ms |
| Click | Calculate bounds → input tap x y | {"text": "OK"} |
| Full cycle | 5-8 seconds | 100-200ms |
Fallback
If the A11y Bridge service is not running (check with /ping), fall back to traditional ADB commands:
adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml
adb shell input tap <x> <y>name: Build APK
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
api-level: 34
build-tools: 34.0.0
- name: Build APK
run: |
chmod +x build.sh
./build.sh
- name: Get APK info
id: apk
run: |
APK_PATH=$(find . -name "openclaw-a11y.apk" | head -1)
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
echo "path=$APK_PATH" >> $GITHUB_OUTPUT
echo "size=$APK_SIZE" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: ${{ steps.apk.outputs.path }}
generate_release_notes: true
body: |
## A11y Bridge ${{ github.ref_name }}
APK size: ${{ steps.apk.outputs.size }}
### Install
```bash
adb install openclaw-a11y.apk
adb shell settings put secure enabled_accessibility_services com.openclaw.a11y/.ClawAccessibilityService
adb shell settings put secure accessibility_enabled 1
adb forward tcp:7333 tcp:7333
```
### Use as OpenClaw Skill
```bash
npx skills add 4ier/a11y-bridge
```
# Build artifacts
build/
*.keystore
# APK (use releases instead)
*.apk
*.apk.idsig
# IDE
.idea/
*.iml
.vscode/
# OS
.DS_Store
Thumbs.db
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.openclaw.a11y"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:label="OpenClaw A11y"
android:icon="@android:drawable/ic_menu_compass"
android:directBootAware="true">
<service
android:name=".ClawAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false"
android:directBootAware="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
</application>
</manifest>
#!/bin/bash
# Build the OpenClaw A11y APK without Gradle
# Requires: Android SDK (ANDROID_HOME or ~/Android/Sdk), javac, keytool
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
BUILD_TOOLS="$SDK/build-tools/34.0.0"
PLATFORM="$SDK/platforms/android-34/android.jar"
OUT="$SCRIPT_DIR/build"
APK_OUT="$SCRIPT_DIR"
AAPT2="$BUILD_TOOLS/aapt2"
D8="$BUILD_TOOLS/d8"
APKSIGNER="$BUILD_TOOLS/apksigner"
echo "=== OpenClaw A11y APK Builder ==="
echo "SDK: $SDK"
# Verify tools exist
for tool in "$AAPT2" "$D8" "$APKSIGNER"; do
if [ ! -f "$tool" ]; then
echo "ERROR: Missing tool: $tool"
exit 1
fi
done
if [ ! -f "$PLATFORM" ]; then
echo "ERROR: Missing platform: $PLATFORM"
exit 1
fi
# Clean
rm -rf "$OUT"
mkdir -p "$OUT"/{compiled,gen,classes,dex}
mkdir -p "$APK_OUT"
echo "[1/6] Compiling resources..."
$AAPT2 compile --dir "$SCRIPT_DIR/res" -o "$OUT/compiled/"
echo "[2/6] Linking resources..."
$AAPT2 link \
--auto-add-overlay \
-I "$PLATFORM" \
--manifest "$SCRIPT_DIR/AndroidManifest.xml" \
--java "$OUT/gen" \
-o "$OUT/base.apk" \
"$OUT"/compiled/*.flat
echo "[3/6] Compiling Java..."
# Find all java source files
find "$SCRIPT_DIR/src" -name "*.java" > "$OUT/sources.txt"
# Add generated R.java
find "$OUT/gen" -name "*.java" >> "$OUT/sources.txt"
javac \
-source 11 -target 11 \
-classpath "$PLATFORM" \
-d "$OUT/classes" \
@"$OUT/sources.txt" \
2>&1
echo "[4/6] Converting to DEX..."
# Find all class files
find "$OUT/classes" -name "*.class" > "$OUT/classfiles.txt"
$D8 \
--lib "$PLATFORM" \
--output "$OUT/dex" \
--min-api 24 \
$(cat "$OUT/classfiles.txt")
echo "[5/6] Packaging APK..."
# Extract base apk, add dex, repack
cp "$OUT/base.apk" "$OUT/unsigned.apk"
cd "$OUT/dex"
zip -u "$OUT/unsigned.apk" classes.dex
cd "$SCRIPT_DIR"
# Zipalign (optional, apksigner handles it)
if [ -f "$BUILD_TOOLS/zipalign" ]; then
"$BUILD_TOOLS/zipalign" -f 4 "$OUT/unsigned.apk" "$OUT/aligned.apk"
mv "$OUT/aligned.apk" "$OUT/unsigned.apk"
fi
echo "[6/6] Signing APK..."
KEYSTORE="$SCRIPT_DIR/debug.keystore"
if [ ! -f "$KEYSTORE" ]; then
keytool -genkey -v \
-keystore "$KEYSTORE" \
-alias debug \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass android -keypass android \
-dname "CN=OpenClaw,O=OpenClaw,L=Unknown,ST=Unknown,C=US"
fi
$APKSIGNER sign \
--ks "$KEYSTORE" \
--ks-key-alias debug \
--ks-pass pass:android \
--key-pass pass:android \
--out "$APK_OUT/openclaw-a11y.apk" \
"$OUT/unsigned.apk"
# Show result
APK_SIZE=$(du -h "$APK_OUT/openclaw-a11y.apk" | cut -f1)
echo ""
echo "✅ Build complete: $APK_OUT/openclaw-a11y.apk ($APK_SIZE)"
echo ""
echo "Install: adb install $APK_OUT/openclaw-a11y.apk"
echo "Enable: Settings → Accessibility → OpenClaw A11y → ON"
echo "Test: curl http://localhost:7333/ping (via adb forward)"
MIT License
Copyright (c) 2025 4ier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
A11y Bridge
Give your AI agent eyes and hands on Android — in 50ms, not 5 seconds.
A 16KB Android Accessibility Service that exposes the live UI tree over HTTP (localhost:7333), enabling AI agents to read and interact with any Android app instantly.
The Problem
The traditional way for AI agents to control Android:
screencap → pull screenshot → uiautomator dump → pull XML → parse → calculate coordinates → input tap x yEach cycle takes 3-5 seconds. Controlling a complex app with 15+ steps? That's a minute of slow-motion replays.
The Solution
A11y Bridge runs an Accessibility Service on the device that exposes a local HTTP API:
# Read the full UI tree (~50ms)
curl http://localhost:7333/screen
# Click by text — no coordinate math needed
curl -X POST http://localhost:7333/click -d '{"text":"Send"}'100x faster. Same information, zero file transfers.
How it Works
Android's Accessibility Service was designed for screen readers — it provides real-time access to the complete UI tree of any app. A11y Bridge wraps this in a lightweight HTTP server:
┌─────────────┐ HTTP ┌──────────────────┐
│ AI Agent │ ◄──────────► │ A11y Bridge APK │
│ (curl/SDK) │ localhost │ (16KB, on device) │
└─────────────┘ :7333 └────────┬─────────┘
│
AccessibilityService
│
┌────────▼─────────┐
│ Any Android App │
└────────────────────┘Quick Start
Download
Grab the latest APK from Releases.
Install
# Install
adb install openclaw-a11y.apk
# Enable the accessibility service
adb shell settings put secure enabled_accessibility_services \
com.openclaw.a11y/.ClawAccessibilityService
adb shell settings put secure accessibility_enabled 1
# Forward port
adb forward tcp:7333 tcp:7333
# Test
curl http://localhost:7333/ping
# → {"status":"ok","service":"openclaw-a11y"}Build from Source
Requires: Android SDK (build-tools 34, platform android-34), JDK 11+
chmod +x build.sh
./build.sh
# → ✅ Build complete: openclaw-a11y.apk (20K)API
GET /ping
Health check.
{"status": "ok", "service": "openclaw-a11y"}GET /screen
Returns the full UI tree as JSON.
curl http://localhost:7333/screen{
"package": "com.android.settings",
"timestamp": 1707500000000,
"nodes": [
{"text": "Settings", "bounds": "0,0,1080,2340", "click": true},
{"text": "Network & internet", "id": "android:id/title", "bounds": "0,200,1080,300", "click": true},
{"text": "Search settings", "bounds": "100,50,980,150", "click": true, "edit": true}
],
"count": 42
}Add ?compact to only return nodes with meaningful content (text, clickable, editable, etc).
POST /click
Click an element by text, resource ID, or content description.
# By visible text (case-insensitive partial match)
curl -X POST http://localhost:7333/click \
-d '{"text": "Settings"}'
# By resource ID
curl -X POST http://localhost:7333/click \
-d '{"id": "com.app:id/send_button"}'
# By content description
curl -X POST http://localhost:7333/click \
-d '{"desc": "Navigate up"}'Response:
{"clicked": true, "x": 540, "y": 960, "matchedText": "Settings"}Uses AccessibilityNodeInfo.performAction(ACTION_CLICK) first (most reliable), falls back to gesture-based tap if the action isn't supported.
POST /tap
Tap at exact coordinates (when you need pixel-level control).
curl -X POST http://localhost:7333/tap \
-d '{"x": 540, "y": 960}'Node Properties
Each node in the /screen response can include:
| Field | Type | Description |
|---|---|---|
text | string | Visible text |
desc | string | Content description (accessibility label) |
id | string | Resource ID (com.app:id/name) |
cls | string | View class name |
bounds | string | left,top,right,bottom screen coordinates |
click | bool | Element is clickable |
edit | bool | Element is editable (text input) |
scroll | bool | Element is scrollable |
checkable | bool | Element is a checkbox/switch |
checked | bool | Checkbox/switch is on |
focused | bool | Element has focus |
selected | bool | Element is selected |
depth | int | Tree depth (full mode only) |
Performance
| Operation | uiautomator dump | A11y Bridge |
|---|---|---|
| Read UI tree | 3-5 seconds | ~50ms |
| Click element | Calculate bounds → input tap | {"text": "OK"} |
| Full interaction cycle | 5-8 seconds | 100-200ms |
Compatibility
- Minimum SDK: Android 7.0 (API 24)
- Target SDK: Android 14 (API 34)
- Tested on: Pixel 4 XL (Android 13)
- APK size: ~16-20KB
- Works with: All apps that expose accessibility nodes (most apps do)
Security Note
- The HTTP server binds to
127.0.0.1only — not accessible from the network - Access requires
adb forward— only the connected computer can reach it - The Accessibility Service can read all UI content — treat it like root access to the UI layer
- Don't install on devices with sensitive data you don't control
Use with AI Agents
This was built for OpenClaw but works with any AI agent framework. The HTTP API is framework-agnostic:
import requests
def read_screen():
return requests.get("http://localhost:7333/screen").json()
def click(text):
return requests.post("http://localhost:7333/click", json={"text": text}).json()
# Example: Open Settings and tap Wi-Fi
click("Settings")
time.sleep(1)
screen = read_screen()
click("Network & internet")How the "Surgery" Happened
This project started as a blog post about giving an AI agent running on an Android phone a "corrective eye surgery" — replacing the slow screencap + uiautomator dump cycle with a real-time accessibility bridge.
The analogy: the old approach was like a nearsighted person taking off their glasses and squinting at the screen every few seconds. The new approach gives the agent clear, always-on vision.
License
MIT
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="service_description">OpenClaw accessibility bridge — exposes UI tree via local HTTP for AI-driven device control.</string>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews|flagRequestEnhancedWebAccessibility"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:notificationTimeout="100"
android:description="@string/service_description" />
package com.openclaw.a11y;
import android.accessibilityservice.AccessibilityService;
import android.accessibilityservice.GestureDescription;
import android.graphics.Path;
import android.graphics.Rect;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* OpenClaw Accessibility Bridge
*
* Exposes the live UI tree over HTTP (localhost:7333) so an AI agent
* can read and interact with the device in ~50ms instead of the 3-5s
* adb screencap + uiautomator dump cycle.
*
* Endpoints:
* GET /screen → full UI tree as JSON
* GET /screen?compact → compact text-only summary
* POST /click → click by text, id, or description
* body: {"text":"Send"} or {"id":"com.app:id/btn"} or {"desc":"Back"}
* POST /tap → tap by coordinates body: {"x":540,"y":960}
* GET /ping → health check
*/
public class ClawAccessibilityService extends AccessibilityService {
private static final String TAG = "ClawA11y";
private static final int PORT = 7333;
private ServerSocket serverSocket;
private Thread serverThread;
private volatile boolean running = false;
@Override
public void onServiceConnected() {
super.onServiceConnected();
Log.i(TAG, "Accessibility service connected, starting HTTP server on port " + PORT);
startServer();
}
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
// We don't need to react to individual events;
// we read the tree on-demand via getRootInActiveWindow()
}
@Override
public void onInterrupt() {
Log.w(TAG, "Accessibility service interrupted");
}
@Override
public void onDestroy() {
super.onDestroy();
running = false;
try {
if (serverSocket != null) serverSocket.close();
} catch (IOException ignored) {}
Log.i(TAG, "Accessibility service destroyed");
}
// ── HTTP Server ──────────────────────────────────────────────
private void startServer() {
running = true;
serverThread = new Thread(() -> {
try {
serverSocket = new ServerSocket(PORT);
Log.i(TAG, "HTTP server listening on :" + PORT);
while (running) {
try {
Socket client = serverSocket.accept();
handleClient(client);
} catch (IOException e) {
if (running) Log.e(TAG, "Accept error", e);
}
}
} catch (IOException e) {
Log.e(TAG, "Could not start server on port " + PORT, e);
}
}, "claw-http");
serverThread.setDaemon(true);
serverThread.start();
}
private void handleClient(Socket client) {
new Thread(() -> {
try (BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
OutputStream out = client.getOutputStream()) {
String requestLine = in.readLine();
if (requestLine == null) return;
// Read headers
int contentLength = 0;
String line;
while ((line = in.readLine()) != null && !line.isEmpty()) {
if (line.toLowerCase().startsWith("content-length:")) {
contentLength = Integer.parseInt(line.substring(15).trim());
}
}
// Read body if present
String body = "";
if (contentLength > 0) {
char[] buf = new char[contentLength];
int read = in.read(buf, 0, contentLength);
body = new String(buf, 0, read);
}
String[] parts = requestLine.split(" ");
String method = parts[0];
String path = parts.length > 1 ? parts[1] : "/";
String response;
int status = 200;
try {
if (path.equals("/ping")) {
response = "{\"status\":\"ok\",\"service\":\"openclaw-a11y\"}";
} else if (path.startsWith("/screen")) {
boolean compact = path.contains("compact");
response = getScreenJson(compact);
} else if (path.equals("/click") && method.equals("POST")) {
response = handleClick(body);
} else if (path.equals("/tap") && method.equals("POST")) {
response = handleTap(body);
} else {
status = 404;
response = "{\"error\":\"not found\",\"endpoints\":[\"/screen\",\"/click\",\"/tap\",\"/ping\"]}";
}
} catch (Exception e) {
status = 500;
response = "{\"error\":\"" + escapeJson(e.getMessage()) + "\"}";
}
String httpResponse = "HTTP/1.1 " + status + " OK\r\n" +
"Content-Type: application/json; charset=utf-8\r\n" +
"Access-Control-Allow-Origin: *\r\n" +
"Connection: close\r\n" +
"Content-Length: " + response.getBytes("UTF-8").length + "\r\n" +
"\r\n" + response;
out.write(httpResponse.getBytes("UTF-8"));
out.flush();
} catch (Exception e) {
Log.e(TAG, "Client handler error", e);
} finally {
try { client.close(); } catch (IOException ignored) {}
}
}, "claw-req").start();
}
// ── Screen Reading ───────────────────────────────────────────
private String getScreenJson(boolean compact) {
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root == null) {
return "{\"error\":\"no active window\",\"nodes\":[]}";
}
try {
JSONObject result = new JSONObject();
result.put("package", root.getPackageName());
result.put("timestamp", System.currentTimeMillis());
JSONArray nodes = new JSONArray();
traverseNode(root, nodes, 0, compact);
result.put("nodes", nodes);
result.put("count", nodes.length());
return result.toString();
} catch (Exception e) {
return "{\"error\":\"" + escapeJson(e.getMessage()) + "\"}";
} finally {
root.recycle();
}
}
private void traverseNode(AccessibilityNodeInfo node, JSONArray nodes, int depth, boolean compact) {
if (node == null) return;
try {
String text = node.getText() != null ? node.getText().toString() : "";
String desc = node.getContentDescription() != null ? node.getContentDescription().toString() : "";
String id = node.getViewIdResourceName() != null ? node.getViewIdResourceName() : "";
String cls = node.getClassName() != null ? node.getClassName().toString() : "";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
boolean hasContent = !text.isEmpty() || !desc.isEmpty() || node.isClickable()
|| node.isEditable() || node.isScrollable() || node.isCheckable();
if (!compact || hasContent) {
JSONObject obj = new JSONObject();
if (!text.isEmpty()) obj.put("text", text);
if (!desc.isEmpty()) obj.put("desc", desc);
if (!id.isEmpty()) obj.put("id", id);
if (!compact) obj.put("cls", cls);
obj.put("bounds", bounds.left + "," + bounds.top + "," + bounds.right + "," + bounds.bottom);
if (node.isClickable()) obj.put("click", true);
if (node.isEditable()) obj.put("edit", true);
if (node.isScrollable()) obj.put("scroll", true);
if (node.isCheckable()) obj.put("checkable", true);
if (node.isChecked()) obj.put("checked", true);
if (node.isFocused()) obj.put("focused", true);
if (node.isSelected()) obj.put("selected", true);
if (!compact) obj.put("depth", depth);
nodes.put(obj);
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
traverseNode(child, nodes, depth + 1, compact);
child.recycle();
}
}
} catch (Exception e) {
// Skip problematic nodes
}
}
// ── Click Handling ───────────────────────────────────────────
private String handleClick(String body) throws Exception {
JSONObject req = new JSONObject(body);
String targetText = req.optString("text", "");
String targetId = req.optString("id", "");
String targetDesc = req.optString("desc", "");
if (targetText.isEmpty() && targetId.isEmpty() && targetDesc.isEmpty()) {
return "{\"error\":\"provide 'text', 'id', or 'desc'\"}";
}
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root == null) return "{\"error\":\"no active window\"}";
try {
AccessibilityNodeInfo target = findNode(root, targetText, targetId, targetDesc);
if (target == null) {
return "{\"error\":\"element not found\",\"text\":\"" + escapeJson(targetText) +
"\",\"id\":\"" + escapeJson(targetId) +
"\",\"desc\":\"" + escapeJson(targetDesc) + "\"}";
}
Rect bounds = new Rect();
target.getBoundsInScreen(bounds);
int x = bounds.centerX();
int y = bounds.centerY();
// Try AccessibilityNodeInfo.performAction first (more reliable)
boolean clicked = target.performAction(AccessibilityNodeInfo.ACTION_CLICK);
if (!clicked) {
// Fall back to gesture-based tap
clicked = performTapGesture(x, y);
}
target.recycle();
JSONObject result = new JSONObject();
result.put("clicked", clicked);
result.put("x", x);
result.put("y", y);
if (!targetText.isEmpty()) result.put("matchedText", targetText);
if (!targetId.isEmpty()) result.put("matchedId", targetId);
if (!targetDesc.isEmpty()) result.put("matchedDesc", targetDesc);
return result.toString();
} finally {
root.recycle();
}
}
private String handleTap(String body) throws Exception {
JSONObject req = new JSONObject(body);
int x = req.getInt("x");
int y = req.getInt("y");
boolean tapped = performTapGesture(x, y);
return "{\"tapped\":" + tapped + ",\"x\":" + x + ",\"y\":" + y + "}";
}
private boolean performTapGesture(int x, int y) {
Path path = new Path();
path.moveTo(x, y);
GestureDescription.Builder builder = new GestureDescription.Builder();
builder.addStroke(new GestureDescription.StrokeDescription(path, 0, 50));
return dispatchGesture(builder.build(), null, null);
}
private AccessibilityNodeInfo findNode(AccessibilityNodeInfo root, String text, String id, String desc) {
if (root == null) return null;
// Check current node
if (matches(root, text, id, desc)) return root;
// Recurse children
for (int i = 0; i < root.getChildCount(); i++) {
AccessibilityNodeInfo child = root.getChild(i);
if (child != null) {
AccessibilityNodeInfo found = findNode(child, text, id, desc);
if (found != null) {
if (found != child) child.recycle();
return found;
}
child.recycle();
}
}
return null;
}
private boolean matches(AccessibilityNodeInfo node, String text, String id, String desc) {
if (!text.isEmpty()) {
String nodeText = node.getText() != null ? node.getText().toString() : "";
if (nodeText.toLowerCase().contains(text.toLowerCase())) return true;
}
if (!id.isEmpty()) {
String nodeId = node.getViewIdResourceName() != null ? node.getViewIdResourceName() : "";
if (nodeId.contains(id)) return true;
}
if (!desc.isEmpty()) {
String nodeDesc = node.getContentDescription() != null ? node.getContentDescription().toString() : "";
if (nodeDesc.toLowerCase().contains(desc.toLowerCase())) return true;
}
return false;
}
// ── Utilities ────────────────────────────────────────────────
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
}
}
Related skills
FAQ
How much faster is it than uiautomator dump?
The docs report reading UI in ~50ms versus 3 to 5 seconds for uiautomator dump, roughly 100x faster per full cycle.
How does it click without coordinates?
It uses AccessibilityNodeInfo.performAction(ACTION_CLICK) first and falls back to a gesture-based tap, matching elements by text, resource id or content description.