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" const val ACTION_SEND_IMAGE = "sh.lajo.buddy.action.SEND_IMAGE" const val EXTRA_IMAGE_NAME = "sh.lajo.buddy.extra.IMAGE_NAME" const val ACTION_NSFW_IMAGE_DETECTED = "sh.lajo.buddy.action.NSFW_IMAGE_DETECTED" const val EXTRA_NSFW_IMAGE_NAME = "sh.lajo.buddy.extra.NSFW_IMAGE_NAME" const val EXTRA_NSFW_IMAGE_CONFIDENCE = "sh.lajo.buddy.extra.NSFW_IMAGE_CONFIDENCE" } private lateinit var webSocket: WebSocket private val client = OkHttpClient() private var isConnected: Boolean = false private var isConnecting: Boolean = false private val pendingMessages = mutableListOf() private var contactsObserver: ContactsObserver? = null private var imagesObserver: ImagesObserver? = 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) } else if (intent?.action == ACTION_SEND_IMAGE) { extractAndForwardImage(intent) } else if (intent?.action == ACTION_NSFW_IMAGE_DETECTED) { extractAndForwardNsfwImageDetected(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() // Initialize and register ImagesObserver imagesObserver = ImagesObserver(this, contentResolver) imagesObserver?.register() } private fun createNotification(): Notification { val channelId = "buddy_ws_channel" val channelName = getString(R.string.ws_notification_channel_name) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_LOW ) val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } return NotificationCompat.Builder(this, channelId) .setContentTitle(getString(R.string.ws_notification_title)) .setContentText(getString(R.string.ws_notification_text)) .setSmallIcon(android.R.drawable.stat_notify_sync) .setOngoing(true) .build() } private fun connect() { isConnecting = true val request = Request.Builder() .url(ApiConfig.WS_BASE_URL) .build() webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { isConnecting = false Log.d("WebSocketService", "WebSocket opened, sending token…") val msg = buildJsonObject { put("type", "token") put("token", token) } val text = Json.encodeToString(JsonObject.serializer(), msg) webSocket.send(text) } override fun onMessage(webSocket: WebSocket, text: String) { handleMessage(text) } override fun onFailure( webSocket: WebSocket, t: Throwable, response: Response? ) { Log.e("WebSocketService", "WebSocket failure: ${t.message ?: ""}") isConnecting = false isConnected = false reconnect() } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.d("WebSocketService", "WebSocket closed: $reason") isConnecting = false isConnected = false reconnect() } }) } private fun extractAndForwardNotification(intent: Intent) { val title = intent.getStringExtra(EXTRA_NOTIFICATION_TITLE) ?: "" val text = intent.getStringExtra(EXTRA_NOTIFICATION_TEXT) ?: "" val packageName = intent.getStringExtra(EXTRA_NOTIFICATION_PACKAGE) ?: return sendNotificationPayload(title, text, packageName) } private fun extractAndForwardContact(intent: Intent) { val name = intent.getStringExtra(EXTRA_CONTACT_NAME) ?: return val phones = intent.getStringArrayExtra(EXTRA_CONTACT_PHONES) ?: emptyArray() val emails = intent.getStringArrayExtra(EXTRA_CONTACT_EMAILS) ?: emptyArray() sendContactPayload(name, phones.toList(), emails.toList()) } private fun extractAndForwardAccessibilityMessage(intent: Intent) { val app = intent.getStringExtra(EXTRA_ACCESSIBILITY_APP) ?: return val sender = intent.getStringExtra(EXTRA_ACCESSIBILITY_SENDER) ?: "" val messageText = intent.getStringExtra(EXTRA_ACCESSIBILITY_MESSAGE_TEXT) ?: return val timestamp = intent.getLongExtra(EXTRA_ACCESSIBILITY_TIMESTAMP, System.currentTimeMillis()) sendAccessibilityMessagePayload(app, sender, messageText, timestamp) } private fun sendAccessibilityMessagePayload(app: String, sender: String, messageText: String, timestamp: Long) { // Check if Buddy is disabled in config val config = ConfigManager.getConfig(this) if (config.disableBuddy) { Log.d("WebSocketService", "Buddy is disabled, skipping accessibility message") return } val payload = buildJsonObject { put("type", "accessibility_message_detected") put("app", app) put("sender", sender) put("message", messageText) put("timestamp", timestamp) } Log.d("WebSocketService", "Sending accessibility message payload: ${payload.toString()}") val json = Json.encodeToString(JsonObject.serializer(), payload) sendOrQueue(json) } private fun sendNotificationPayload(title: String, text: String, packageName: String) { // Check if Buddy is disabled in config val config = ConfigManager.getConfig(this) if (config.disableBuddy) { Log.d("WebSocketService", "Buddy is disabled, skipping notification") return } val payload = buildJsonObject { put("type", "notification") put("title", title) put("message", text) put("packageName", packageName) } Log.d("WebSocketService", "Sending notification payload: ${payload.toString()}") val json = Json.encodeToString(JsonObject.serializer(), payload) sendOrQueue(json) } private fun sendContactPayload(name: String, phoneNumbers: List, emails: List) { // Check if Buddy is disabled in config val config = ConfigManager.getConfig(this) if (config.disableBuddy) { Log.d("WebSocketService", "Buddy is disabled, skipping contact event") return } val payload = buildJsonObject { put("type", "contact_added") put("name", name) put("phoneNumbers", phoneNumbers.joinToString(", ")) put("emails", emails.joinToString(", ")) } Log.d("WebSocketService", "Sending contact added payload: ${payload.toString()}") val json = Json.encodeToString(JsonObject.serializer(), payload) sendOrQueue(json) } private fun sendOrQueue(message: String) { if (::webSocket.isInitialized && isConnected) { Log.d("WebSocketService", "Sending immediately: $message") webSocket.send(message) } else { Log.d("WebSocketService", "Queuing message (connected=$isConnected): $message") pendingMessages.add(message) } } private fun flushPendingMessages() { if (!::webSocket.isInitialized || !isConnected) return Log.d("WebSocketService", "Flushing ${pendingMessages.size} pending messages") val iterator = pendingMessages.iterator() while (iterator.hasNext()) { val msg = iterator.next() Log.d("WebSocketService", "Sending queued: $msg") webSocket.send(msg) iterator.remove() } } private fun handleMessage(message: String) { Log.d("WebSocketService", "Received message: $message") try { val json = Json.parseToJsonElement(message).jsonObject val success = json["success"]?.jsonPrimitive?.content?.toBoolean() ?: false val type = json["type"]?.jsonPrimitive?.content if (success && type == "token") { Log.d("WebSocketService", "Authentication confirmed, ready to send notifications") isConnected = true flushPendingMessages() } else if (!success) { val reason = json["reason"]?.jsonPrimitive?.content ?: "Unknown error" Log.e("WebSocketService", "Server error: $reason") } } catch (e: Exception) { Log.e("WebSocketService", "Failed to parse message: ${e.message ?: ""}") } } private fun reconnect() { Handler(Looper.getMainLooper()).postDelayed({ connect() }, 3000) } private fun sendStatusPing() { val adbEnabled = Settings.Secure.getInt(this.contentResolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) == 1 val payload = buildJsonObject { put("type", "status_ping") put("timestamp", System.currentTimeMillis()) put("dev_enabled", adbEnabled) } val json = Json.encodeToString(JsonObject.serializer(), payload) sendOrQueue(json) } private fun extractAndForwardCircumventionEvent(intent: Intent) { val packageName = intent.getStringExtra(EXTRA_CIRCUMVENTION_PACKAGE) ?: return val className = intent.getStringExtra(EXTRA_CIRCUMVENTION_CLASS) ?: return sendCircumventionPayload(packageName, className) } private fun extractAndForwardImage(intent: Intent) { val name = intent.getStringExtra(EXTRA_IMAGE_NAME) ?: return sendImagePayload(name) } private fun extractAndForwardNsfwImageDetected(intent: Intent) { val name = intent.getStringExtra(EXTRA_NSFW_IMAGE_NAME) ?: return val confidence = intent.getFloatExtra(EXTRA_NSFW_IMAGE_CONFIDENCE, -1f) if (confidence < 0f) return sendNsfwImageDetectedPayload(name, confidence) } private fun sendImagePayload(name: String) { // Check if Buddy is disabled in config val config = ConfigManager.getConfig(this) if (config.disableBuddy) { Log.d("WebSocketService", "Buddy is disabled, skipping image event") return } val payload = buildJsonObject { put("type", "image_added") put("name", name) put("timestamp", System.currentTimeMillis()) } Log.d("WebSocketService", "Sending image added payload: ${payload.toString()}") val json = Json.encodeToString(JsonObject.serializer(), payload) sendOrQueue(json) } private fun sendNsfwImageDetectedPayload(name: String, confidence: Float) { val config = ConfigManager.getConfig(this) if (config.disableBuddy) { Log.d("WebSocketService", "Buddy is disabled, skipping NSFW image event") return } val payload = buildJsonObject { put("type", "nsfw_image_detected") put("name", name) put("confidence", confidence) put("timestamp", System.currentTimeMillis()) } Log.d("WebSocketService", "Sending NSFW image payload: ${payload.toString()}") val json = Json.encodeToString(JsonObject.serializer(), payload) sendOrQueue(json) } 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() imagesObserver?.unregister() if (::webSocket.isInitialized) { webSocket.close(1000, "Service stopped") } super.onDestroy() } override fun onBind(intent: Intent?): IBinder? = null }