From adb6a4fd9ec3a23c04d5e4c2ce799448237915c4 Mon Sep 17 00:00:00 2001 From: JustZvan Date: Fri, 6 Feb 2026 13:38:36 +0100 Subject: feat: initial commit --- app/src/main/AndroidManifest.xml | 79 +++ app/src/main/java/sh/lajo/buddy/ApiConfig.kt | 6 + app/src/main/java/sh/lajo/buddy/AppNavHost.kt | 96 +++ app/src/main/java/sh/lajo/buddy/BootReceiver.kt | 23 + .../sh/lajo/buddy/BuddyAccessibilityService.kt | 334 ++++++++++ .../java/sh/lajo/buddy/BuddyNotificationService.kt | 65 ++ app/src/main/java/sh/lajo/buddy/BuddyVPNService.kt | 334 ++++++++++ app/src/main/java/sh/lajo/buddy/ConfigManager.kt | 92 +++ .../main/java/sh/lajo/buddy/ContactsObserver.kt | 186 ++++++ app/src/main/java/sh/lajo/buddy/Destination.kt | 26 + app/src/main/java/sh/lajo/buddy/DeviceConfig.kt | 17 + app/src/main/java/sh/lajo/buddy/HomeScreen.kt | 67 ++ app/src/main/java/sh/lajo/buddy/HttpClient.kt | 14 + app/src/main/java/sh/lajo/buddy/MainActivity.kt | 122 ++++ app/src/main/java/sh/lajo/buddy/MainScreen.kt | 16 + .../main/java/sh/lajo/buddy/OnboardingScreens.kt | 693 +++++++++++++++++++++ .../main/java/sh/lajo/buddy/PermissionsScreen.kt | 15 + app/src/main/java/sh/lajo/buddy/SettingsScreen.kt | 17 + .../main/java/sh/lajo/buddy/WebSocketService.kt | 387 ++++++++++++ app/src/main/java/sh/lajo/buddy/ui/theme/Theme.kt | 66 ++ app/src/main/java/sh/lajo/buddy/ui/theme/Type.kt | 34 + .../main/res/drawable/ic_launcher_background.xml | 170 +++++ .../main/res/drawable/ic_launcher_foreground.xml | 30 + app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 6237 bytes .../res/mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 1202 bytes .../res/mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 3293 bytes .../res/mipmap-hdpi/ic_launcher_monochrome.png | Bin 0 -> 3293 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3420 bytes .../res/mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 697 bytes .../res/mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1785 bytes .../res/mipmap-mdpi/ic_launcher_monochrome.png | Bin 0 -> 1785 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8884 bytes .../res/mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 1804 bytes .../res/mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 5196 bytes .../res/mipmap-xhdpi/ic_launcher_monochrome.png | Bin 0 -> 5196 bytes app/src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 16581 bytes .../res/mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 3418 bytes .../res/mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 9888 bytes .../res/mipmap-xxhdpi/ic_launcher_monochrome.png | Bin 0 -> 9888 bytes app/src/main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 25483 bytes .../res/mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 5425 bytes .../res/mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 15621 bytes .../res/mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin 0 -> 15621 bytes app/src/main/res/values-hr/strings.xml | 92 +++ app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 91 +++ app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/accessibility_config.xml | 12 + app/src/main/res/xml/backup_rules.xml | 13 + app/src/main/res/xml/data_extraction_rules.xml | 19 + 51 files changed, 3137 insertions(+) create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/sh/lajo/buddy/ApiConfig.kt create mode 100644 app/src/main/java/sh/lajo/buddy/AppNavHost.kt create mode 100644 app/src/main/java/sh/lajo/buddy/BootReceiver.kt create mode 100644 app/src/main/java/sh/lajo/buddy/BuddyAccessibilityService.kt create mode 100644 app/src/main/java/sh/lajo/buddy/BuddyNotificationService.kt create mode 100644 app/src/main/java/sh/lajo/buddy/BuddyVPNService.kt create mode 100644 app/src/main/java/sh/lajo/buddy/ConfigManager.kt create mode 100644 app/src/main/java/sh/lajo/buddy/ContactsObserver.kt create mode 100644 app/src/main/java/sh/lajo/buddy/Destination.kt create mode 100644 app/src/main/java/sh/lajo/buddy/DeviceConfig.kt create mode 100644 app/src/main/java/sh/lajo/buddy/HomeScreen.kt create mode 100644 app/src/main/java/sh/lajo/buddy/HttpClient.kt create mode 100644 app/src/main/java/sh/lajo/buddy/MainActivity.kt create mode 100644 app/src/main/java/sh/lajo/buddy/MainScreen.kt create mode 100644 app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt create mode 100644 app/src/main/java/sh/lajo/buddy/PermissionsScreen.kt create mode 100644 app/src/main/java/sh/lajo/buddy/SettingsScreen.kt create mode 100644 app/src/main/java/sh/lajo/buddy/WebSocketService.kt create mode 100644 app/src/main/java/sh/lajo/buddy/ui/theme/Theme.kt create mode 100644 app/src/main/java/sh/lajo/buddy/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/values-hr/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/accessibility_config.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml (limited to 'app/src/main') 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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(100, 0.75f, true) + private val MESSAGE_DEDUP_WINDOW_MS = 5000L // 5 seconds + + override fun onServiceConnected() { + super.onServiceConnected() + + val info = AccessibilityServiceInfo().apply { + eventTypes = + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED or + AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED or + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or + AccessibilityEvent.TYPE_VIEW_SCROLLED + + feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC + + packageNames = arrayOf( + PACKAGE_WHATSAPP, + PACKAGE_SIGNAL, + PACKAGE_SIMPLEX, + "com.miui.securitycore" + ) + + flags = + AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or + AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or + AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS + + notificationTimeout = 100 + } + + serviceInfo = info + Log.d(TAG, "Accessibility service connected") + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event == null) return + + val packageName = event.packageName?.toString() ?: return + val className = event.className?.toString() ?: return + + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && + packageName == "com.miui.securitycore" && + className.contains("PrivateSpaceMainActivity")) { + sendPrivateSpaceEvent() + return + } + + // Only process supported apps + if (packageName !in getAppNameMap().keys) return + + val rootNode = rootInActiveWindow ?: return + + try { + when (packageName) { + PACKAGE_WHATSAPP -> processWhatsAppMessages(rootNode, packageName) + PACKAGE_SIGNAL -> processSignalMessages(rootNode, packageName) + PACKAGE_SIMPLEX -> processSimpleXMessages(rootNode, packageName) + } + } catch (e: Exception) { + Log.e(TAG, "Error processing accessibility event: ${e.message ?: ""}") + } finally { + // Clean up old entries from dedup map + cleanupRecentMessages() + } + } + + override fun onInterrupt() { + Log.d(TAG, "Accessibility service interrupted") + } + + private fun getAppNameMap(): Map { + return mapOf( + PACKAGE_WHATSAPP to R.string.app_name_whatsapp, + PACKAGE_SIGNAL to R.string.app_name_signal, + PACKAGE_SIMPLEX to R.string.app_name_simplex + ) + } + + private fun processWhatsAppMessages(rootNode: AccessibilityNodeInfo, packageName: String) { + val messageNodes = mutableListOf() + + findNodesByViewIdContains(rootNode, "message", messageNodes) + findNodesByViewIdContains(rootNode, "conversation", messageNodes) + + val textNodes = mutableListOf() + collectTextNodes(rootNode, textNodes) + + for (node in textNodes) { + val text = node.text?.toString() ?: continue + if (text.isBlank() || text.length < 2) continue + + val sender = extractWhatsAppSender(node) + + sendMessageEvent(packageName, sender, text) + } + } + + private fun processSignalMessages(rootNode: AccessibilityNodeInfo, packageName: String) { + val textNodes = mutableListOf() + collectTextNodes(rootNode, textNodes) + + for (node in textNodes) { + val text = node.text?.toString() ?: continue + if (text.isBlank() || text.length < 2) continue + + val sender = extractSignalSender(node) + + sendMessageEvent(packageName, sender, text) + } + } + + private fun processSimpleXMessages(rootNode: AccessibilityNodeInfo, packageName: String) { + // SimpleX chat message extraction + val textNodes = mutableListOf() + collectTextNodes(rootNode, textNodes) + + for (node in textNodes) { + val text = node.text?.toString() ?: continue + if (text.isBlank() || text.length < 2) continue + + val sender = extractSimpleXSender(node) + + sendMessageEvent(packageName, sender, text) + } + } + + private fun extractWhatsAppSender(node: AccessibilityNodeInfo): String { + // Try to find sender name by traversing parent nodes + var parent = node.parent + var attempts = 0 + + while (parent != null && attempts < 10) { + for (i in 0 until parent.childCount) { + val sibling = parent.getChild(i) ?: continue + val viewId = sibling.viewIdResourceName ?: "" + + if (viewId.contains("name", ignoreCase = true) || + viewId.contains("contact", ignoreCase = true) || + viewId.contains("header", ignoreCase = true)) { + val senderText = sibling.text?.toString() + if (!senderText.isNullOrBlank()) { + sibling.recycle() + return senderText + } + } + sibling.recycle() + } + + val nextParent = parent.parent + parent.recycle() + parent = nextParent + attempts++ + } + + return "Unknown" + } + + private fun extractSignalSender(node: AccessibilityNodeInfo): String { + var parent = node.parent + var attempts = 0 + + while (parent != null && attempts < 10) { + for (i in 0 until parent.childCount) { + val sibling = parent.getChild(i) ?: continue + val viewId = sibling.viewIdResourceName ?: "" + val contentDesc = sibling.contentDescription?.toString() ?: "" + + if (viewId.contains("sender", ignoreCase = true) || + viewId.contains("name", ignoreCase = true) || + viewId.contains("group_member", ignoreCase = true) || + contentDesc.contains("from", ignoreCase = true)) { + val senderText = sibling.text?.toString() ?: contentDesc + if (senderText.isNotBlank()) { + sibling.recycle() + return senderText + } + } + sibling.recycle() + } + + val nextParent = parent.parent + parent.recycle() + parent = nextParent + attempts++ + } + + return "Unknown" + } + + private fun extractSimpleXSender(node: AccessibilityNodeInfo): String { + var parent = node.parent + var attempts = 0 + + while (parent != null && attempts < 10) { + for (i in 0 until parent.childCount) { + val sibling = parent.getChild(i) ?: continue + val viewId = sibling.viewIdResourceName ?: "" + + if (viewId.contains("sender", ignoreCase = true) || + viewId.contains("name", ignoreCase = true) || + viewId.contains("member", ignoreCase = true)) { + val senderText = sibling.text?.toString() + if (!senderText.isNullOrBlank()) { + sibling.recycle() + return senderText + } + } + sibling.recycle() + } + + val nextParent = parent.parent + parent.recycle() + parent = nextParent + attempts++ + } + + return "Unknown" + } + + private fun collectTextNodes(node: AccessibilityNodeInfo, output: MutableList) { + val text = node.text?.toString() + if (!text.isNullOrBlank()) { + output.add(node) + } + + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + collectTextNodes(child, output) + } + } + + private fun findNodesByViewIdContains( + node: AccessibilityNodeInfo, + pattern: String, + output: MutableList + ) { + val viewId = node.viewIdResourceName ?: "" + if (viewId.contains(pattern, ignoreCase = true)) { + output.add(node) + } + + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + findNodesByViewIdContains(child, pattern, output) + } + } + + private fun sendMessageEvent(packageName: String, sender: String, messageText: String) { + val dedupKey = "$packageName:$sender:${messageText.hashCode()}" + val now = System.currentTimeMillis() + + val lastProcessed = recentMessages[dedupKey] + if (lastProcessed != null && (now - lastProcessed) < MESSAGE_DEDUP_WINDOW_MS) { + return // Skip duplicate + } + + recentMessages[dedupKey] = now + + val appNameResId = getAppNameMap()[packageName] + val appName = if (appNameResId != null) getString(appNameResId) else packageName + + val messagePreview = messageText.take(50) + Log.d(TAG, "Message detected in $appName from $sender: $messagePreview...") + + val intent = Intent(this, WebSocketService::class.java).apply { + action = WebSocketService.ACTION_ACCESSIBILITY_MESSAGE + putExtra(WebSocketService.EXTRA_ACCESSIBILITY_APP, appName) + putExtra(WebSocketService.EXTRA_ACCESSIBILITY_SENDER, sender) + putExtra(WebSocketService.EXTRA_ACCESSIBILITY_MESSAGE_TEXT, messageText) + putExtra(WebSocketService.EXTRA_ACCESSIBILITY_TIMESTAMP, now) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + } + + private fun cleanupRecentMessages() { + val now = System.currentTimeMillis() + val iterator = recentMessages.entries.iterator() + + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > MESSAGE_DEDUP_WINDOW_MS * 2) { + iterator.remove() + } else { + break // Since LinkedHashMap maintains access order, newer entries follow + } + } + } + + private fun sendPrivateSpaceEvent() { + val intent = Intent(this, WebSocketService::class.java).apply { + action = WebSocketService.ACTION_CIRCUMVENTION_EVENT + putExtra(WebSocketService.EXTRA_CIRCUMVENTION_PACKAGE, "com.miui.securitycore") + putExtra(WebSocketService.EXTRA_CIRCUMVENTION_CLASS, "com.miui.securitycore.PrivateSpaceMainActivity") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + } +} 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() + 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>(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? { + 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? { + 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 { + val result = HashSet(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() + + 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(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 { + val phoneNumbers = mutableListOf() + 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 { + val emails = mutableListOf() + 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, emails: List) { + 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(null) } + var errorText by remember { mutableStateOf(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(bodyString) + }.getOrNull() + + val reason = parsed?.reason?.takeIf { it.isNotBlank() } + throw IllegalStateException(reason ?: "HTTP ${response.code}") + } + + bodyString + } + } + + val parsed = runCatching { + networkJson.decodeFromString(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() + + 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, emails: List) { + // 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + \ 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 @@ + + + + + + \ 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 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png 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 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png 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 @@ + + Buddy + + + 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. + Nepoznato + + + WhatsApp + Signal + SimpleX + + + Buddy te čuva 🐶 + Sigurna veza aktivna + Buddy pozadinska veza + + + Povezano + Buddy radi + Povezano + + + Glavni zaslon + + + Postavke + + + Dobrodošli u Buddy + Ova aplikacija mora biti postavljena na telefonu djeteta + Dalje + Natrag + + + Obavijesti + Buddy mora moći čitati obavijesti kako bi spriječio tvoje dijete da radi loše stvari na internetu! + Dopuštenje za obavijesti: %s + Pristup obavijestima (slušatelj): %s + Odobreno + Nije odobreno + Omogućeno + Nije omogućeno + Odobri dopuštenje za obavijesti + Omogući pristup obavijestima + Završi oba koraka gore da nastaviš. + + + Pristupačnost + Buddy treba pristup pristupačnosti kako bi pomogao zaštititi tvoje dijete od štetnog sadržaja + Usluga pristupačnosti: %s + Omogući pristup pristupačnosti + Omogući Buddy uslugu pristupačnosti da nastaviš. + + + Pozadinski rad + Buddy mora raditi u pozadini + Onemogućeno za Buddy + Omogućeno (može zaustaviti Buddy) + Optimizacija baterije: %s + Dozvoli rad u pozadini + Otvori postavke optimizacije baterije + Onemogući optimizaciju baterije za Buddy da završiš postavljanje. + + + Kontakti + Buddy treba pristup kontaktima kako bi pomogao zaštititi tvoje dijete + Dopuštenje za kontakte: %s + Odobri dopuštenje za kontakte + Odobri dopuštenje za kontakte da nastaviš. + + + VPN + Buddy koristi VPN za filtriranje i praćenje prometa. Molimo omogući ga prije prijave. + Aktivan + Nije otkriven + VPN status: %s + Otvori VPN postavke + Omogućio sam VPN + Omogući VPN (ili potvrdi da je omogućen) da nastaviš. + + + Račun + Koristi podatke svog roditeljskog računa da povežeš ovaj uređaj + Prijava + Prijavljujem se… + E-mail + Lozinka + Greška: %s + Uspješno povezano + + 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 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ 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 @@ + + Buddy + + + 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. + Unknown + + + WhatsApp + Signal + SimpleX + + + Buddy is watching out 🐶 + Secure connection active + Buddy Background Connection + + + Connected + Buddy is running + Connected + + + Main Screen + + + Settings Screen + + + Welcome to Buddy + This app must be set up on the child\'s phone + Next + Back + + + Notifications + Buddy needs to be able to read notifications in order to prevent your child from doing bad stuff on the internet! + Notification permission: %s + Notification access (listener): %s + Granted + Not granted + Enabled + Not enabled + Grant notification permission + Enable notification access + Complete both steps above to continue. + + + Accessibility + Buddy needs accessibility access to help protect your child from harmful content + Accessibility service: %s + Enable accessibility access + Enable Buddy\'s accessibility service to continue. + + + Background + Buddy needs to run in the background + Disabled for Buddy + Enabled (may stop Buddy) + Battery optimization: %s + Allow running in background + Open battery optimization settings + Disable battery optimization for Buddy to finish setup. + + + Contacts + Buddy needs access to contacts to help keep your child safe + Contacts permission: %s + Grant contacts permission + Grant contacts permission to continue. + + + VPN + Buddy uses a VPN to help filter and monitor traffic. Please enable it before logging in. + Active + Not detected + VPN status: %s + Open VPN settings + I enabled the VPN + Enable VPN (or confirm it\'s enabled) to continue. + + + Account + Use your parent account details to link this device + Login + Logging in… + Email + Password + Error: %s + Linked successfully + \ 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 @@ + + + +