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(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 { 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() findNodesByViewIdContains(rootNode, "message", messageNodes) findNodesByViewIdContains(rootNode, "conversation", messageNodes) val textNodes = mutableListOf() 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() 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() 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) { 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 ) { 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) } } }