diff options
| author | JustZvan <justzvan@justzvan.xyz> | 2026-02-06 13:38:36 +0100 |
|---|---|---|
| committer | JustZvan <justzvan@justzvan.xyz> | 2026-02-06 13:38:36 +0100 |
| commit | adb6a4fd9ec3a23c04d5e4c2ce799448237915c4 (patch) | |
| tree | 786edcf5888788e0667a90fae96d7ebec68c507a /app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt | |
feat: initial commit
Diffstat (limited to 'app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt')
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt | 334 |
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) + } + } +} |