diff options
Diffstat (limited to 'app/src/main/java/sh/lajo/buddy/WebSocketService.kt')
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/WebSocketService.kt | 387 |
1 files changed, 387 insertions, 0 deletions
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 +} |