feat(android): add AccessibilityService, ScreenTreeBuilder, permissions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sanju Sivalingam
2026-02-17 17:43:42 +05:30
parent 78b605bc86
commit 0e8ff24e08
5 changed files with 242 additions and 0 deletions

View File

@@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name=".DroidClawApp" android:name=".DroidClawApp"
android:allowBackup="true" android:allowBackup="true"
@@ -23,6 +30,23 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".accessibility.DroidClawAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
<service
android:name=".connection.ConnectionService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,96 @@
package com.thisux.droidclaw.accessibility
import android.accessibilityservice.AccessibilityService
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.thisux.droidclaw.model.UIElement
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
class DroidClawAccessibilityService : AccessibilityService() {
companion object {
private const val TAG = "DroidClawA11y"
val isRunning = MutableStateFlow(false)
val lastScreenTree = MutableStateFlow<List<UIElement>>(emptyList())
var instance: DroidClawAccessibilityService? = null
}
override fun onServiceConnected() {
super.onServiceConnected()
Log.i(TAG, "Accessibility service connected")
instance = this
isRunning.value = true
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// We capture on-demand via getScreenTree(), not on every event
}
override fun onInterrupt() {
Log.w(TAG, "Accessibility service interrupted")
}
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "Accessibility service destroyed")
instance = null
isRunning.value = false
}
fun getScreenTree(): List<UIElement> {
val delays = longArrayOf(50, 100, 200)
for (delayMs in delays) {
val root = rootInActiveWindow
if (root != null) {
try {
val elements = ScreenTreeBuilder.capture(root)
lastScreenTree.value = elements
return elements
} finally {
root.recycle()
}
}
runBlocking { delay(delayMs) }
}
Log.w(TAG, "rootInActiveWindow null after retries")
return emptyList()
}
fun findNodeAt(x: Int, y: Int): AccessibilityNodeInfo? {
val root = rootInActiveWindow ?: return null
return findNodeAtRecursive(root, x, y)
}
private fun findNodeAtRecursive(
node: AccessibilityNodeInfo,
x: Int,
y: Int
): AccessibilityNodeInfo? {
val rect = android.graphics.Rect()
node.getBoundsInScreen(rect)
if (!rect.contains(x, y)) {
node.recycle()
return null
}
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val found = findNodeAtRecursive(child, x, y)
if (found != null) {
node.recycle()
return found
}
}
return if (node.isClickable || node.isLongClickable || node.isEditable) {
node
} else {
node.recycle()
null
}
}
}

View File

@@ -0,0 +1,95 @@
package com.thisux.droidclaw.accessibility
import android.graphics.Rect
import android.view.accessibility.AccessibilityNodeInfo
import com.thisux.droidclaw.model.UIElement
import java.security.MessageDigest
object ScreenTreeBuilder {
fun capture(rootNode: AccessibilityNodeInfo?): List<UIElement> {
if (rootNode == null) return emptyList()
val elements = mutableListOf<UIElement>()
walkTree(rootNode, elements, depth = 0, parentDesc = "")
return elements
}
private fun walkTree(
node: AccessibilityNodeInfo,
elements: MutableList<UIElement>,
depth: Int,
parentDesc: String
) {
try {
val rect = Rect()
node.getBoundsInScreen(rect)
val text = node.text?.toString() ?: ""
val contentDesc = node.contentDescription?.toString() ?: ""
val viewId = node.viewIdResourceName ?: ""
val className = node.className?.toString() ?: ""
val displayText = text.ifEmpty { contentDesc }
val isInteractive = node.isClickable || node.isLongClickable ||
node.isEditable || node.isScrollable || node.isFocusable
if (isInteractive || displayText.isNotEmpty()) {
val centerX = (rect.left + rect.right) / 2
val centerY = (rect.top + rect.bottom) / 2
val width = rect.width()
val height = rect.height()
val action = when {
node.isEditable -> "type"
node.isScrollable -> "scroll"
node.isLongClickable -> "longpress"
node.isClickable -> "tap"
else -> "read"
}
elements.add(
UIElement(
id = viewId,
text = displayText,
type = className.substringAfterLast("."),
bounds = "[${rect.left},${rect.top}][${rect.right},${rect.bottom}]",
center = listOf(centerX, centerY),
size = listOf(width, height),
clickable = node.isClickable,
editable = node.isEditable,
enabled = node.isEnabled,
checked = node.isChecked,
focused = node.isFocused,
selected = node.isSelected,
scrollable = node.isScrollable,
longClickable = node.isLongClickable,
password = node.isPassword,
hint = node.hintText?.toString() ?: "",
action = action,
parent = parentDesc,
depth = depth
)
)
}
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
try {
walkTree(child, elements, depth + 1, className)
} finally {
child.recycle()
}
}
} catch (_: Exception) {
// Node may have been recycled during traversal
}
}
fun computeScreenHash(elements: List<UIElement>): String {
val digest = MessageDigest.getInstance("MD5")
for (el in elements) {
digest.update("${el.id}|${el.text}|${el.center}".toByteArray())
}
return digest.digest().joinToString("") { "%02x".format(it) }.take(12)
}
}

View File

@@ -0,0 +1,18 @@
package com.thisux.droidclaw.connection
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Foreground service for maintaining the WebSocket connection to the DroidClaw server.
* Full implementation will be added in Task 9.
*/
class ConnectionService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_NOT_STICKY
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|flagReportViewIds|flagRequestEnhancedWebAccessibility"
android:canPerformGestures="true"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:settingsActivity="com.thisux.droidclaw.MainActivity" />