From 70f7d41d9a5846a72df667a0c3ab314e94740370 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Tue, 7 Apr 2026 20:36:25 +1000 Subject: [PATCH] feat: implement background sync service for Matrix integration --- android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 17 ++ .../main/java/com/paarrot/app/BootReceiver.kt | 36 +++ .../java/com/paarrot/app/MainActivity.java | 9 +- .../java/com/paarrot/app/MatrixSyncService.kt | 238 ++++++++++++++++++ .../java/com/paarrot/app/SyncServicePlugin.kt | 94 +++++++ android/build.gradle | 6 +- .../app/pages/client/ClientNonUIFeatures.tsx | 31 +++ overlay/src/app/utils/backgroundSync.ts | 91 +++++++ 9 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/paarrot/app/BootReceiver.kt create mode 100644 android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt create mode 100644 android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt create mode 100644 overlay/src/app/utils/backgroundSync.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index ff594a9..ec5b5f1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' android { namespace = "com.paarrot.app" @@ -36,6 +37,7 @@ dependencies { implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation project(':capacitor-android') + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e11a684..dec3748 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,12 +33,29 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"> + + + + + + + + + + + diff --git a/android/app/src/main/java/com/paarrot/app/BootReceiver.kt b/android/app/src/main/java/com/paarrot/app/BootReceiver.kt new file mode 100644 index 0000000..3987271 --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/BootReceiver.kt @@ -0,0 +1,36 @@ +package com.paarrot.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build + +/** + * Restarts [MatrixSyncService] after the device boots, using credentials + * previously persisted by [SyncServicePlugin]. + */ +class BootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + + val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) + val homeserver = prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return + val token = prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return + val userId = prefs.getString(MatrixSyncService.EXTRA_USER_ID, null) ?: "" + val deviceId = prefs.getString(MatrixSyncService.EXTRA_DEVICE_ID, null) ?: "" + + val serviceIntent = Intent(context, MatrixSyncService::class.java).apply { + putExtra(MatrixSyncService.EXTRA_HOMESERVER, homeserver) + putExtra(MatrixSyncService.EXTRA_TOKEN, token) + putExtra(MatrixSyncService.EXTRA_USER_ID, userId) + putExtra(MatrixSyncService.EXTRA_DEVICE_ID, deviceId) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } +} diff --git a/android/app/src/main/java/com/paarrot/app/MainActivity.java b/android/app/src/main/java/com/paarrot/app/MainActivity.java index 4ed2af6..affb861 100644 --- a/android/app/src/main/java/com/paarrot/app/MainActivity.java +++ b/android/app/src/main/java/com/paarrot/app/MainActivity.java @@ -1,5 +1,12 @@ package com.paarrot.app; +import android.os.Bundle; import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + registerPlugin(SyncServicePlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt new file mode 100644 index 0000000..5bd5c3f --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -0,0 +1,238 @@ +package com.paarrot.app + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Persistent ForegroundService that maintains a Matrix /sync long-poll loop, + * showing native notifications for new messages when the main app process is alive + * but the UI is not in the foreground, and when the app has been swiped away. + * + * Start via [SyncServicePlugin]. Credentials are persisted in SharedPreferences + * so [BootReceiver] can restart it after a device reboot. + */ +class MatrixSyncService : Service() { + + private val job = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.IO + job) + + /** Event IDs already shown as notifications this session — prevents duplicates on restart. */ + private val shownEventIds = HashSet(64) + + override fun onBind(intent: Intent?) = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) + + val homeserver = intent?.getStringExtra(EXTRA_HOMESERVER) + ?: prefs.getString(EXTRA_HOMESERVER, null) + val token = intent?.getStringExtra(EXTRA_TOKEN) + ?: prefs.getString(EXTRA_TOKEN, null) + val userId = intent?.getStringExtra(EXTRA_USER_ID) + ?: prefs.getString(EXTRA_USER_ID, null) ?: "" + + if (homeserver == null || token == null) { + stopSelf() + return START_NOT_STICKY + } + + startForeground(NOTIF_ID_STATUS, buildStatusNotification()) + + serviceScope.launch { + runSyncLoop(homeserver, token, userId) + } + + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + job.cancel() + } + + private suspend fun runSyncLoop(homeserver: String, token: String, userId: String) { + val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + var since = prefs.getString(KEY_SINCE, null) + var isFirstSync = since == null + + while (serviceScope.isActive) { + try { + val url = buildSyncUrl(homeserver.trimEnd('/'), since) + val (responseCode, body) = doHttpGet(url, token) + + when (responseCode) { + 200 -> { + val json = JSONObject(body ?: "{}") + val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() } + + if (nextBatch != null) { + prefs.edit().putString(KEY_SINCE, nextBatch).apply() + + if (!isFirstSync && !appInForeground) { + processRoomEvents(json, userId) + } + isFirstSync = false + since = nextBatch + } + } + 401, 403 -> { + Log.w(TAG, "Auth error $responseCode — stopping sync") + stopSelf() + return + } + else -> delay(BACKOFF_ERRS_MS) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Sync error: ${e.message}") + delay(BACKOFF_ERRS_MS) + } + } + } + + private fun buildSyncUrl(base: String, since: String?): String { + val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}""" + val encodedFilter = URLEncoder.encode(filter, "UTF-8") + val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else "" + return "$base/_matrix/client/v3/sync?timeout=30000&filter=$encodedFilter$sinceParam" + } + + private suspend fun doHttpGet(urlString: String, token: String): Pair = + withContext(Dispatchers.IO) { + val conn = URL(urlString).openConnection() as HttpURLConnection + try { + conn.requestMethod = "GET" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Accept", "application/json") + conn.connectTimeout = 5_000 + conn.readTimeout = 35_000 + val code = conn.responseCode + val body = if (code == 200) conn.inputStream.bufferedReader().readText() else null + Pair(code, body) + } catch (e: Exception) { + Pair(-1, null) + } finally { + conn.disconnect() + } + } + + private fun processRoomEvents(sync: JSONObject, myUserId: String) { + val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureMessageChannel(nm) + + joinedRooms.keys().forEach { roomId -> + val timeline = joinedRooms.optJSONObject(roomId) + ?.optJSONObject("timeline") ?: return@forEach + val events = timeline.optJSONArray("events") ?: return@forEach + + for (i in 0 until events.length()) { + val event = events.optJSONObject(i) ?: continue + val eventId = event.optString("event_id") + + if (event.optString("type") != "m.room.message") continue + if (event.optString("sender") == myUserId) continue + if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue + + val content = event.optJSONObject("content") ?: continue + val body = content.optString("body").ifBlank { continue } + val sender = event.optString("sender") + val senderName = sender.substringAfter("@").substringBefore(":") + + showMessageNotification(nm, senderName, body) + } + } + } + + private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + ?: Intent(this, MainActivity::class.java) + val pi = PendingIntent.getActivity( + this, 0, launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES) + .setSmallIcon(R.drawable.ic_stat_paarrot) + .setContentTitle(sender) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pi) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + nm.notify(System.currentTimeMillis().toInt(), notification) + } + + private fun buildStatusNotification(): Notification { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_STATUS, "Sync Status", + NotificationManager.IMPORTANCE_MIN, + ).apply { + description = "Paarrot background sync status" + setShowBadge(false) + } + nm.createNotificationChannel(channel) + } + + return NotificationCompat.Builder(this, CHANNEL_STATUS) + .setSmallIcon(R.drawable.ic_stat_paarrot) + .setContentTitle("Paarrot") + .setContentText("Connected") + .setPriority(NotificationCompat.PRIORITY_MIN) + .setOngoing(true) + .build() + } + + private fun ensureMessageChannel(nm: NotificationManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_MESSAGES, "Messages", + NotificationManager.IMPORTANCE_HIGH, + ).apply { description = "Matrix message notifications" } + nm.createNotificationChannel(channel) + } + } + + companion object { + private const val TAG = "MatrixSyncService" + const val EXTRA_HOMESERVER = "homeserver_url" + const val EXTRA_TOKEN = "access_token" + const val EXTRA_USER_ID = "user_id" + const val EXTRA_DEVICE_ID = "device_id" + const val PREFS = "matrix_sync_prefs" + const val KEY_SINCE = "since_token" + private const val NOTIF_ID_STATUS = 1001 + private const val CHANNEL_STATUS = "sync_status" + private const val CHANNEL_MESSAGES = "messages" + private const val BACKOFF_ERRS_MS = 10_000L + + /** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */ + @Volatile + var appInForeground = false + } +} diff --git a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt new file mode 100644 index 0000000..276f3b0 --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt @@ -0,0 +1,94 @@ +package com.paarrot.app + +import android.content.Context +import android.content.Intent +import android.os.Build +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +/** + * Capacitor plugin that controls [MatrixSyncService] from JS. + * + * JS API: + * - `start({ homeserverUrl, accessToken, userId, deviceId })` — start/update sync service + * - `stop()` — stop sync service and clear persisted credentials + * - `setAppForeground({ foreground })` — tell the service whether the app UI is visible + * - `getStatus()` — returns `{ running: boolean }` + */ +@CapacitorPlugin(name = "MatrixBackgroundSync") +class SyncServicePlugin : Plugin() { + + /** + * Starts the [MatrixSyncService] with the provided Matrix credentials. + * Persists credentials in SharedPreferences so [BootReceiver] can restart + * the service after a device reboot. + */ + @PluginMethod + fun start(call: PluginCall) { + val homeserver = call.getString("homeserverUrl") + ?: return call.reject("homeserverUrl required") + val token = call.getString("accessToken") + ?: return call.reject("accessToken required") + val userId = call.getString("userId") ?: "" + val deviceId = call.getString("deviceId") ?: "" + + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(MatrixSyncService.EXTRA_HOMESERVER, homeserver) + .putString(MatrixSyncService.EXTRA_TOKEN, token) + .putString(MatrixSyncService.EXTRA_USER_ID, userId) + .putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId) + .apply() + + val intent = Intent(context, MatrixSyncService::class.java).apply { + putExtra(MatrixSyncService.EXTRA_HOMESERVER, homeserver) + putExtra(MatrixSyncService.EXTRA_TOKEN, token) + putExtra(MatrixSyncService.EXTRA_USER_ID, userId) + putExtra(MatrixSyncService.EXTRA_DEVICE_ID, deviceId) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + + call.resolve() + } + + /** Stops the sync service and erases persisted credentials. */ + @PluginMethod + fun stop(call: PluginCall) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() + context.stopService(Intent(context, MatrixSyncService::class.java)) + call.resolve() + } + + /** + * Notifies the service whether the Capacitor WebView UI is currently visible. + * When `foreground == true` the service skips firing notifications because + * the JS layer handles them directly via LocalNotifications. + */ + @PluginMethod + fun setAppForeground(call: PluginCall) { + MatrixSyncService.appInForeground = call.getBoolean("foreground", false) ?: false + call.resolve() + } + + /** Returns whether the sync service is currently running. */ + @PluginMethod + fun getStatus(call: PluginCall) { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) + as android.app.ActivityManager + @Suppress("DEPRECATION") + val running = am.getRunningServices(Int.MAX_VALUE) + .any { it.service.className == MatrixSyncService::class.java.name } + call.resolve(JSObject().apply { put("running", running) }) + } + + companion object { + const val PREFS = "sync_service_prefs" + } +} diff --git a/android/build.gradle b/android/build.gradle index f8f0e43..d091be0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,7 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - + ext { + kotlin_version = '1.9.25' + } + repositories { google() mavenCentral() @@ -9,6 +12,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.13.0' classpath 'com.google.gms:google-services:4.4.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx index 5f1582b..8410b3f 100644 --- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -36,6 +36,11 @@ import { setupNotificationTapListener, } from '../../utils/tauri'; import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api'; +import { + startBackgroundSync, + stopBackgroundSync, + setAppForegroundState, +} from '../../utils/backgroundSync'; /** * Applies the selected emoji style font to the document. @@ -413,6 +418,31 @@ function MessageNotifications() { return null; } +/** + * Starts the native Android background sync service on login, keeps it + * informed of foreground state so it doesn't double-fire notifications, + * and stops it cleanly on unmount (logout). + * Only active on Android Capacitor builds. + */ +function BackgroundSyncSetup() { + const mx = useMatrixClient(); + + useEffect(() => { + startBackgroundSync(mx); + + const onVisibility = () => setAppForegroundState(!document.hidden); + document.addEventListener('visibilitychange', onVisibility); + setAppForegroundState(!document.hidden); + + return () => { + document.removeEventListener('visibilitychange', onVisibility); + stopBackgroundSync(); + }; + }, [mx]); + + return null; +} + /** * Initializes the Paarrot API for Electron integration * Registers the navigate function and sets up IPC handlers @@ -475,6 +505,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts new file mode 100644 index 0000000..f9c270e --- /dev/null +++ b/overlay/src/app/utils/backgroundSync.ts @@ -0,0 +1,91 @@ +import { Capacitor, registerPlugin } from '@capacitor/core'; +import type { MatrixClient } from 'matrix-js-sdk'; + +interface MatrixBackgroundSyncPlugin { + /** Start the background sync service with the given Matrix credentials. */ + start(options: { + homeserverUrl: string; + accessToken: string; + userId: string; + deviceId: string; + }): Promise; + /** Stop the background sync service and clear persisted credentials. */ + stop(): Promise; + /** + * Notify the service whether the app UI is visible. + * When foreground is true, the service suppresses native notifications + * because the JS layer handles them via LocalNotifications. + */ + setAppForeground(options: { foreground: boolean }): Promise; + /** Returns whether the sync service is currently running. */ + getStatus(): Promise<{ running: boolean }>; +} + +const MatrixBackgroundSync = registerPlugin('MatrixBackgroundSync'); + +/** Returns true when the current platform is Android Capacitor. */ +export const isBackgroundSyncSupported = (): boolean => + Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; + +/** + * Starts the native Matrix background sync service. + * Reads credentials directly from the active MatrixClient. + * Safe to call on every login — the service is idempotent. + * @param mx Authenticated Matrix client + */ +export const startBackgroundSync = async (mx: MatrixClient): Promise => { + if (!isBackgroundSyncSupported()) return; + + const homeserverUrl = mx.getHomeserverUrl(); + const accessToken = mx.getAccessToken(); + const userId = mx.getUserId(); + const deviceId = mx.getDeviceId(); + + if (!homeserverUrl || !accessToken || !userId) { + console.warn('[BackgroundSync] Missing credentials, not starting'); + return; + } + + try { + await MatrixBackgroundSync.start({ + homeserverUrl, + accessToken, + userId, + deviceId: deviceId ?? '', + }); + console.log('[BackgroundSync] Service started'); + } catch (err) { + console.warn('[BackgroundSync] Failed to start:', err); + } +}; + +/** + * Stops the native Matrix background sync service. + * Call on logout so the service stops and credentials are wiped. + */ +export const stopBackgroundSync = async (): Promise => { + if (!isBackgroundSyncSupported()) return; + + try { + await MatrixBackgroundSync.stop(); + console.log('[BackgroundSync] Service stopped'); + } catch (err) { + console.warn('[BackgroundSync] Failed to stop:', err); + } +}; + +/** + * Tells the native service whether the app UI is in the foreground. + * Call when document visibility changes so the service can suppress + * duplicate notifications while the JS layer is active. + * @param foreground true if the WebView UI is currently visible + */ +export const setAppForegroundState = async (foreground: boolean): Promise => { + if (!isBackgroundSyncSupported()) return; + + try { + await MatrixBackgroundSync.setAppForeground({ foreground }); + } catch (err) { + console.warn('[BackgroundSync] setAppForeground failed:', err); + } +};