summaryrefslogtreecommitdiff
path: root/app/src/main/java/sh/lajo/buddy/WebSocketService.kt
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/sh/lajo/buddy/WebSocketService.kt')
-rw-r--r--app/src/main/java/sh/lajo/buddy/WebSocketService.kt387
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
+}