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);
+ }
+};