summaryrefslogtreecommitdiff
path: root/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main')
-rw-r--r--app/src/main/AndroidManifest.xml79
-rw-r--r--app/src/main/java/sh/lajo/buddy/ApiConfig.kt6
-rw-r--r--app/src/main/java/sh/lajo/buddy/AppNavHost.kt96
-rw-r--r--app/src/main/java/sh/lajo/buddy/BootReceiver.kt23
-rw-r--r--app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt334
-rw-r--r--app/src/main/java/sh/lajo/buddy/BuddyNotificationService.kt65
-rw-r--r--app/src/main/java/sh/lajo/buddy/BuddyVPNService.kt334
-rw-r--r--app/src/main/java/sh/lajo/buddy/ConfigManager.kt92
-rw-r--r--app/src/main/java/sh/lajo/buddy/ContactsObserver.kt186
-rw-r--r--app/src/main/java/sh/lajo/buddy/Destination.kt26
-rw-r--r--app/src/main/java/sh/lajo/buddy/DeviceConfig.kt17
-rw-r--r--app/src/main/java/sh/lajo/buddy/HomeScreen.kt67
-rw-r--r--app/src/main/java/sh/lajo/buddy/HttpClient.kt14
-rw-r--r--app/src/main/java/sh/lajo/buddy/MainActivity.kt122
-rw-r--r--app/src/main/java/sh/lajo/buddy/MainScreen.kt16
-rw-r--r--app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt693
-rw-r--r--app/src/main/java/sh/lajo/buddy/PermissionsScreen.kt15
-rw-r--r--app/src/main/java/sh/lajo/buddy/SettingsScreen.kt17
-rw-r--r--app/src/main/java/sh/lajo/buddy/WebSocketService.kt387
-rw-r--r--app/src/main/java/sh/lajo/buddy/ui/theme/Theme.kt66
-rw-r--r--app/src/main/java/sh/lajo/buddy/ui/theme/Type.kt34
-rw-r--r--app/src/main/res/drawable/ic_launcher_background.xml170
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml30
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml6
-rw-r--r--app/src/main/res/mipmap-hdpi/ic_launcher.pngbin0 -> 6237 bytes
-rw-r--r--app/src/main/res/mipmap-hdpi/ic_launcher_background.pngbin0 -> 1202 bytes
-rw-r--r--app/src/main/res/mipmap-hdpi/ic_launcher_foreground.pngbin0 -> 3293 bytes
-rw-r--r--app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.pngbin0 -> 3293 bytes
-rw-r--r--app/src/main/res/mipmap-mdpi/ic_launcher.pngbin0 -> 3420 bytes
-rw-r--r--app/src/main/res/mipmap-mdpi/ic_launcher_background.pngbin0 -> 697 bytes
-rw-r--r--app/src/main/res/mipmap-mdpi/ic_launcher_foreground.pngbin0 -> 1785 bytes
-rw-r--r--app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.pngbin0 -> 1785 bytes
-rw-r--r--app/src/main/res/mipmap-xhdpi/ic_launcher.pngbin0 -> 8884 bytes
-rw-r--r--app/src/main/res/mipmap-xhdpi/ic_launcher_background.pngbin0 -> 1804 bytes
-rw-r--r--app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.pngbin0 -> 5196 bytes
-rw-r--r--app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.pngbin0 -> 5196 bytes
-rw-r--r--app/src/main/res/mipmap-xxhdpi/ic_launcher.pngbin0 -> 16581 bytes
-rw-r--r--app/src/main/res/mipmap-xxhdpi/ic_launcher_background.pngbin0 -> 3418 bytes
-rw-r--r--app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.pngbin0 -> 9888 bytes
-rw-r--r--app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.pngbin0 -> 9888 bytes
-rw-r--r--app/src/main/res/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 25483 bytes
-rw-r--r--app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.pngbin0 -> 5425 bytes
-rw-r--r--app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.pngbin0 -> 15621 bytes
-rw-r--r--app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.pngbin0 -> 15621 bytes
-rw-r--r--app/src/main/res/values-hr/strings.xml92
-rw-r--r--app/src/main/res/values/colors.xml10
-rw-r--r--app/src/main/res/values/strings.xml91
-rw-r--r--app/src/main/res/values/themes.xml5
-rw-r--r--app/src/main/res/xml/accessibility_config.xml12
-rw-r--r--app/src/main/res/xml/backup_rules.xml13
-rw-r--r--app/src/main/res/xml/data_extraction_rules.xml19
51 files changed, 3137 insertions, 0 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..2449540
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+ <uses-permission android:name="android.permission.READ_CONTACTS" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
+
+ <application
+ android:allowBackup="true"
+ android:dataExtractionRules="@xml/data_extraction_rules"
+ android:fullBackupContent="@xml/backup_rules"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:roundIcon="@mipmap/ic_launcher"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.Buddy">
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:label="@string/app_name"
+ android:theme="@style/Theme.Buddy">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+
+ <receiver
+ android:name=".BootReceiver"
+ android:enabled="true"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
+ </intent-filter>
+ </receiver>
+
+ <service
+ android:name=".BuddyNotificationService"
+ android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.service.notification.NotificationListenerService" />
+ </intent-filter>
+ </service>
+
+ <service
+ android:name=".BuddyAccessibilityService"
+ 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=".BuddyVPNService"
+ android:exported="false"
+ android:permission="android.permission.BIND_VPN_SERVICE">
+ <intent-filter>
+ <action android:name="android.net.VpnService"/>
+ </intent-filter>
+ </service>
+
+
+ <service
+ android:name=".WebSocketService"
+ android:exported="false"
+ android:foregroundServiceType="remoteMessaging" />
+ </application>
+</manifest> \ No newline at end of file
diff --git a/app/src/main/java/sh/lajo/buddy/ApiConfig.kt b/app/src/main/java/sh/lajo/buddy/ApiConfig.kt
new file mode 100644
index 0000000..d26fe2d
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/ApiConfig.kt
@@ -0,0 +1,6 @@
+package sh.lajo.buddy
+
+object ApiConfig {
+ const val BASE_URL = "https://buddy-dev.justzvan.click"
+ const val WS_BASE_URL ="wss://buddy-dev.justzvan.click/kid/connect"
+}
diff --git a/app/src/main/java/sh/lajo/buddy/AppNavHost.kt b/app/src/main/java/sh/lajo/buddy/AppNavHost.kt
new file mode 100644
index 0000000..d79195a
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/AppNavHost.kt
@@ -0,0 +1,96 @@
+package sh.lajo.buddy
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+
+private const val ROUTE_ONBOARDING_STEP1 = "onboarding/step1"
+private const val ROUTE_ONBOARDING_STEP2 = "onboarding/step2"
+private const val ROUTE_ONBOARDING_STEP3 = "onboarding/step3"
+private const val ROUTE_ONBOARDING_STEP4 = "onboarding/step4"
+private const val ROUTE_ONBOARDING_STEP5 = "onboarding/step5"
+private const val ROUTE_ONBOARDING_STEP6 = "onboarding/step6"
+private const val ROUTE_ONBOARDING_STEP7 = "onboarding/step7"
+private const val ROUTE_MAIN = "main"
+private const val ROUTE_HOME = "home"
+private const val ROUTE_SETTINGS = "settings"
+
+@Composable
+fun AppNavHost(
+ navController: NavHostController,
+ startDestination: String,
+ modifier: Modifier = Modifier,
+) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
+ modifier = modifier,
+ ) {
+ composable(ROUTE_ONBOARDING_STEP1) {
+ OnboardingStep1Screen(
+ onNext = { navController.navigate(ROUTE_ONBOARDING_STEP2) },
+ )
+ }
+
+ composable(ROUTE_ONBOARDING_STEP2) {
+ OnboardingStep2Screen(
+ onBack = { navController.popBackStack() },
+ onNext = { navController.navigate(ROUTE_ONBOARDING_STEP3) },
+ )
+ }
+
+ composable(ROUTE_ONBOARDING_STEP3) {
+ OnboardingStep3Screen(
+ onBack = { navController.popBackStack() },
+ onNext = { navController.navigate(ROUTE_ONBOARDING_STEP4) }
+ )
+ }
+
+ composable(ROUTE_ONBOARDING_STEP4) {
+ OnboardingStep4Screen(
+ onBack = { navController.popBackStack() },
+ onNext = { navController.navigate(ROUTE_ONBOARDING_STEP5) }
+ )
+ }
+
+ composable(ROUTE_ONBOARDING_STEP5) {
+ OnboardingStep5Screen(
+ onBack = { navController.popBackStack() },
+ onNext = { navController.navigate(ROUTE_ONBOARDING_STEP6) }
+ )
+ }
+
+ composable(ROUTE_ONBOARDING_STEP6) {
+ OnboardingStep6Screen(
+ onBack = { navController.popBackStack() },
+ onNext = { navController.navigate(ROUTE_ONBOARDING_STEP7) },
+ )
+ }
+
+ composable(ROUTE_ONBOARDING_STEP7) {
+ OnboardingStep7Screen(
+ onBack = { navController.popBackStack() },
+ onFinish = {
+ navController.navigate(ROUTE_HOME) {
+ popUpTo(ROUTE_ONBOARDING_STEP1) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ )
+ }
+
+ composable(ROUTE_MAIN) {
+ MainScreen()
+ }
+
+ composable(ROUTE_HOME) {
+ HomeScreen()
+ }
+
+ composable(ROUTE_SETTINGS) {
+ SettingsScreen()
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/sh/lajo/buddy/BootReceiver.kt b/app/src/main/java/sh/lajo/buddy/BootReceiver.kt
new file mode 100644
index 0000000..6f0867c
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/BootReceiver.kt
@@ -0,0 +1,23 @@
+package sh.lajo.buddy
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
+ val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
+ if (!prefs.getBoolean("onboardingFinished", false)) return
+
+ val serviceIntent = Intent(context, WebSocketService::class.java)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(serviceIntent)
+ } else {
+ context.startService(serviceIntent)
+ }
+ }
+ }
+}
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)
+ }
+ }
+}
diff --git a/app/src/main/java/sh/lajo/buddy/BuddyNotificationService.kt b/app/src/main/java/sh/lajo/buddy/BuddyNotificationService.kt
new file mode 100644
index 0000000..51076b9
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/BuddyNotificationService.kt
@@ -0,0 +1,65 @@
+package sh.lajo.buddy
+
+import android.content.Intent
+import android.provider.ContactsContract
+import android.service.notification.NotificationListenerService
+import android.service.notification.StatusBarNotification
+import android.util.Log
+
+class BuddyNotificationService : NotificationListenerService() {
+ override fun onNotificationPosted(sbn: StatusBarNotification?) {
+ sbn ?: return
+
+ val config = ConfigManager.getConfig(this)
+ if (config.disableBuddy) {
+ Log.d("BuddyNotificationService", "Buddy is disabled, skipping notification")
+ return
+ }
+
+
+ val notification = sbn.notification
+ val title = notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE)?.toString() ?: ""
+ val text = notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)?.toString() ?: ""
+ val packageName = sbn.packageName
+
+ val allowedPackages = arrayOf(
+ "com.whatsapp",
+ "com.discord",
+ "org.thoughtcrime.securesms", // Signal
+ "network.loki.messenger", // Session
+ "chat.simplex.app", // SimpleX
+ )
+
+ if (!allowedPackages.contains(packageName)) {
+ return
+ }
+
+ val contacts = mutableListOf<String>()
+ val cursor = contentResolver.query(
+ ContactsContract.Contacts.CONTENT_URI,
+ arrayOf(ContactsContract.Contacts.DISPLAY_NAME),
+ null, null, null
+ )
+ cursor?.use {
+ while (it.moveToNext()) {
+ val name = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME))
+ contacts.add(name)
+ }
+ }
+
+ for (contactName in contacts) {
+ if (title.contains(contactName, ignoreCase = true)) {
+ return
+ }
+ }
+
+ val intent = Intent(this, WebSocketService::class.java).apply {
+ action = WebSocketService.ACTION_SEND_NOTIFICATION
+ putExtra(WebSocketService.EXTRA_NOTIFICATION_TITLE, title)
+ putExtra(WebSocketService.EXTRA_NOTIFICATION_TEXT, text)
+ putExtra(WebSocketService.EXTRA_NOTIFICATION_PACKAGE, packageName)
+ }
+
+ startForegroundService(intent)
+ }
+}
diff --git a/app/src/main/java/sh/lajo/buddy/BuddyVPNService.kt b/app/src/main/java/sh/lajo/buddy/BuddyVPNService.kt
new file mode 100644
index 0000000..9517464
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/BuddyVPNService.kt
@@ -0,0 +1,334 @@
+package sh.lajo.buddy
+
+import android.content.Intent
+import android.net.VpnService
+import android.os.IBinder
+import android.util.Log
+import java.io.BufferedReader
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStreamReader
+import java.io.OutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.Locale
+import java.util.concurrent.atomic.AtomicReference
+import java.util.zip.GZIPInputStream
+
+class BuddyVPNService : VpnService() {
+ private companion object {
+ private const val TAG = "BuddyVPNService"
+
+ private const val BLOCKLIST_URL_PRIMARY =
+ "https://cdn.jsdelivr.net/gh/lajo-sh/assets/domains.gz"
+ private const val BLOCKLIST_URL_FALLBACK =
+ "https://github.com/lajo-sh/assets/raw/refs/heads/main/domains.gz"
+
+ private const val CONNECT_TIMEOUT_MS = 10_000
+ private const val READ_TIMEOUT_MS = 20_000
+
+ private const val MAX_DOMAINS = 2_000_000
+ }
+
+ /** In-memory only. Rebuilt on every service start. */
+ private val blockedDomainsRef = AtomicReference<Set<String>>(emptySet())
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Thread {
+ val loaded = loadRemoteBlocklist()
+ if (loaded != null) {
+ blockedDomainsRef.set(loaded)
+ Log.i(TAG, "Loaded blocklist: ${loaded.size} domains")
+ } else {
+ Log.w(TAG, "Blocklist load failed; continuing with empty list")
+ }
+ }.start()
+
+ val builder = Builder()
+ .setSession("BuddyVPN")
+ .addAddress("10.0.0.2", 32)
+ .addRoute("10.0.0.1", 32)
+ .addDnsServer("10.0.0.1")
+
+ val vpnInterface = builder.establish() ?: return START_STICKY
+
+ Thread {
+ try {
+ val input = FileInputStream(vpnInterface.fileDescriptor)
+ val output = FileOutputStream(vpnInterface.fileDescriptor)
+ val buffer = ByteArray(32767)
+
+ val dnsSocket = java.net.DatagramSocket()
+ protect(dnsSocket)
+
+ while (true) {
+ val length = input.read(buffer)
+ if (length > 0) {
+ try {
+ handlePacket(buffer, length, output, dnsSocket)
+ } catch (e: Exception) {
+ Log.e(TAG, "Packet handling error", e)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "VPN loop error: ${e.message}", e)
+ }
+ }.start()
+
+ return START_STICKY
+ }
+
+ private fun loadRemoteBlocklist(): Set<String>? {
+ return try {
+ fetchAndParseDomains(BLOCKLIST_URL_PRIMARY) ?: fetchAndParseDomains(BLOCKLIST_URL_FALLBACK)
+ } catch (e: Exception) {
+ Log.w(TAG, "Unexpected blocklist load error: ${e.message}", e)
+ null
+ }
+ }
+
+ private fun fetchAndParseDomains(url: String): Set<String>? {
+ var conn: HttpURLConnection? = null
+ return try {
+ conn = (URL(url).openConnection() as HttpURLConnection).apply {
+ instanceFollowRedirects = true
+ connectTimeout = CONNECT_TIMEOUT_MS
+ readTimeout = READ_TIMEOUT_MS
+ requestMethod = "GET"
+ setRequestProperty("Accept-Encoding", "gzip")
+ setRequestProperty("User-Agent", "BuddyVPNService")
+ }
+
+ val code = conn.responseCode
+ if (code !in 200..299) {
+ Log.w(TAG, "Blocklist fetch failed ($code) from $url")
+ return null
+ }
+
+ conn.inputStream.use { raw ->
+ GZIPInputStream(raw).use { gz ->
+ return parseDomainsFromGzipStream(gz)
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Blocklist fetch/parse failed from $url: ${e.message}")
+ null
+ } finally {
+ conn?.disconnect()
+ }
+ }
+
+ private fun parseDomainsFromGzipStream(stream: java.io.InputStream): Set<String> {
+ val result = HashSet<String>(256 * 1024)
+ BufferedReader(InputStreamReader(stream)).useLines { lines ->
+ for (line in lines) {
+ val d = normalizeDomain(line) ?: continue
+ result.add(d)
+ if (result.size >= MAX_DOMAINS) break
+ }
+ }
+ return result
+ }
+
+ private fun normalizeDomain(raw: String): String? {
+ var s = raw.trim()
+ if (s.isEmpty()) return null
+
+ val hash = s.indexOf('#')
+ if (hash >= 0) s = s.substring(0, hash).trim()
+ if (s.isEmpty()) return null
+
+ val parts = s.split(Regex("\\s+"))
+ val candidate = parts.lastOrNull()?.trim().orEmpty()
+ if (candidate.isEmpty()) return null
+
+ val cleaned = candidate
+ .trimEnd('.')
+ .lowercase(Locale.US)
+
+ if (cleaned.length !in 1..253) return null
+ if (!cleaned.any { it == '.' }) return null
+
+ return cleaned
+ }
+
+ private fun isBlocked(domain: String): Boolean {
+ // Check if blocking is enabled in config
+ val config = ConfigManager.getConfig(this)
+ if (!config.blockAdultSites) {
+ return false
+ }
+
+ val blockedDomains = blockedDomainsRef.get()
+ if (blockedDomains.isEmpty()) return false
+
+ val d = normalizeDomain(domain) ?: return false
+ if (blockedDomains.contains(d)) return true
+
+ var idx = d.indexOf('.')
+ while (idx >= 0 && idx + 1 < d.length) {
+ val suffix = d.substring(idx + 1)
+ if (blockedDomains.contains(suffix)) return true
+ idx = d.indexOf('.', idx + 1)
+ }
+
+ return false
+ }
+
+ private fun isDns(packet: ByteArray, length: Int): Boolean {
+ if (length < 28) return false
+ val protocol = packet.getOrNull(9)?.toInt() ?: return false
+ if (protocol != 17) return false
+ val destPort = ((packet[22].toInt() and 0xFF) shl 8) or (packet[23].toInt() and 0xFF)
+ return destPort == 53
+ }
+
+ private fun extractDomain(packet: ByteArray): String? {
+ var index = 40
+ val labels = mutableListOf<String>()
+
+ try {
+ while (index < packet.size) {
+ val len = packet[index].toInt() and 0xFF
+ if (len == 0) break
+ if ((len and 0xC0) == 0xC0) {
+ return null
+ }
+ index++
+ if (index + len > packet.size) return null
+ labels.add(String(packet, index, len))
+ index += len
+ }
+ } catch (_: Exception) {
+ return null
+ }
+
+ return if (labels.isEmpty()) null else labels.joinToString(".")
+ }
+
+ private fun handlePacket(packet: ByteArray, length: Int, output: OutputStream, dnsSocket: java.net.DatagramSocket) {
+ if (!isDns(packet, length)) {
+ return
+ }
+
+ val domain = extractDomain(packet)
+ if (domain == null) {
+ forwardDns(packet, length, output, dnsSocket)
+ return
+ }
+
+ if (isBlocked(domain)) {
+ Log.w(TAG, "blocked: $domain")
+ sendNxDomain(packet, length, output)
+ } else {
+ forwardDns(packet, length, output, dnsSocket)
+ }
+ }
+
+ private fun forwardDns(packet: ByteArray, length: Int, output: OutputStream, dnsSocket: java.net.DatagramSocket) {
+ val dnsPayloadLen = length - 28
+ if (dnsPayloadLen <= 0) return
+
+ val buf = ByteArray(dnsPayloadLen)
+ System.arraycopy(packet, 28, buf, 0, dnsPayloadLen)
+
+ val outPacket = java.net.DatagramPacket(buf, dnsPayloadLen,
+ java.net.InetAddress.getByName("9.9.9.9"), 53) // quad 9
+
+ dnsSocket.send(outPacket)
+
+ val respBuf = ByteArray(4096)
+ val inPacket = java.net.DatagramPacket(respBuf, respBuf.size)
+ try {
+ dnsSocket.soTimeout = 2000
+ dnsSocket.receive(inPacket)
+
+ val response = checkAndConstructResponse(packet, inPacket.data, inPacket.length)
+ output.write(response)
+ } catch (_: Exception) {
+
+ }
+ }
+
+ private fun sendNxDomain(packet: ByteArray, length: Int, output: OutputStream) {
+ val dnsLen = length - 28
+ val responseDns = ByteArray(dnsLen)
+ System.arraycopy(packet, 28, responseDns, 0, dnsLen)
+
+ responseDns[2] = 0x81.toByte()
+ responseDns[3] = 0x03.toByte()
+
+ val ipPacket = checkAndConstructResponse(packet, responseDns, responseDns.size)
+ output.write(ipPacket)
+ }
+
+ private fun checkAndConstructResponse(request: ByteArray, dnsPayload: ByteArray, dnsLen: Int): ByteArray {
+ val totalLen = 28 + dnsLen
+ val response = ByteArray(totalLen)
+
+ response[0] = 0x45 // IPv4
+ response[1] = 0x00
+ // Length
+ response[2] = (totalLen shr 8).toByte()
+ response[3] = (totalLen and 0xFF).toByte()
+ // ID (random or 0)
+ response[4] = 0x00
+ response[5] = 0x00
+ // Flags/Frag
+ response[6] = 0x40 // Don't fragment
+ response[7] = 0x00
+ // TTL
+ response[8] = 64
+ // Protocol
+ response[9] = 17 // UDP
+ // Checksum (calc later)
+ response[10] = 0; response[11] = 0
+
+ System.arraycopy(request, 16, response, 12, 4) // Old Dst is new Src
+ System.arraycopy(request, 12, response, 16, 4) // Old Src is new Dst
+
+ // Calc IP Checksum
+ fillIpChecksum(response)
+
+ // 2. UDP Header (8 bytes)
+ // Src Port = Request Dst Port
+ response[20] = request[22]
+ response[21] = request[23]
+ // Dst Port = Request Src Port
+ response[22] = request[20]
+ response[23] = request[21]
+ // Length
+ val udpLen = 8 + dnsLen
+ response[24] = (udpLen shr 8).toByte()
+ response[25] = (udpLen and 0xFF).toByte()
+ // Checksum = 0 (valid for UDP)
+ response[26] = 0
+ response[27] = 0
+
+ // 3. DNS Payload
+ System.arraycopy(dnsPayload, 0, response, 28, dnsLen)
+
+ return response
+ }
+
+ private fun fillIpChecksum(packet: ByteArray) {
+ var sum = 0
+ // Header length 20 bytes = 10 shorts
+ for (i in 0 until 10) {
+ // Skip checksum field itself (10, 11)
+ if (i == 5) continue
+ val b1 = packet[i * 2].toInt() and 0xFF
+ val b2 = packet[i * 2 + 1].toInt() and 0xFF
+ sum += (b1 shl 8) + b2
+ }
+ while ((sum shr 16) > 0) {
+ sum = (sum and 0xFFFF) + (sum shr 16)
+ }
+ val checksum = sum.inv() and 0xFFFF
+ packet[10] = (checksum shr 8).toByte()
+ packet[11] = (checksum and 0xFF).toByte()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = super.onBind(intent)
+}
diff --git a/app/src/main/java/sh/lajo/buddy/ConfigManager.kt b/app/src/main/java/sh/lajo/buddy/ConfigManager.kt
new file mode 100644
index 0000000..eb37184
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/ConfigManager.kt
@@ -0,0 +1,92 @@
+package sh.lajo.buddy
+
+import android.content.Context
+import android.util.Log
+import kotlinx.serialization.json.Json
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Request
+import okhttp3.Response
+import java.io.IOException
+
+object ConfigManager {
+ private const val TAG = "ConfigManager"
+ private const val PREFS_KEY_DISABLE_BUDDY = "config_disable_buddy"
+ private const val PREFS_KEY_BLOCK_ADULT_SITES = "config_block_adult_sites"
+ private const val PREFS_KEY_LAST_FETCH = "config_last_fetch"
+ private const val FETCH_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ fun getConfig(context: Context): DeviceConfig {
+ val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
+ return DeviceConfig(
+ disableBuddy = prefs.getBoolean(PREFS_KEY_DISABLE_BUDDY, false),
+ blockAdultSites = prefs.getBoolean(PREFS_KEY_BLOCK_ADULT_SITES, true)
+ )
+ }
+
+ fun saveConfig(context: Context, config: DeviceConfig) {
+ val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
+ prefs.edit().apply {
+ putBoolean(PREFS_KEY_DISABLE_BUDDY, config.disableBuddy)
+ putBoolean(PREFS_KEY_BLOCK_ADULT_SITES, config.blockAdultSites)
+ apply()
+ }
+ }
+
+ fun shouldFetchConfig(context: Context): Boolean {
+ val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
+ val lastFetch = prefs.getLong(PREFS_KEY_LAST_FETCH, 0)
+ return System.currentTimeMillis() - lastFetch > FETCH_INTERVAL_MS
+ }
+
+ fun fetchConfig(context: Context, onComplete: ((Boolean) -> Unit)? = null) {
+ val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
+ val token = prefs.getString("auth_token", "") ?: ""
+
+ if (token.isEmpty()) {
+ Log.w(TAG, "No auth token available, skipping config fetch")
+ onComplete?.invoke(false)
+ return
+ }
+
+ val request = Request.Builder()
+ .url("${ApiConfig.BASE_URL}/kid/getconfig")
+ .addHeader("Authorization", "Bearer $token")
+ .get()
+ .build()
+
+ HttpClient.client.newCall(request).enqueue(object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ Log.e(TAG, "Failed to fetch config: ${e.message}")
+ onComplete?.invoke(false)
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ try {
+ val body = response.body?.string()
+ if (response.isSuccessful && body != null) {
+ val configResponse = json.decodeFromString<ConfigResponse>(body)
+ if (configResponse.success && configResponse.config != null) {
+ saveConfig(context, configResponse.config)
+ prefs.edit().putLong(PREFS_KEY_LAST_FETCH, System.currentTimeMillis()).apply()
+ Log.i(TAG, "Config fetched successfully: ${configResponse.config}")
+ onComplete?.invoke(true)
+ } else {
+ Log.w(TAG, "Config fetch failed: ${configResponse.reason}")
+ onComplete?.invoke(false)
+ }
+ } else {
+ Log.w(TAG, "Config fetch returned ${response.code}")
+ onComplete?.invoke(false)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse config response: ${e.message}")
+ onComplete?.invoke(false)
+ }
+ }
+ })
+ }
+}
+
diff --git a/app/src/main/java/sh/lajo/buddy/ContactsObserver.kt b/app/src/main/java/sh/lajo/buddy/ContactsObserver.kt
new file mode 100644
index 0000000..50b7a48
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/ContactsObserver.kt
@@ -0,0 +1,186 @@
+package sh.lajo.buddy
+
+import android.Manifest
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.database.ContentObserver
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import android.provider.ContactsContract
+import android.util.Log
+import androidx.core.content.ContextCompat
+
+class ContactsObserver(
+ private val context: Context,
+ private val contentResolver: ContentResolver
+) : ContentObserver(Handler(Looper.getMainLooper())) {
+
+ companion object {
+ private const val TAG = "ContactsObserver"
+ }
+
+ private var lastContactCount = -1
+
+ init {
+ // Initialize the contact count
+ lastContactCount = getContactCount()
+ }
+
+ override fun onChange(selfChange: Boolean) {
+ onChange(selfChange, null)
+ }
+
+ override fun onChange(selfChange: Boolean, uri: Uri?) {
+
+ // Check if we have READ_CONTACTS permission
+ if (ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_CONTACTS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ Log.w(TAG, "No READ_CONTACTS permission, skipping contact change")
+ return
+ }
+
+ Log.d(TAG, "Contact change detected, URI: $uri")
+
+ // Get current contact count
+ val currentCount = getContactCount()
+
+ // If count increased, a contact was likely added
+ if (lastContactCount >= 0 && currentCount > lastContactCount) {
+ Log.d(TAG, "Contact added detected (count: $lastContactCount -> $currentCount)")
+ // Find and send the new contact(s)
+ findAndSendNewContacts()
+ }
+
+ lastContactCount = currentCount
+ }
+
+ private fun getContactCount(): Int {
+ try {
+ val cursor = contentResolver.query(
+ ContactsContract.Contacts.CONTENT_URI,
+ arrayOf(ContactsContract.Contacts._ID),
+ null,
+ null,
+ null
+ )
+ cursor?.use {
+ return it.count
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting contact count", e)
+ }
+ return 0
+ }
+
+ private fun findAndSendNewContacts() {
+ try {
+ // Query the most recently added contacts
+ val cursor = contentResolver.query(
+ ContactsContract.Contacts.CONTENT_URI,
+ arrayOf(
+ ContactsContract.Contacts._ID,
+ ContactsContract.Contacts.DISPLAY_NAME
+ ),
+ null,
+ null,
+ "${ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP} DESC"
+ )
+
+ cursor?.use {
+ if (it.moveToFirst()) {
+ // Get the most recent contact
+ val contactId = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
+ val name = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME))
+
+ val phoneNumbers = getPhoneNumbers(contactId)
+ val emails = getEmails(contactId)
+
+ // Send the contact info via WebSocket
+ sendContactAddedEvent(name, phoneNumbers, emails)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error finding new contacts", e)
+ }
+ }
+
+ private fun getPhoneNumbers(contactId: String): List<String> {
+ val phoneNumbers = mutableListOf<String>()
+ try {
+ val cursor = contentResolver.query(
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
+ arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
+ "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
+ arrayOf(contactId),
+ null
+ )
+
+ cursor?.use {
+ while (it.moveToNext()) {
+ val number = it.getString(it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER))
+ phoneNumbers.add(number)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting phone numbers", e)
+ }
+ return phoneNumbers
+ }
+
+ private fun getEmails(contactId: String): List<String> {
+ val emails = mutableListOf<String>()
+ try {
+ val cursor = contentResolver.query(
+ ContactsContract.CommonDataKinds.Email.CONTENT_URI,
+ arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS),
+ "${ContactsContract.CommonDataKinds.Email.CONTACT_ID} = ?",
+ arrayOf(contactId),
+ null
+ )
+
+ cursor?.use {
+ while (it.moveToNext()) {
+ val email = it.getString(it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS))
+ emails.add(email)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting emails", e)
+ }
+ return emails
+ }
+
+ private fun sendContactAddedEvent(name: String, phoneNumbers: List<String>, emails: List<String>) {
+ Log.d(TAG, "Sending contact added event: $name, phones: $phoneNumbers, emails: $emails")
+
+ val intent = Intent(context, WebSocketService::class.java).apply {
+ action = WebSocketService.ACTION_SEND_CONTACT
+ putExtra(WebSocketService.EXTRA_CONTACT_NAME, name)
+ putExtra(WebSocketService.EXTRA_CONTACT_PHONES, phoneNumbers.toTypedArray())
+ putExtra(WebSocketService.EXTRA_CONTACT_EMAILS, emails.toTypedArray())
+ }
+
+ context.startForegroundService(intent)
+ }
+
+ fun register() {
+ contentResolver.registerContentObserver(
+ ContactsContract.Contacts.CONTENT_URI,
+ true,
+ this
+ )
+ Log.d(TAG, "ContactsObserver registered")
+ }
+
+ fun unregister() {
+ contentResolver.unregisterContentObserver(this)
+ Log.d(TAG, "ContactsObserver unregistered")
+ }
+}
+
diff --git a/app/src/main/java/sh/lajo/buddy/Destination.kt b/app/src/main/java/sh/lajo/buddy/Destination.kt
new file mode 100644
index 0000000..ba82cbb
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/Destination.kt
@@ -0,0 +1,26 @@
+package sh.lajo.buddy
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.ui.graphics.vector.ImageVector
+
+enum class Destination(
+ val route: String,
+ val label: String,
+ val icon: ImageVector,
+ val contentDescription: String
+) {
+ Home(
+ route = "home",
+ label = "Home",
+ icon = Icons.Default.Home,
+ contentDescription = "Home Screen"
+ ),
+ Settings(
+ route = "settings",
+ label = "Settings",
+ icon = Icons.Default.Settings,
+ contentDescription = "Settings Screen"
+ )
+} \ No newline at end of file
diff --git a/app/src/main/java/sh/lajo/buddy/DeviceConfig.kt b/app/src/main/java/sh/lajo/buddy/DeviceConfig.kt
new file mode 100644
index 0000000..f8e24a9
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/DeviceConfig.kt
@@ -0,0 +1,17 @@
+package sh.lajo.buddy
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DeviceConfig(
+ val disableBuddy: Boolean = false,
+ val blockAdultSites: Boolean = true
+)
+
+@Serializable
+data class ConfigResponse(
+ val success: Boolean,
+ val config: DeviceConfig? = null,
+ val reason: String? = null
+)
+
diff --git a/app/src/main/java/sh/lajo/buddy/HomeScreen.kt b/app/src/main/java/sh/lajo/buddy/HomeScreen.kt
new file mode 100644
index 0000000..c7208bc
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/HomeScreen.kt
@@ -0,0 +1,67 @@
+package sh.lajo.buddy
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+
+
+@Composable
+fun HomeScreen() {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ StatusCard()
+ }
+ }
+}
+
+@Composable
+private fun StatusCard() {
+ ElevatedCard(
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = stringResource(R.string.home_icon_description)
+ )
+ Column(
+ modifier = Modifier.padding(start = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.home_status_connected),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = stringResource(R.string.home_status_running),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/sh/lajo/buddy/HttpClient.kt b/app/src/main/java/sh/lajo/buddy/HttpClient.kt
new file mode 100644
index 0000000..eae5324
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/HttpClient.kt
@@ -0,0 +1,14 @@
+package sh.lajo.buddy
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+
+object HttpClient {
+ val client: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ // You can add timeouts, interceptors, etc. here if needed
+ .build()
+ }
+
+ val JSON = "application/json".toMediaType()
+}
diff --git a/app/src/main/java/sh/lajo/buddy/MainActivity.kt b/app/src/main/java/sh/lajo/buddy/MainActivity.kt
new file mode 100644
index 0000000..3f398b3
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/MainActivity.kt
@@ -0,0 +1,122 @@
+package sh.lajo.buddy
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.VpnService
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import sh.lajo.buddy.ui.theme.BuddyTheme
+
+class MainActivity : ComponentActivity() {
+ private val vpnPrepareLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ startBuddyVpnServiceIfAllowed()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ if (ConfigManager.shouldFetchConfig(this)) {
+ ConfigManager.fetchConfig(this)
+ }
+
+ maybePrepareAndStartVpn()
+
+ setContent {
+ BuddyTheme {
+ val context = LocalContext.current
+ val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
+ val navController = rememberNavController()
+ val startDestination = if (prefs.getBoolean("onboardingFinished", false)) "home" else "onboarding/step1"
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = navBackStackEntry?.destination?.route
+
+ if (prefs.getBoolean("onboardingFinished", false)) {
+ val wsIntent = Intent(context, WebSocketService::class.java)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(wsIntent)
+ } else {
+ context.startService(wsIntent)
+ }
+ }
+
+ // Determine which destinations should show the bottom bar
+ val showBottomBar = currentRoute in listOf("main", "home", "settings")
+
+ Scaffold(
+ bottomBar = {
+ // Only show the bottom bar on the main screens
+ if (showBottomBar) {
+ NavigationBar {
+ Destination.entries.forEach { destination ->
+ NavigationBarItem(
+ selected = currentRoute == destination.route,
+ onClick = {
+ navController.navigate(destination.route) {
+ // Pop up to main to avoid building up a large stack
+ popUpTo("main") {
+ saveState = true
+ }
+ // Avoid multiple copies of the same destination when
+ // reselecting the same item
+ launchSingleTop = true
+ // Restore state when reselecting a previously selected item
+ restoreState = true
+ }
+ },
+ icon = { Icon(destination.icon, contentDescription = destination.contentDescription) },
+ label = { Text(destination.label) }
+ )
+ }
+ }
+ }
+ }
+ ) { contentPadding ->
+ AppNavHost(
+ navController = navController,
+ startDestination = startDestination,
+ modifier = Modifier.padding(contentPadding)
+ )
+ }
+ }
+ }
+ }
+
+ private fun maybePrepareAndStartVpn() {
+ // If the user already granted VPN permission, prepare() returns null.
+ val prepareIntent = VpnService.prepare(this)
+ if (prepareIntent != null) {
+ vpnPrepareLauncher.launch(prepareIntent)
+ } else {
+ startBuddyVpnServiceIfAllowed()
+ }
+ }
+
+ private fun startBuddyVpnServiceIfAllowed() {
+ // Even on Android O+, VpnService isn't started via startForegroundService like typical
+ // foreground services; establishing the VPN tunnel is controlled by VpnService APIs.
+ // Using startService here is the recommended pattern.
+ val intent = Intent(this, BuddyVPNService::class.java)
+ startService(intent)
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/sh/lajo/buddy/MainScreen.kt b/app/src/main/java/sh/lajo/buddy/MainScreen.kt
new file mode 100644
index 0000000..2985331
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/MainScreen.kt
@@ -0,0 +1,16 @@
+package sh.lajo.buddy
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+
+@Composable
+fun MainScreen() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.main_screen_placeholder))
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt b/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt
new file mode 100644
index 0000000..d1b2378
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt
@@ -0,0 +1,693 @@
+package sh.lajo.buddy
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Build
+import android.os.PowerManager
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.res.stringResource
+
+import okhttp3.Request
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.encodeToString
+import okhttp3.RequestBody.Companion.toRequestBody
+import sh.lajo.buddy.ApiConfig.BASE_URL
+import sh.lajo.buddy.HttpClient.JSON
+
+@Serializable
+data class LoginRequest(
+ val email: String,
+ val password: String
+)
+
+@Serializable
+data class KidLinkResponse(
+ val success: Boolean,
+ val token: String? = null,
+ val reason: String? = null,
+)
+
+private val networkJson = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+}
+
+@Composable
+fun OnboardingStep1Screen(
+ onNext: () -> Unit,
+) {
+ OnboardingScaffold(
+ title = stringResource(R.string.onboarding_step1_title),
+ body = stringResource(R.string.onboarding_step1_body),
+ primaryButtonText = stringResource(R.string.onboarding_next),
+ onPrimary = onNext,
+ showBack = false,
+ onBack = null,
+ )
+}
+
+@Composable
+fun OnboardingStep2Screen(
+ onBack: () -> Unit,
+ onNext: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ var postNotificationsGranted by remember { mutableStateOf(isPostNotificationsGranted(context)) }
+ var notificationListenerEnabled by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { granted -> postNotificationsGranted = granted }
+ )
+
+ // Refresh when coming back from system settings screens.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, context) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ postNotificationsGranted = isPostNotificationsGranted(context)
+ notificationListenerEnabled = isNotificationListenerEnabled(context)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val canProceed = postNotificationsGranted && notificationListenerEnabled
+
+ val notificationPermissionText = stringResource(
+ R.string.onboarding_notification_permission_status,
+ stringResource(if (postNotificationsGranted) R.string.onboarding_permission_granted else R.string.onboarding_permission_not_granted)
+ )
+ val notificationListenerText = stringResource(
+ R.string.onboarding_notification_listener_status,
+ stringResource(if (notificationListenerEnabled) R.string.onboarding_permission_enabled else R.string.onboarding_permission_not_enabled)
+ )
+
+ val statusText = "$notificationPermissionText\n$notificationListenerText"
+
+ OnboardingScaffold(
+ title = stringResource(R.string.onboarding_step2_title),
+ body = stringResource(R.string.onboarding_step2_body),
+ primaryButtonText = stringResource(R.string.onboarding_next),
+ onPrimary = {
+ if (canProceed) onNext()
+ },
+ primaryEnabled = canProceed,
+ showBack = true,
+ onBack = onBack,
+ extraContent = {
+ Text(statusText, style = MaterialTheme.typography.bodySmall)
+ Spacer(Modifier.height(12.dp))
+
+ if (!postNotificationsGranted) {
+ Button(
+ onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
+ ) {
+ Text(stringResource(R.string.onboarding_grant_notification_permission))
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ if (!notificationListenerEnabled) {
+ Button(
+ onClick = {
+ safeStartActivity(context, Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
+ }
+ ) {
+ Text(stringResource(R.string.onboarding_enable_notification_access))
+ }
+ }
+
+ if (!canProceed) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ stringResource(R.string.onboarding_complete_steps),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun OnboardingStep3Screen(
+ onBack: () -> Unit,
+ onNext: () -> Unit
+) {
+ val context = LocalContext.current
+
+ var accessibilityServiceEnabled by remember {
+ mutableStateOf(isAccessibilityServiceEnabled(context))
+ }
+
+ // Refresh when coming back from system settings screens.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, context) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ accessibilityServiceEnabled = isAccessibilityServiceEnabled(context)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val canProceed = accessibilityServiceEnabled
+
+ OnboardingScaffold(
+ title = stringResource(R.string.onboarding_step3_title),
+ body = stringResource(R.string.onboarding_step3_body),
+ primaryButtonText = stringResource(R.string.onboarding_next),
+ onPrimary = {
+ if (canProceed) onNext()
+ },
+ primaryEnabled = canProceed,
+ showBack = true,
+ onBack = onBack,
+ extraContent = {
+ Text(
+ stringResource(
+ R.string.onboarding_accessibility_service_status,
+ stringResource(if (accessibilityServiceEnabled) R.string.onboarding_permission_enabled else R.string.onboarding_permission_not_enabled)
+ ),
+ style = MaterialTheme.typography.bodySmall
+ )
+ Spacer(Modifier.height(12.dp))
+
+ if (!accessibilityServiceEnabled) {
+ Button(
+ onClick = {
+ safeStartActivity(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
+ }
+ ) {
+ Text(stringResource(R.string.onboarding_enable_accessibility_access))
+ }
+ }
+
+ if (!canProceed) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ stringResource(R.string.onboarding_enable_accessibility_to_continue),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun OnboardingStep4Screen(
+ onBack: () -> Unit,
+ onNext: () -> Unit
+) {
+ val context = LocalContext.current
+
+ var ignoringBatteryOptimizations by remember {
+ mutableStateOf(isIgnoringBatteryOptimizations(context))
+ }
+
+ // Refresh when coming back from system settings screens.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, context) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ ignoringBatteryOptimizations = isIgnoringBatteryOptimizations(context)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val canProceed = ignoringBatteryOptimizations
+
+ OnboardingScaffold(
+ title = stringResource(R.string.onboarding_step4_title),
+ body = stringResource(R.string.onboarding_step4_body),
+ primaryButtonText = stringResource(R.string.onboarding_next),
+ onPrimary = {
+ if (canProceed) onNext()
+ },
+ primaryEnabled = canProceed,
+ showBack = true,
+ onBack = onBack,
+ extraContent = {
+ Text(
+ stringResource(
+ R.string.onboarding_battery_optimization_status,
+ stringResource(if (ignoringBatteryOptimizations) R.string.onboarding_battery_optimization_disabled else R.string.onboarding_battery_optimization_enabled)
+ ),
+ style = MaterialTheme.typography.bodySmall
+ )
+ Spacer(Modifier.height(12.dp))
+
+ Button(
+ onClick = {
+ val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:${context.packageName}")
+ }
+ safeStartActivity(context, intent)
+ }
+ ) {
+ Text(stringResource(R.string.onboarding_allow_running_in_background))
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedButton(
+ onClick = {
+ safeStartActivity(context, Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
+ }
+ ) {
+ Text(stringResource(R.string.onboarding_open_battery_optimization_settings))
+ }
+
+ if (!canProceed) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ stringResource(R.string.onboarding_disable_battery_optimization_to_continue),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun OnboardingStep5Screen(
+ onBack: () -> Unit,
+ onNext: () -> Unit
+) {
+ val context = LocalContext.current
+
+ var contactsPermissionGranted by remember {
+ mutableStateOf(isContactsPermissionGranted(context))
+ }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { granted -> contactsPermissionGranted = granted }
+ )
+
+ // Refresh when coming back from system settings screens.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, context) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ contactsPermissionGranted = isContactsPermissionGranted(context)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val canProceed = contactsPermissionGranted
+
+ OnboardingScaffold(
+ title = stringResource(R.string.onboarding_step5_title),
+ body = stringResource(R.string.onboarding_step5_body),
+ primaryButtonText = stringResource(R.string.onboarding_next),
+ onPrimary = {
+ if (canProceed) onNext()
+ },
+ primaryEnabled = canProceed,
+ showBack = true,
+ onBack = onBack,
+ extraContent = {
+ Text(
+ stringResource(
+ R.string.onboarding_contacts_permission_status,
+ stringResource(if (contactsPermissionGranted) R.string.onboarding_permission_granted else R.string.onboarding_permission_not_granted)
+ ),
+ style = MaterialTheme.typography.bodySmall
+ )
+ Spacer(Modifier.height(12.dp))
+
+ if (!contactsPermissionGranted) {
+ Button(
+ onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }
+ ) {
+ Text(stringResource(R.string.onboarding_grant_contacts_permission))
+ }
+ }
+
+ if (!canProceed) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ stringResource(R.string.onboarding_grant_contacts_to_continue),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun OnboardingStep6Screen(
+ onBack: () -> Unit,
+ onNext: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ // Best-effort detection: some OEMs hide details, so we also allow manual confirmation.
+ var vpnActive by remember { mutableStateOf(isVpnActive(context)) }
+ var userConfirmed by remember { mutableStateOf(false) }
+
+ // Refresh when coming back from system settings screens.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, context) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ vpnActive = isVpnActive(context)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val canProceed = vpnActive || userConfirmed
+
+ OnboardingScaffold(
+ title = stringResource(R.string.onboarding_step6_title),
+ body = stringResource(R.string.onboarding_step6_body),
+ primaryButtonText = stringResource(R.string.onboarding_next),
+ onPrimary = {
+ if (canProceed) onNext()
+ },
+ primaryEnabled = canProceed,
+ showBack = true,
+ onBack = onBack,
+ extraContent = {
+ Text(
+ stringResource(
+ R.string.onboarding_vpn_status,
+ stringResource(if (vpnActive) R.string.onboarding_vpn_status_active else R.string.onboarding_vpn_status_not_detected)
+ ),
+ style = MaterialTheme.typography.bodySmall
+ )
+ Spacer(Modifier.height(12.dp))
+
+ Button(
+ onClick = {
+ // This opens the system VPN settings page.
+ safeStartActivity(context, Intent(Settings.ACTION_VPN_SETTINGS))
+ }
+ ) {
+ Text(stringResource(R.string.onboarding_open_vpn_settings))
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedButton(
+ onClick = { userConfirmed = true }
+ ) {
+ Text(stringResource(R.string.onboarding_i_enabled_vpn))
+ }
+
+ if (!canProceed) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ stringResource(R.string.onboarding_enable_vpn_to_continue),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun OnboardingStep7Screen(
+ onFinish: () -> Unit,
+ onBack: () -> Unit
+) {
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+
+ var isLoading by remember { mutableStateOf(false) }
+ var resultText by remember { mutableStateOf<String?>(null) }
+ var errorText by remember { mutableStateOf<String?>(null) }
+
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ OnboardingScaffold(
+ title = stringResource(R.string.onboarding_step7_title),
+ body = stringResource(R.string.onboarding_step7_body),
+ primaryButtonText = if (isLoading) stringResource(R.string.onboarding_logging_in) else stringResource(R.string.onboarding_login),
+ onPrimary = {
+ if (isLoading) return@OnboardingScaffold
+
+ isLoading = true
+ errorText = null
+ resultText = null
+
+ coroutineScope.launch {
+ try {
+ val client = HttpClient.client
+
+ val jsonBody = networkJson.encodeToString(LoginRequest(email, password))
+ val body = jsonBody.toRequestBody(JSON)
+
+ val request = Request.Builder()
+ .url("${BASE_URL}/kid/link")
+ .post(body)
+ .build()
+
+ val rawBody = withContext(Dispatchers.IO) {
+ client.newCall(request).execute().use { response ->
+ val bodyString = response.body?.string().orEmpty()
+
+ // If we got a non-2xx response, still try to parse a structured error.
+ if (!response.isSuccessful) {
+ val parsed = runCatching {
+ networkJson.decodeFromString<KidLinkResponse>(bodyString)
+ }.getOrNull()
+
+ val reason = parsed?.reason?.takeIf { it.isNotBlank() }
+ throw IllegalStateException(reason ?: "HTTP ${response.code}")
+ }
+
+ bodyString
+ }
+ }
+
+ val parsed = runCatching {
+ networkJson.decodeFromString<KidLinkResponse>(rawBody)
+ }.getOrElse { t ->
+ throw IllegalStateException("Invalid server response")
+ }
+
+ if (parsed.success) {
+ val token = parsed.token?.trim().orEmpty()
+ if (token.isBlank()) {
+ throw IllegalStateException("Missing token in server response")
+ }
+ resultText = context.getString(R.string.onboarding_linked_successfully)
+ val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
+ prefs.edit()
+ .putString("auth_token", token)
+ .putBoolean("onboardingFinished", true)
+ .apply()
+ onFinish()
+ } else {
+ val reason = parsed.reason?.takeIf { it.isNotBlank() } ?: "Unknown error"
+ throw IllegalStateException(reason)
+ }
+ } catch (t: Throwable) {
+ errorText = t.message ?: t::class.java.simpleName
+ } finally {
+ isLoading = false
+ }
+ }
+ },
+ primaryEnabled = !isLoading,
+ showBack = true,
+ onBack = onBack,
+ extraContent = {
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ label = { Text(stringResource(R.string.onboarding_email)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(stringResource(R.string.onboarding_password)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ )
+
+ if (errorText != null) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.onboarding_error, errorText!!),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ if (resultText != null) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = resultText!!,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+private fun OnboardingScaffold(
+ title: String,
+ body: String,
+ primaryButtonText: String,
+ onPrimary: () -> Unit,
+ primaryEnabled: Boolean = true,
+ showBack: Boolean,
+ onBack: (() -> Unit)?,
+ extraContent: @Composable (() -> Unit)? = null,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .weight(1f),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(title, style = MaterialTheme.typography.headlineMedium)
+ Spacer(Modifier.height(12.dp))
+ Text(body, style = MaterialTheme.typography.bodyLarge)
+
+ if (extraContent != null) {
+ Spacer(Modifier.height(20.dp))
+ extraContent()
+ }
+ }
+ }
+
+ // No weight here; this row should wrap content and sit at the bottom due to the Box using weight(1f).
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (showBack) Arrangement.SpaceBetween else Arrangement.Absolute.Right,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (showBack && onBack != null) {
+ OutlinedButton(onClick = onBack) { Text(stringResource(R.string.onboarding_back)) }
+ }
+
+ Button(
+ onClick = onPrimary,
+ enabled = primaryEnabled,
+ ) { Text(primaryButtonText) }
+ }
+ }
+}
+
+private fun isPostNotificationsGranted(context: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else {
+ true
+ }
+}
+
+private fun isNotificationListenerEnabled(context: Context): Boolean {
+ val enabled = Settings.Secure.getString(
+ context.contentResolver,
+ "enabled_notification_listeners"
+ ) ?: return false
+ return enabled.contains(context.packageName)
+}
+
+private fun isAccessibilityServiceEnabled(context: Context): Boolean {
+ val enabled = Settings.Secure.getString(
+ context.contentResolver,
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
+ ) ?: return false
+ val serviceName = "${context.packageName}/${context.packageName}.BuddyAccessibilityService"
+ return enabled.contains(serviceName)
+}
+
+private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
+ val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return false
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ pm.isIgnoringBatteryOptimizations(context.packageName)
+ } else {
+ true
+ }
+}
+
+private fun isContactsPermissionGranted(context: Context): Boolean {
+ return context.checkSelfPermission(Manifest.permission.READ_CONTACTS) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED
+}
+
+private fun isVpnActive(context: Context): Boolean {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+ ?: return false
+ val network = cm.activeNetwork ?: return false
+ val caps = cm.getNetworkCapabilities(network) ?: return false
+
+ // TRANSPORT_VPN is available on API 21+.
+ return caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
+}
+
+private fun safeStartActivity(context: Context, intent: Intent) {
+ val safeIntent = Intent(intent).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (safeIntent.resolveActivity(context.packageManager) != null) {
+ context.startActivity(safeIntent)
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/sh/lajo/buddy/PermissionsScreen.kt b/app/src/main/java/sh/lajo/buddy/PermissionsScreen.kt
new file mode 100644
index 0000000..c0d7dbb
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/PermissionsScreen.kt
@@ -0,0 +1,15 @@
+package sh.lajo.buddy
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavController
+
+/**
+ * Deprecated: old entry-point kept so existing navigation/routes don't crash.
+ * The onboarding flow now lives in `OnboardingScreens.kt`.
+ */
+@Composable
+fun PermissionsScreen(navController: NavController) {
+ OnboardingStep1Screen(
+ onNext = { navController.navigate("onboarding/step2") }
+ )
+}
diff --git a/app/src/main/java/sh/lajo/buddy/SettingsScreen.kt b/app/src/main/java/sh/lajo/buddy/SettingsScreen.kt
new file mode 100644
index 0000000..ab3d285
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/SettingsScreen.kt
@@ -0,0 +1,17 @@
+package sh.lajo.buddy
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+
+@Composable
+fun SettingsScreen() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.settings_screen_title))
+ }
+}
+
diff --git a/app/src/main/java/sh/lajo/buddy/WebSocketService.kt b/app/src/main/java/sh/lajo/buddy/WebSocketService.kt
new file mode 100644
index 0000000..100f019
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/WebSocketService.kt
@@ -0,0 +1,387 @@
+package sh.lajo.buddy
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Intent
+import android.os.Build
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.provider.Settings
+
+import androidx.core.app.NotificationCompat
+
+import android.util.Log
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.put
+
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+
+
+class WebSocketService : Service() {
+
+ companion object {
+ const val ACTION_SEND_NOTIFICATION = "sh.lajo.buddy.action.SEND_NOTIFICATION"
+ const val EXTRA_NOTIFICATION_TITLE = "sh.lajo.buddy.extra.NOTIFICATION_TITLE"
+ const val EXTRA_NOTIFICATION_TEXT = "sh.lajo.buddy.extra.NOTIFICATION_TEXT"
+ const val EXTRA_NOTIFICATION_PACKAGE = "sh.lajo.buddy.extra.NOTIFICATION_PACKAGE"
+ const val ACTION_SEND_CONTACT = "sh.lajo.buddy.action.SEND_CONTACT"
+ const val EXTRA_CONTACT_NAME = "sh.lajo.buddy.extra.CONTACT_NAME"
+ const val EXTRA_CONTACT_PHONES = "sh.lajo.buddy.extra.CONTACT_PHONES"
+ const val EXTRA_CONTACT_EMAILS = "sh.lajo.buddy.extra.CONTACT_EMAILS"
+ const val ACTION_ACCESSIBILITY_MESSAGE = "sh.lajo.buddy.action.ACCESSIBILITY_MESSAGE"
+ const val EXTRA_ACCESSIBILITY_APP = "sh.lajo.buddy.extra.ACCESSIBILITY_APP"
+ const val EXTRA_ACCESSIBILITY_SENDER = "sh.lajo.buddy.extra.ACCESSIBILITY_SENDER"
+ const val EXTRA_ACCESSIBILITY_MESSAGE_TEXT = "sh.lajo.buddy.extra.ACCESSIBILITY_MESSAGE_TEXT"
+ const val EXTRA_ACCESSIBILITY_TIMESTAMP = "sh.lajo.buddy.extra.ACCESSIBILITY_TIMESTAMP"
+ const val ACTION_CIRCUMVENTION_EVENT = "sh.lajo.buddy.action.CIRCUMVENTION_EVENT"
+ const val EXTRA_CIRCUMVENTION_PACKAGE = "sh.lajo.buddy.extra.CIRCUMVENTION_PACKAGE"
+ const val EXTRA_CIRCUMVENTION_CLASS = "sh.lajo.buddy.extra.CIRCUMVENTION_CLASS"
+ }
+
+ private lateinit var webSocket: WebSocket
+ private val client = OkHttpClient()
+
+ private var isConnected: Boolean = false
+ private var isConnecting: Boolean = false
+ private val pendingMessages = mutableListOf<String>()
+
+ private var contactsObserver: ContactsObserver? = null
+
+ var token: String = ""
+ private val configFetchHandler = Handler(Looper.getMainLooper())
+ private val configFetchRunnable = object : Runnable {
+ override fun run() {
+ if (ConfigManager.shouldFetchConfig(this@WebSocketService)) {
+ ConfigManager.fetchConfig(this@WebSocketService)
+ }
+ // Check again in 5 minutes
+ configFetchHandler.postDelayed(this, 5 * 60 * 1000L)
+ }
+ }
+
+ private val statusPingHandler = Handler(Looper.getMainLooper())
+ private val statusPingRunnable = object : Runnable {
+ override fun run() {
+ sendStatusPing()
+
+ statusPingHandler.postDelayed(this, 10 * 1000L)
+ }
+ }
+
+ private fun startConfigFetchTimer() {
+ configFetchHandler.postDelayed(configFetchRunnable, 5 * 60 * 1000L)
+ }
+
+ private fun startStatusPingTimer() {
+ statusPingHandler.postDelayed(statusPingRunnable, 10 * 1000L)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ startForeground(1, createNotification())
+
+ if (!isConnected && !isConnecting) {
+ connect()
+ }
+
+ if (intent?.action == ACTION_SEND_NOTIFICATION) {
+ extractAndForwardNotification(intent)
+ } else if (intent?.action == ACTION_SEND_CONTACT) {
+ extractAndForwardContact(intent)
+ } else if (intent?.action == ACTION_ACCESSIBILITY_MESSAGE) {
+ extractAndForwardAccessibilityMessage(intent)
+ } else if (intent?.action == ACTION_CIRCUMVENTION_EVENT) {
+ extractAndForwardCircumventionEvent(intent)
+ }
+
+ return START_STICKY
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
+ token = prefs.getString("auth_token", "").toString()
+
+ // Fetch config on service start if needed
+ if (ConfigManager.shouldFetchConfig(this)) {
+ ConfigManager.fetchConfig(this)
+ }
+
+ // Set up periodic config fetching
+ startConfigFetchTimer()
+ startStatusPingTimer()
+
+ // Initialize and register ContactsObserver
+ contactsObserver = ContactsObserver(this, contentResolver)
+ contactsObserver?.register()
+ }
+
+ private fun createNotification(): Notification {
+ val channelId = "buddy_ws_channel"
+ val channelName = getString(R.string.ws_notification_channel_name)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ channelId,
+ channelName,
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+
+ return NotificationCompat.Builder(this, channelId)
+ .setContentTitle(getString(R.string.ws_notification_title))
+ .setContentText(getString(R.string.ws_notification_text))
+ .setSmallIcon(android.R.drawable.stat_notify_sync)
+ .setOngoing(true)
+ .build()
+ }
+
+ private fun connect() {
+ isConnecting = true
+
+ val request = Request.Builder()
+ .url(ApiConfig.WS_BASE_URL)
+ .build()
+
+ webSocket = client.newWebSocket(request, object : WebSocketListener() {
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ isConnecting = false
+ Log.d("WebSocketService", "WebSocket opened, sending token…")
+
+ val msg = buildJsonObject {
+ put("type", "token")
+ put("token", token)
+ }
+
+ val text = Json.encodeToString(JsonObject.serializer(), msg)
+ webSocket.send(text)
+ }
+
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ handleMessage(text)
+ }
+
+ override fun onFailure(
+ webSocket: WebSocket,
+ t: Throwable,
+ response: Response?
+ ) {
+ Log.e("WebSocketService", "WebSocket failure: ${t.message ?: ""}")
+ isConnecting = false
+ isConnected = false
+ reconnect()
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ Log.d("WebSocketService", "WebSocket closed: $reason")
+ isConnecting = false
+ isConnected = false
+ reconnect()
+ }
+ })
+ }
+
+ private fun extractAndForwardNotification(intent: Intent) {
+ val title = intent.getStringExtra(EXTRA_NOTIFICATION_TITLE) ?: ""
+ val text = intent.getStringExtra(EXTRA_NOTIFICATION_TEXT) ?: ""
+ val packageName = intent.getStringExtra(EXTRA_NOTIFICATION_PACKAGE) ?: return
+
+ sendNotificationPayload(title, text, packageName)
+ }
+
+ private fun extractAndForwardContact(intent: Intent) {
+ val name = intent.getStringExtra(EXTRA_CONTACT_NAME) ?: return
+ val phones = intent.getStringArrayExtra(EXTRA_CONTACT_PHONES) ?: emptyArray()
+ val emails = intent.getStringArrayExtra(EXTRA_CONTACT_EMAILS) ?: emptyArray()
+
+ sendContactPayload(name, phones.toList(), emails.toList())
+ }
+
+ private fun extractAndForwardAccessibilityMessage(intent: Intent) {
+ val app = intent.getStringExtra(EXTRA_ACCESSIBILITY_APP) ?: return
+ val sender = intent.getStringExtra(EXTRA_ACCESSIBILITY_SENDER) ?: ""
+ val messageText = intent.getStringExtra(EXTRA_ACCESSIBILITY_MESSAGE_TEXT) ?: return
+ val timestamp = intent.getLongExtra(EXTRA_ACCESSIBILITY_TIMESTAMP, System.currentTimeMillis())
+
+ sendAccessibilityMessagePayload(app, sender, messageText, timestamp)
+ }
+
+ private fun sendAccessibilityMessagePayload(app: String, sender: String, messageText: String, timestamp: Long) {
+ // Check if Buddy is disabled in config
+ val config = ConfigManager.getConfig(this)
+ if (config.disableBuddy) {
+ Log.d("WebSocketService", "Buddy is disabled, skipping accessibility message")
+ return
+ }
+
+ val payload = buildJsonObject {
+ put("type", "accessibility_message_detected")
+ put("app", app)
+ put("sender", sender)
+ put("message", messageText)
+ put("timestamp", timestamp)
+ }
+
+ Log.d("WebSocketService", "Sending accessibility message payload: ${payload.toString()}")
+
+ val json = Json.encodeToString(JsonObject.serializer(), payload)
+ sendOrQueue(json)
+ }
+
+ private fun sendNotificationPayload(title: String, text: String, packageName: String) {
+ // Check if Buddy is disabled in config
+ val config = ConfigManager.getConfig(this)
+ if (config.disableBuddy) {
+ Log.d("WebSocketService", "Buddy is disabled, skipping notification")
+ return
+ }
+
+ val payload = buildJsonObject {
+ put("type", "notification")
+ put("title", title)
+ put("message", text)
+ put("packageName", packageName)
+ }
+
+ Log.d("WebSocketService", "Sending notification payload: ${payload.toString()}")
+
+ val json = Json.encodeToString(JsonObject.serializer(), payload)
+ sendOrQueue(json)
+ }
+
+ private fun sendContactPayload(name: String, phoneNumbers: List<String>, emails: List<String>) {
+ // Check if Buddy is disabled in config
+ val config = ConfigManager.getConfig(this)
+ if (config.disableBuddy) {
+ Log.d("WebSocketService", "Buddy is disabled, skipping contact event")
+ return
+ }
+
+ val payload = buildJsonObject {
+ put("type", "contact_added")
+ put("name", name)
+ put("phoneNumbers", phoneNumbers.joinToString(", "))
+ put("emails", emails.joinToString(", "))
+ }
+
+ Log.d("WebSocketService", "Sending contact added payload: ${payload.toString()}")
+
+ val json = Json.encodeToString(JsonObject.serializer(), payload)
+ sendOrQueue(json)
+ }
+
+ private fun sendOrQueue(message: String) {
+ if (::webSocket.isInitialized && isConnected) {
+ Log.d("WebSocketService", "Sending immediately: $message")
+ webSocket.send(message)
+ } else {
+ Log.d("WebSocketService", "Queuing message (connected=$isConnected): $message")
+ pendingMessages.add(message)
+ }
+ }
+
+ private fun flushPendingMessages() {
+ if (!::webSocket.isInitialized || !isConnected) return
+ Log.d("WebSocketService", "Flushing ${pendingMessages.size} pending messages")
+ val iterator = pendingMessages.iterator()
+ while (iterator.hasNext()) {
+ val msg = iterator.next()
+ Log.d("WebSocketService", "Sending queued: $msg")
+ webSocket.send(msg)
+ iterator.remove()
+ }
+ }
+
+ private fun handleMessage(message: String) {
+ Log.d("WebSocketService", "Received message: $message")
+
+ try {
+ val json = Json.parseToJsonElement(message).jsonObject
+ val success = json["success"]?.jsonPrimitive?.content?.toBoolean() ?: false
+ val type = json["type"]?.jsonPrimitive?.content
+
+ if (success && type == "token") {
+ Log.d("WebSocketService", "Authentication confirmed, ready to send notifications")
+ isConnected = true
+ flushPendingMessages()
+ } else if (!success) {
+ val reason = json["reason"]?.jsonPrimitive?.content ?: "Unknown error"
+ Log.e("WebSocketService", "Server error: $reason")
+ }
+ } catch (e: Exception) {
+ Log.e("WebSocketService", "Failed to parse message: ${e.message ?: ""}")
+ }
+ }
+
+ private fun reconnect() {
+ Handler(Looper.getMainLooper()).postDelayed({
+ connect()
+ }, 3000)
+ }
+
+ private fun sendStatusPing() {
+ val adbEnabled = Settings.Secure.getInt(this.contentResolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) == 1
+
+ val payload = buildJsonObject {
+ put("type", "status_ping")
+ put("timestamp", System.currentTimeMillis())
+ put("dev_enabled", adbEnabled)
+ }
+
+ val json = Json.encodeToString(JsonObject.serializer(), payload)
+ sendOrQueue(json)
+ }
+
+ private fun extractAndForwardCircumventionEvent(intent: Intent) {
+ val packageName = intent.getStringExtra(EXTRA_CIRCUMVENTION_PACKAGE) ?: return
+ val className = intent.getStringExtra(EXTRA_CIRCUMVENTION_CLASS) ?: return
+
+ sendCircumventionPayload(packageName, className)
+ }
+
+ private fun sendCircumventionPayload(packageName: String, className: String) {
+ // Check if Buddy is disabled in config
+ val config = ConfigManager.getConfig(this)
+ if (config.disableBuddy) {
+ Log.d("WebSocketService", "Buddy is disabled, skipping circumvention event")
+ return
+ }
+
+ val payload = buildJsonObject {
+ put("type", "circumvention_event")
+ put("packageName", packageName)
+ put("className", className)
+ put("timestamp", System.currentTimeMillis())
+ }
+
+ Log.d("WebSocketService", "Sending circumvention event payload: ${payload.toString()}")
+
+ val json = Json.encodeToString(JsonObject.serializer(), payload)
+ sendOrQueue(json)
+ }
+
+ override fun onDestroy() {
+ configFetchHandler.removeCallbacks(configFetchRunnable)
+ statusPingHandler.removeCallbacks(statusPingRunnable)
+ contactsObserver?.unregister()
+ if (::webSocket.isInitialized) {
+ webSocket.close(1000, "Service stopped")
+ }
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+}
diff --git a/app/src/main/java/sh/lajo/buddy/ui/theme/Theme.kt b/app/src/main/java/sh/lajo/buddy/ui/theme/Theme.kt
new file mode 100644
index 0000000..71f61b6
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/ui/theme/Theme.kt
@@ -0,0 +1,66 @@
+package sh.lajo.buddy.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.graphics.Color
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Color(0xFFF42E2E),
+ onPrimary = Color(0xFFF3F3F3),
+
+ primaryContainer = Color(0xFFF42E2E),
+ onPrimaryContainer = Color(0xFFF3F3F3),
+
+ secondary = Color(0xFFF42E2E),
+ onSecondary = Color(0xFFF3F3F3),
+
+ secondaryContainer = Color(0xFFF42E2E),
+ onSecondaryContainer = Color(0xFFF3F3F3),
+
+ background = Color(0xFF020202),
+ onBackground = Color(0xFFF3F3F3),
+
+ surface = Color(0xFF111112),
+ onSurface = Color(0xFFF3F3F3),
+
+ surfaceVariant = Color(0xFF1A1A1B),
+ onSurfaceVariant = Color(0xFFF3F3F3),
+
+ surfaceContainer = Color(0xFF111112),
+ surfaceContainerLow = Color(0xFF0A0A0A),
+
+ outline = Color(0xFF3A3A3A),
+)
+
+
+
+private val LightColorScheme = lightColorScheme(
+ primary = Color(0xFFF42E2E), // Red foreground (same as dark for brand consistency)
+ onPrimary = Color(0xFFFFFFFF), // Text/icons on primary
+ background = Color(0xFFF3F3F3), // Light background
+ onBackground = Color(0xFF020202), // Text/icons on background
+ surface = Color(0xFFFFFFFF), // Cards, sheets, etc.
+ onSurface = Color(0xFF020202), // Text/icons on surface
+ tertiary = Color(0xFF1A1A1B), // Accent elements
+)
+
+@Composable
+fun BuddyTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = false,
+ content: @Composable () -> Unit,
+) {
+ val context = LocalContext.current
+
+ val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/app/src/main/java/sh/lajo/buddy/ui/theme/Type.kt b/app/src/main/java/sh/lajo/buddy/ui/theme/Type.kt
new file mode 100644
index 0000000..743e63c
--- /dev/null
+++ b/app/src/main/java/sh/lajo/buddy/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package sh.lajo.buddy.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+) \ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path
+ android:fillColor="#3DDC84"
+ android:pathData="M0,0h108v108h-108z" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M9,0L9,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,0L19,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M29,0L29,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M39,0L39,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M49,0L49,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M59,0L59,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M69,0L69,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M79,0L79,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M89,0L89,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M99,0L99,108"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,9L108,9"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,19L108,19"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,29L108,29"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,39L108,39"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,49L108,49"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,59L108,59"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,69L108,69"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,79L108,79"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,89L108,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M0,99L108,99"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,29L89,29"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,39L89,39"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,49L89,49"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,59L89,59"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,69L89,69"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M19,79L89,79"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M29,19L29,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M39,19L39,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M49,19L49,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M59,19L59,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M69,19L69,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+ <path
+ android:fillColor="#00000000"
+ android:pathData="M79,19L79,89"
+ android:strokeWidth="0.8"
+ android:strokeColor="#33FFFFFF" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:aapt="http://schemas.android.com/aapt"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
+ <aapt:attr name="android:fillColor">
+ <gradient
+ android:endX="85.84757"
+ android:endY="92.4963"
+ android:startX="42.9492"
+ android:startY="49.59793"
+ android:type="linear">
+ <item
+ android:color="#44000000"
+ android:offset="0.0" />
+ <item
+ android:color="#00000000"
+ android:offset="1.0" />
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
+ android:strokeWidth="1"
+ android:strokeColor="#00000000" />
+</vector> \ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..345888d
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@mipmap/ic_launcher_background"/>
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+ <monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..f1da635
--- /dev/null
+++ b/app/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..12ca48a
--- /dev/null
+++ b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Binary files differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..4eb8bba
--- /dev/null
+++ b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..4eb8bba
--- /dev/null
+++ b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Binary files differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..1b4cefd
--- /dev/null
+++ b/app/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..3f4e077
--- /dev/null
+++ b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Binary files differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..467cc9e
--- /dev/null
+++ b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..467cc9e
--- /dev/null
+++ b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..b4645fd
--- /dev/null
+++ b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..d496eae
--- /dev/null
+++ b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..c70fd9e
--- /dev/null
+++ b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..c70fd9e
--- /dev/null
+++ b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..0c84d1c
--- /dev/null
+++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..4d1ed7c
--- /dev/null
+++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..29172dc
--- /dev/null
+++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..29172dc
--- /dev/null
+++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..66c8f86
--- /dev/null
+++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..9e29d2a
--- /dev/null
+++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6a54dc9
--- /dev/null
+++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Binary files differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..6a54dc9
--- /dev/null
+++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
Binary files differ
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000..ef4b8d8
--- /dev/null
+++ b/app/src/main/res/values-hr/strings.xml
@@ -0,0 +1,92 @@
+<resources>
+ <string name="app_name">Buddy</string>
+
+ <!-- Accessibility Service -->
+ <string name="accessibility_service_description">Buddy prati poruke u WhatsApp-u, Signalu i SimpleX-u kako bi te zaštitio. Ova usluga čita sadržaj poruka na zaslonu za otkrivanje i prijavu aktivnosti.</string>
+ <string name="accessibility_unknown_sender">Nepoznato</string>
+
+ <!-- App Names -->
+ <string name="app_name_whatsapp">WhatsApp</string>
+ <string name="app_name_signal">Signal</string>
+ <string name="app_name_simplex">SimpleX</string>
+
+ <!-- WebSocket Service Notification -->
+ <string name="ws_notification_title">Buddy te čuva 🐶</string>
+ <string name="ws_notification_text">Sigurna veza aktivna</string>
+ <string name="ws_notification_channel_name">Buddy pozadinska veza</string>
+
+ <!-- Home Screen -->
+ <string name="home_status_connected">Povezano</string>
+ <string name="home_status_running">Buddy radi</string>
+ <string name="home_icon_description">Povezano</string>
+
+ <!-- Main Screen -->
+ <string name="main_screen_placeholder">Glavni zaslon</string>
+
+ <!-- Settings Screen -->
+ <string name="settings_screen_title">Postavke</string>
+
+ <!-- Onboarding Step 1 -->
+ <string name="onboarding_step1_title">Dobrodošli u Buddy</string>
+ <string name="onboarding_step1_body">Ova aplikacija mora biti postavljena na telefonu djeteta</string>
+ <string name="onboarding_next">Dalje</string>
+ <string name="onboarding_back">Natrag</string>
+
+ <!-- Onboarding Step 2 - Notifications -->
+ <string name="onboarding_step2_title">Obavijesti</string>
+ <string name="onboarding_step2_body">Buddy mora moći čitati obavijesti kako bi spriječio tvoje dijete da radi loše stvari na internetu!</string>
+ <string name="onboarding_notification_permission_status">Dopuštenje za obavijesti: %s</string>
+ <string name="onboarding_notification_listener_status">Pristup obavijestima (slušatelj): %s</string>
+ <string name="onboarding_permission_granted">Odobreno</string>
+ <string name="onboarding_permission_not_granted">Nije odobreno</string>
+ <string name="onboarding_permission_enabled">Omogućeno</string>
+ <string name="onboarding_permission_not_enabled">Nije omogućeno</string>
+ <string name="onboarding_grant_notification_permission">Odobri dopuštenje za obavijesti</string>
+ <string name="onboarding_enable_notification_access">Omogući pristup obavijestima</string>
+ <string name="onboarding_complete_steps">Završi oba koraka gore da nastaviš.</string>
+
+ <!-- Onboarding Step 3 - Accessibility -->
+ <string name="onboarding_step3_title">Pristupačnost</string>
+ <string name="onboarding_step3_body">Buddy treba pristup pristupačnosti kako bi pomogao zaštititi tvoje dijete od štetnog sadržaja</string>
+ <string name="onboarding_accessibility_service_status">Usluga pristupačnosti: %s</string>
+ <string name="onboarding_enable_accessibility_access">Omogući pristup pristupačnosti</string>
+ <string name="onboarding_enable_accessibility_to_continue">Omogući Buddy uslugu pristupačnosti da nastaviš.</string>
+
+ <!-- Onboarding Step 4 - Background -->
+ <string name="onboarding_step4_title">Pozadinski rad</string>
+ <string name="onboarding_step4_body">Buddy mora raditi u pozadini</string>
+ <string name="onboarding_battery_optimization_disabled">Onemogućeno za Buddy</string>
+ <string name="onboarding_battery_optimization_enabled">Omogućeno (može zaustaviti Buddy)</string>
+ <string name="onboarding_battery_optimization_status">Optimizacija baterije: %s</string>
+ <string name="onboarding_allow_running_in_background">Dozvoli rad u pozadini</string>
+ <string name="onboarding_open_battery_optimization_settings">Otvori postavke optimizacije baterije</string>
+ <string name="onboarding_disable_battery_optimization_to_continue">Onemogući optimizaciju baterije za Buddy da završiš postavljanje.</string>
+
+ <!-- Onboarding Step 5 - Contacts -->
+ <string name="onboarding_step5_title">Kontakti</string>
+ <string name="onboarding_step5_body">Buddy treba pristup kontaktima kako bi pomogao zaštititi tvoje dijete</string>
+ <string name="onboarding_contacts_permission_status">Dopuštenje za kontakte: %s</string>
+ <string name="onboarding_grant_contacts_permission">Odobri dopuštenje za kontakte</string>
+ <string name="onboarding_grant_contacts_to_continue">Odobri dopuštenje za kontakte da nastaviš.</string>
+
+ <!-- Onboarding Step 6 - VPN -->
+ <string name="onboarding_step6_title">VPN</string>
+ <string name="onboarding_step6_body">Buddy koristi VPN za filtriranje i praćenje prometa. Molimo omogući ga prije prijave.</string>
+ <string name="onboarding_vpn_status_active">Aktivan</string>
+ <string name="onboarding_vpn_status_not_detected">Nije otkriven</string>
+ <string name="onboarding_vpn_status">VPN status: %s</string>
+ <string name="onboarding_open_vpn_settings">Otvori VPN postavke</string>
+ <string name="onboarding_i_enabled_vpn">Omogućio sam VPN</string>
+ <string name="onboarding_enable_vpn_to_continue">Omogući VPN (ili potvrdi da je omogućen) da nastaviš.</string>
+
+ <!-- Onboarding Step 7 - Account -->
+ <string name="onboarding_step7_title">Račun</string>
+ <string name="onboarding_step7_body">Koristi podatke svog roditeljskog računa da povežeš ovaj uređaj</string>
+ <string name="onboarding_login">Prijava</string>
+ <string name="onboarding_logging_in">Prijavljujem se…</string>
+ <string name="onboarding_email">E-mail</string>
+ <string name="onboarding_password">Lozinka</string>
+ <string name="onboarding_error">Greška: %s</string>
+ <string name="onboarding_linked_successfully">Uspješno povezano</string>
+</resources>
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="purple_200">#FFBB86FC</color>
+ <color name="purple_500">#FF6200EE</color>
+ <color name="purple_700">#FF3700B3</color>
+ <color name="teal_200">#FF03DAC5</color>
+ <color name="teal_700">#FF018786</color>
+ <color name="black">#FF000000</color>
+ <color name="white">#FFFFFFFF</color>
+</resources> \ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..14b013a
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,91 @@
+<resources>
+ <string name="app_name">Buddy</string>
+
+ <!-- Accessibility Service -->
+ <string name="accessibility_service_description">Buddy monitors messages in WhatsApp, Signal, and SimpleX to help keep you safe. This service reads message content on screen to detect and report activity.</string>
+ <string name="accessibility_unknown_sender">Unknown</string>
+
+ <!-- App Names -->
+ <string name="app_name_whatsapp">WhatsApp</string>
+ <string name="app_name_signal">Signal</string>
+ <string name="app_name_simplex">SimpleX</string>
+
+ <!-- WebSocket Service Notification -->
+ <string name="ws_notification_title">Buddy is watching out 🐶</string>
+ <string name="ws_notification_text">Secure connection active</string>
+ <string name="ws_notification_channel_name">Buddy Background Connection</string>
+
+ <!-- Home Screen -->
+ <string name="home_status_connected">Connected</string>
+ <string name="home_status_running">Buddy is running</string>
+ <string name="home_icon_description">Connected</string>
+
+ <!-- Main Screen -->
+ <string name="main_screen_placeholder">Main Screen</string>
+
+ <!-- Settings Screen -->
+ <string name="settings_screen_title">Settings Screen</string>
+
+ <!-- Onboarding Step 1 -->
+ <string name="onboarding_step1_title">Welcome to Buddy</string>
+ <string name="onboarding_step1_body">This app must be set up on the child\'s phone</string>
+ <string name="onboarding_next">Next</string>
+ <string name="onboarding_back">Back</string>
+
+ <!-- Onboarding Step 2 - Notifications -->
+ <string name="onboarding_step2_title">Notifications</string>
+ <string name="onboarding_step2_body">Buddy needs to be able to read notifications in order to prevent your child from doing bad stuff on the internet!</string>
+ <string name="onboarding_notification_permission_status">Notification permission: %s</string>
+ <string name="onboarding_notification_listener_status">Notification access (listener): %s</string>
+ <string name="onboarding_permission_granted">Granted</string>
+ <string name="onboarding_permission_not_granted">Not granted</string>
+ <string name="onboarding_permission_enabled">Enabled</string>
+ <string name="onboarding_permission_not_enabled">Not enabled</string>
+ <string name="onboarding_grant_notification_permission">Grant notification permission</string>
+ <string name="onboarding_enable_notification_access">Enable notification access</string>
+ <string name="onboarding_complete_steps">Complete both steps above to continue.</string>
+
+ <!-- Onboarding Step 3 - Accessibility -->
+ <string name="onboarding_step3_title">Accessibility</string>
+ <string name="onboarding_step3_body">Buddy needs accessibility access to help protect your child from harmful content</string>
+ <string name="onboarding_accessibility_service_status">Accessibility service: %s</string>
+ <string name="onboarding_enable_accessibility_access">Enable accessibility access</string>
+ <string name="onboarding_enable_accessibility_to_continue">Enable Buddy\'s accessibility service to continue.</string>
+
+ <!-- Onboarding Step 4 - Background -->
+ <string name="onboarding_step4_title">Background</string>
+ <string name="onboarding_step4_body">Buddy needs to run in the background</string>
+ <string name="onboarding_battery_optimization_disabled">Disabled for Buddy</string>
+ <string name="onboarding_battery_optimization_enabled">Enabled (may stop Buddy)</string>
+ <string name="onboarding_battery_optimization_status">Battery optimization: %s</string>
+ <string name="onboarding_allow_running_in_background">Allow running in background</string>
+ <string name="onboarding_open_battery_optimization_settings">Open battery optimization settings</string>
+ <string name="onboarding_disable_battery_optimization_to_continue">Disable battery optimization for Buddy to finish setup.</string>
+
+ <!-- Onboarding Step 5 - Contacts -->
+ <string name="onboarding_step5_title">Contacts</string>
+ <string name="onboarding_step5_body">Buddy needs access to contacts to help keep your child safe</string>
+ <string name="onboarding_contacts_permission_status">Contacts permission: %s</string>
+ <string name="onboarding_grant_contacts_permission">Grant contacts permission</string>
+ <string name="onboarding_grant_contacts_to_continue">Grant contacts permission to continue.</string>
+
+ <!-- Onboarding Step 6 - VPN -->
+ <string name="onboarding_step6_title">VPN</string>
+ <string name="onboarding_step6_body">Buddy uses a VPN to help filter and monitor traffic. Please enable it before logging in.</string>
+ <string name="onboarding_vpn_status_active">Active</string>
+ <string name="onboarding_vpn_status_not_detected">Not detected</string>
+ <string name="onboarding_vpn_status">VPN status: %s</string>
+ <string name="onboarding_open_vpn_settings">Open VPN settings</string>
+ <string name="onboarding_i_enabled_vpn">I enabled the VPN</string>
+ <string name="onboarding_enable_vpn_to_continue">Enable VPN (or confirm it\'s enabled) to continue.</string>
+
+ <!-- Onboarding Step 7 - Account -->
+ <string name="onboarding_step7_title">Account</string>
+ <string name="onboarding_step7_body">Use your parent account details to link this device</string>
+ <string name="onboarding_login">Login</string>
+ <string name="onboarding_logging_in">Logging in…</string>
+ <string name="onboarding_email">Email</string>
+ <string name="onboarding_password">Password</string>
+ <string name="onboarding_error">Error: %s</string>
+ <string name="onboarding_linked_successfully">Linked successfully</string>
+</resources> \ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..54de2ae
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <style name="Theme.Buddy" parent="android:Theme.Material.Light.NoActionBar" />
+</resources> \ No newline at end of file
diff --git a/app/src/main/res/xml/accessibility_config.xml b/app/src/main/res/xml/accessibility_config.xml
new file mode 100644
index 0000000..83babd6
--- /dev/null
+++ b/app/src/main/res/xml/accessibility_config.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<accessibility-service
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ tools:targetApi="36"
+ android:accessibilityEventTypes="typeWindowContentChanged|typeViewTextChanged|typeWindowStateChanged|typeViewScrolled"
+ android:packageNames="com.whatsapp,org.thoughtcrime.securesms,chat.simplex.app"
+ android:accessibilityFeedbackType="feedbackGeneric"
+ android:notificationTimeout="100"
+ android:canRetrieveWindowContent="true"
+ android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews"
+ android:description="@string/accessibility_service_description" />
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Sample backup rules file; uncomment and customize as necessary.
+ See https://developer.android.com/guide/topics/data/autobackup
+ for details.
+ Note: This file is ignored for devices older than API 31
+ See https://developer.android.com/about/versions/12/backup-restore
+-->
+<full-backup-content>
+ <!--
+ <include domain="sharedpref" path="."/>
+ <exclude domain="sharedpref" path="device.xml"/>
+-->
+</full-backup-content> \ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Sample data extraction rules file; uncomment and customize as necessary.
+ See https://developer.android.com/about/versions/12/backup-restore#xml-changes
+ for details.
+-->
+<data-extraction-rules>
+ <cloud-backup>
+ <!-- TODO: Use <include> and <exclude> to control what is backed up.
+ <include .../>
+ <exclude .../>
+ -->
+ </cloud-backup>
+ <!--
+ <device-transfer>
+ <include .../>
+ <exclude .../>
+ </device-transfer>
+ -->
+</data-extraction-rules> \ No newline at end of file