feat(android): add AccessibilityService, ScreenTreeBuilder, permissions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:name=".DroidClawApp"
|
||||
android:allowBackup="true"
|
||||
@@ -23,6 +30,23 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
9
android/app/src/main/res/xml/accessibility_config.xml
Normal file
9
android/app/src/main/res/xml/accessibility_config.xml
Normal 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" />
|
||||
Reference in New Issue
Block a user