summaryrefslogtreecommitdiff
path: root/app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt')
-rw-r--r--app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt334
1 files changed, 334 insertions, 0 deletions
diff --git a/app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt b/app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt
new file mode 100644
index 0000000..5a1c248
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt
@@ -0,0 +1,334 @@
+package sh.lajo.buddy
+
+import android.accessibilityservice.AccessibilityService
+import android.accessibilityservice.AccessibilityServiceInfo
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+
+class BuddyAccessibilityService : AccessibilityService() {
+
+ companion object {
+ private const val TAG = "BuddyAccessibility"
+
+ // Package names for supported messaging apps
+ const val PACKAGE_WHATSAPP = "com.whatsapp"
+ const val PACKAGE_SIGNAL = "org.thoughtcrime.securesms"
+ const val PACKAGE_SIMPLEX = "chat.simplex.app"
+
+ // Circumvention events: package to class name pattern
+ val CIRCUMVENTION_EVENTS = mapOf(
+ "com.miui.securitycore" to "PrivateSpaceMainActivity"
+ )
+ }
+
+ // Track recently processed messages to avoid duplicates
+ private val recentMessages = LinkedHashMap<String, Long>(100, 0.75f, true)
+ private val MESSAGE_DEDUP_WINDOW_MS = 5000L // 5 seconds
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+
+ val info = AccessibilityServiceInfo().apply {
+ eventTypes =
+ AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED or
+ AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED or
+ AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or
+ AccessibilityEvent.TYPE_VIEW_SCROLLED
+
+ feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
+
+ packageNames = arrayOf(
+ PACKAGE_WHATSAPP,
+ PACKAGE_SIGNAL,
+ PACKAGE_SIMPLEX,
+ "com.miui.securitycore"
+ )
+
+ flags =
+ AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or
+ AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
+ AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
+
+ notificationTimeout = 100
+ }
+
+ serviceInfo = info
+ Log.d(TAG, "Accessibility service connected")
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) {
+ if (event == null) return
+
+ val packageName = event.packageName?.toString() ?: return
+ val className = event.className?.toString() ?: return
+
+ if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED &&
+ packageName == "com.miui.securitycore" &&
+ className.contains("PrivateSpaceMainActivity")) {
+ sendPrivateSpaceEvent()
+ return
+ }
+
+ // Only process supported apps
+ if (packageName !in getAppNameMap().keys) return
+
+ val rootNode = rootInActiveWindow ?: return
+
+ try {
+ when (packageName) {
+ PACKAGE_WHATSAPP -> processWhatsAppMessages(rootNode, packageName)
+ PACKAGE_SIGNAL -> processSignalMessages(rootNode, packageName)
+ PACKAGE_SIMPLEX -> processSimpleXMessages(rootNode, packageName)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error processing accessibility event: ${e.message ?: ""}")
+ } finally {
+ // Clean up old entries from dedup map
+ cleanupRecentMessages()
+ }
+ }
+
+ override fun onInterrupt() {
+ Log.d(TAG, "Accessibility service interrupted")
+ }
+
+ private fun getAppNameMap(): Map<String, Int> {
+ return mapOf(
+ PACKAGE_WHATSAPP to R.string.app_name_whatsapp,
+ PACKAGE_SIGNAL to R.string.app_name_signal,
+ PACKAGE_SIMPLEX to R.string.app_name_simplex
+ )
+ }
+
+ private fun processWhatsAppMessages(rootNode: AccessibilityNodeInfo, packageName: String) {
+ val messageNodes = mutableListOf<AccessibilityNodeInfo>()
+
+ findNodesByViewIdContains(rootNode, "message", messageNodes)
+ findNodesByViewIdContains(rootNode, "conversation", messageNodes)
+
+ val textNodes = mutableListOf<AccessibilityNodeInfo>()
+ collectTextNodes(rootNode, textNodes)
+
+ for (node in textNodes) {
+ val text = node.text?.toString() ?: continue
+ if (text.isBlank() || text.length < 2) continue
+
+ val sender = extractWhatsAppSender(node)
+
+ sendMessageEvent(packageName, sender, text)
+ }
+ }
+
+ private fun processSignalMessages(rootNode: AccessibilityNodeInfo, packageName: String) {
+ val textNodes = mutableListOf<AccessibilityNodeInfo>()
+ collectTextNodes(rootNode, textNodes)
+
+ for (node in textNodes) {
+ val text = node.text?.toString() ?: continue
+ if (text.isBlank() || text.length < 2) continue
+
+ val sender = extractSignalSender(node)
+
+ sendMessageEvent(packageName, sender, text)
+ }
+ }
+
+ private fun processSimpleXMessages(rootNode: AccessibilityNodeInfo, packageName: String) {
+ // SimpleX chat message extraction
+ val textNodes = mutableListOf<AccessibilityNodeInfo>()
+ collectTextNodes(rootNode, textNodes)
+
+ for (node in textNodes) {
+ val text = node.text?.toString() ?: continue
+ if (text.isBlank() || text.length < 2) continue
+
+ val sender = extractSimpleXSender(node)
+
+ sendMessageEvent(packageName, sender, text)
+ }
+ }
+
+ private fun extractWhatsAppSender(node: AccessibilityNodeInfo): String {
+ // Try to find sender name by traversing parent nodes
+ var parent = node.parent
+ var attempts = 0
+
+ while (parent != null && attempts < 10) {
+ for (i in 0 until parent.childCount) {
+ val sibling = parent.getChild(i) ?: continue
+ val viewId = sibling.viewIdResourceName ?: ""
+
+ if (viewId.contains("name", ignoreCase = true) ||
+ viewId.contains("contact", ignoreCase = true) ||
+ viewId.contains("header", ignoreCase = true)) {
+ val senderText = sibling.text?.toString()
+ if (!senderText.isNullOrBlank()) {
+ sibling.recycle()
+ return senderText
+ }
+ }
+ sibling.recycle()
+ }
+
+ val nextParent = parent.parent
+ parent.recycle()
+ parent = nextParent
+ attempts++
+ }
+
+ return "Unknown"
+ }
+
+ private fun extractSignalSender(node: AccessibilityNodeInfo): String {
+ var parent = node.parent
+ var attempts = 0
+
+ while (parent != null && attempts < 10) {
+ for (i in 0 until parent.childCount) {
+ val sibling = parent.getChild(i) ?: continue
+ val viewId = sibling.viewIdResourceName ?: ""
+ val contentDesc = sibling.contentDescription?.toString() ?: ""
+
+ if (viewId.contains("sender", ignoreCase = true) ||
+ viewId.contains("name", ignoreCase = true) ||
+ viewId.contains("group_member", ignoreCase = true) ||
+ contentDesc.contains("from", ignoreCase = true)) {
+ val senderText = sibling.text?.toString() ?: contentDesc
+ if (senderText.isNotBlank()) {
+ sibling.recycle()
+ return senderText
+ }
+ }
+ sibling.recycle()
+ }
+
+ val nextParent = parent.parent
+ parent.recycle()
+ parent = nextParent
+ attempts++
+ }
+
+ return "Unknown"
+ }
+
+ private fun extractSimpleXSender(node: AccessibilityNodeInfo): String {
+ var parent = node.parent
+ var attempts = 0
+
+ while (parent != null && attempts < 10) {
+ for (i in 0 until parent.childCount) {
+ val sibling = parent.getChild(i) ?: continue
+ val viewId = sibling.viewIdResourceName ?: ""
+
+ if (viewId.contains("sender", ignoreCase = true) ||
+ viewId.contains("name", ignoreCase = true) ||
+ viewId.contains("member", ignoreCase = true)) {
+ val senderText = sibling.text?.toString()
+ if (!senderText.isNullOrBlank()) {
+ sibling.recycle()
+ return senderText
+ }
+ }
+ sibling.recycle()
+ }
+
+ val nextParent = parent.parent
+ parent.recycle()
+ parent = nextParent
+ attempts++
+ }
+
+ return "Unknown"
+ }
+
+ private fun collectTextNodes(node: AccessibilityNodeInfo, output: MutableList<AccessibilityNodeInfo>) {
+ val text = node.text?.toString()
+ if (!text.isNullOrBlank()) {
+ output.add(node)
+ }
+
+ for (i in 0 until node.childCount) {
+ val child = node.getChild(i) ?: continue
+ collectTextNodes(child, output)
+ }
+ }
+
+ private fun findNodesByViewIdContains(
+ node: AccessibilityNodeInfo,
+ pattern: String,
+ output: MutableList<AccessibilityNodeInfo>
+ ) {
+ val viewId = node.viewIdResourceName ?: ""
+ if (viewId.contains(pattern, ignoreCase = true)) {
+ output.add(node)
+ }
+
+ for (i in 0 until node.childCount) {
+ val child = node.getChild(i) ?: continue
+ findNodesByViewIdContains(child, pattern, output)
+ }
+ }
+
+ private fun sendMessageEvent(packageName: String, sender: String, messageText: String) {
+ val dedupKey = "$packageName:$sender:${messageText.hashCode()}"
+ val now = System.currentTimeMillis()
+
+ val lastProcessed = recentMessages[dedupKey]
+ if (lastProcessed != null && (now - lastProcessed) < MESSAGE_DEDUP_WINDOW_MS) {
+ return // Skip duplicate
+ }
+
+ recentMessages[dedupKey] = now
+
+ val appNameResId = getAppNameMap()[packageName]
+ val appName = if (appNameResId != null) getString(appNameResId) else packageName
+
+ val messagePreview = messageText.take(50)
+ Log.d(TAG, "Message detected in $appName from $sender: $messagePreview...")
+
+ val intent = Intent(this, WebSocketService::class.java).apply {
+ action = WebSocketService.ACTION_ACCESSIBILITY_MESSAGE
+ putExtra(WebSocketService.EXTRA_ACCESSIBILITY_APP, appName)
+ putExtra(WebSocketService.EXTRA_ACCESSIBILITY_SENDER, sender)
+ putExtra(WebSocketService.EXTRA_ACCESSIBILITY_MESSAGE_TEXT, messageText)
+ putExtra(WebSocketService.EXTRA_ACCESSIBILITY_TIMESTAMP, now)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
+ }
+
+ private fun cleanupRecentMessages() {
+ val now = System.currentTimeMillis()
+ val iterator = recentMessages.entries.iterator()
+
+ while (iterator.hasNext()) {
+ val entry = iterator.next()
+ if (now - entry.value > MESSAGE_DEDUP_WINDOW_MS * 2) {
+ iterator.remove()
+ } else {
+ break // Since LinkedHashMap maintains access order, newer entries follow
+ }
+ }
+ }
+
+ private fun sendPrivateSpaceEvent() {
+ val intent = Intent(this, WebSocketService::class.java).apply {
+ action = WebSocketService.ACTION_CIRCUMVENTION_EVENT
+ putExtra(WebSocketService.EXTRA_CIRCUMVENTION_PACKAGE, "com.miui.securitycore")
+ putExtra(WebSocketService.EXTRA_CIRCUMVENTION_CLASS, "com.miui.securitycore.PrivateSpaceMainActivity")
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
+ }
+}