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