diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index dec3748..56aaa56 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -47,6 +47,15 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/paarrot/app/BootReceiver.kt b/android/app/src/main/java/com/paarrot/app/BootReceiver.kt
index 3987271..8ae90ed 100644
--- a/android/app/src/main/java/com/paarrot/app/BootReceiver.kt
+++ b/android/app/src/main/java/com/paarrot/app/BootReceiver.kt
@@ -3,34 +3,15 @@ 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].
+ * Triggers a one-shot sync fetch after boot if credentials exist.
*/
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)
- }
+ MatrixSyncService.requestSyncFetch(context, "boot_completed")
}
}
diff --git a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
index 8cc956a..6eab5e8 100644
--- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
+++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
@@ -15,8 +15,6 @@ 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
@@ -25,12 +23,10 @@ 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.
+ * ForegroundService that performs a short, one-shot Matrix /sync fetch.
*
- * Start via [SyncServicePlugin]. Credentials are persisted in SharedPreferences
- * so [BootReceiver] can restart it after a device reboot.
+ * This service is wake-triggered by push pings (UnifiedPush/FCM bridge) and
+ * then fetches message content from Matrix before posting local notifications.
*/
class MatrixSyncService : Service() {
@@ -57,13 +53,20 @@ class MatrixSyncService : Service() {
return START_NOT_STICKY
}
+ val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: "unknown"
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
serviceScope.launch {
- runSyncLoop(homeserver, token, userId)
+ try {
+ runSingleSyncFetch(homeserver, token, userId)
+ Log.d(TAG, "One-shot sync completed (reason=$triggerReason)")
+ } finally {
+ stopForegroundCompat()
+ stopSelf(startId)
+ }
}
- return START_STICKY
+ return START_NOT_STICKY
}
override fun onDestroy() {
@@ -71,44 +74,44 @@ class MatrixSyncService : Service() {
job.cancel()
}
- private suspend fun runSyncLoop(homeserver: String, token: String, userId: String) {
+ private suspend fun runSingleSyncFetch(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
+ val since = prefs.getString(KEY_SINCE, null)
+ val isFirstSync = since == null
- while (serviceScope.isActive) {
- try {
- val url = buildSyncUrl(homeserver.trimEnd('/'), since)
- val (responseCode, body) = doHttpGet(url, token)
+ 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() }
+ 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 (nextBatch != null) {
+ prefs.edit().putString(KEY_SINCE, nextBatch).apply()
- if (!isFirstSync && !appInForeground) {
- processRoomEvents(json, userId)
- }
- isFirstSync = false
- since = nextBatch
+ if (!isFirstSync && !appInForeground) {
+ processRoomEvents(json, userId)
}
}
- 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)
+ 401, 403 -> {
+ Log.w(TAG, "Auth error $responseCode — clearing credentials")
+ applicationContext
+ .getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .apply()
+ }
+ else -> {
+ Log.w(TAG, "Sync fetch failed with HTTP $responseCode")
+ }
}
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "Sync error: ${e.message}")
}
}
@@ -116,7 +119,7 @@ class MatrixSyncService : Service() {
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"
+ return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam"
}
private suspend fun doHttpGet(urlString: String, token: String): Pair =
@@ -203,12 +206,21 @@ class MatrixSyncService : Service() {
return NotificationCompat.Builder(this, CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle("Paarrot")
- .setContentText("Connected")
+ .setContentText("Checking for new messages")
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true)
.build()
}
+ private fun stopForegroundCompat() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ @Suppress("DEPRECATION")
+ stopForeground(true)
+ }
+ }
+
private fun ensureMessageChannel(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
@@ -225,15 +237,52 @@ class MatrixSyncService : Service() {
const val EXTRA_TOKEN = "access_token"
const val EXTRA_USER_ID = "user_id"
const val EXTRA_DEVICE_ID = "device_id"
+ const val EXTRA_TRIGGER_REASON = "trigger_reason"
const val PREFS = "matrix_sync_prefs"
const val KEY_SINCE = "since_token"
+ private const val KEY_LAST_WAKE_MS = "last_wake_ms"
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
+ private const val MIN_WAKE_INTERVAL_MS = 7_500L
+ const val ACTION_PUSH_PING = "com.paarrot.app.ACTION_PUSH_PING"
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
@Volatile
var appInForeground = false
+
+ /**
+ * Starts a one-shot sync fetch if credentials are available and the call is not rate-limited.
+ */
+ fun requestSyncFetch(context: Context, reason: String) {
+ val credsPrefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
+ val homeserver = credsPrefs.getString(EXTRA_HOMESERVER, null) ?: return
+ val token = credsPrefs.getString(EXTRA_TOKEN, null) ?: return
+ val userId = credsPrefs.getString(EXTRA_USER_ID, null) ?: ""
+ val deviceId = credsPrefs.getString(EXTRA_DEVICE_ID, null) ?: ""
+
+ val syncPrefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val now = System.currentTimeMillis()
+ val lastWake = syncPrefs.getLong(KEY_LAST_WAKE_MS, 0L)
+ if (now - lastWake < MIN_WAKE_INTERVAL_MS) {
+ Log.d(TAG, "Skipping wake: rate-limited ($reason)")
+ return
+ }
+ syncPrefs.edit().putLong(KEY_LAST_WAKE_MS, now).apply()
+
+ val intent = Intent(context, MatrixSyncService::class.java).apply {
+ putExtra(EXTRA_HOMESERVER, homeserver)
+ putExtra(EXTRA_TOKEN, token)
+ putExtra(EXTRA_USER_ID, userId)
+ putExtra(EXTRA_DEVICE_ID, deviceId)
+ putExtra(EXTRA_TRIGGER_REASON, reason)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
}
}
diff --git a/android/app/src/main/java/com/paarrot/app/PushPingReceiver.kt b/android/app/src/main/java/com/paarrot/app/PushPingReceiver.kt
new file mode 100644
index 0000000..aead9c1
--- /dev/null
+++ b/android/app/src/main/java/com/paarrot/app/PushPingReceiver.kt
@@ -0,0 +1,25 @@
+package com.paarrot.app
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+/**
+ * Receives tiny push wake pings and triggers one Matrix fetch.
+ *
+ * - `org.unifiedpush.android.connector.MESSAGE` supports UnifiedPush distributors.
+ * - [MatrixSyncService.ACTION_PUSH_PING] is an app-local fallback action
+ * that other bridges (e.g., FCM service) can emit.
+ */
+class PushPingReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val reason = when (intent.action) {
+ "org.unifiedpush.android.connector.MESSAGE" -> "unifiedpush_ping"
+ MatrixSyncService.ACTION_PUSH_PING -> "app_push_ping"
+ else -> return
+ }
+
+ MatrixSyncService.requestSyncFetch(context, reason)
+ }
+}
diff --git a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt
index 276f3b0..ad4b8ea 100644
--- a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt
+++ b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt
@@ -2,7 +2,6 @@ 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
@@ -13,19 +12,16 @@ 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
+ * - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist/update sync credentials
+ * - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake)
+ * - `stop()` — clear persisted credentials and stop any in-flight fetch
* - `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.
- */
+ /** Persists Matrix credentials used later by push-triggered sync fetches. */
@PluginMethod
fun start(call: PluginCall) {
val homeserver = call.getString("homeserverUrl")
@@ -42,19 +38,14 @@ class SyncServicePlugin : Plugin() {
.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()
+ }
+ /** Manually triggers a one-shot sync fetch (mainly for diagnostics/testing). */
+ @PluginMethod
+ fun triggerPing(call: PluginCall) {
+ val reason = call.getString("reason") ?: "manual_plugin_ping"
+ MatrixSyncService.requestSyncFetch(context, reason)
call.resolve()
}
diff --git a/cinny b/cinny
index a3dac17..7b69ed6 160000
--- a/cinny
+++ b/cinny
@@ -1 +1 @@
-Subproject commit a3dac17b7cd47598e3dbdd61374bb267df1b344e
+Subproject commit 7b69ed6df85fdbfa22df5957c268fd874a10f749
diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx
index 8410b3f..d675ae8 100644
--- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -419,9 +419,9 @@ function MessageNotifications() {
}
/**
- * Starts the native Android background sync service on login, keeps it
+ * Configures native Android push-ping background sync on login, keeps it
* informed of foreground state so it doesn't double-fire notifications,
- * and stops it cleanly on unmount (logout).
+ * and clears it cleanly on unmount (logout).
* Only active on Android Capacitor builds.
*/
function BackgroundSyncSetup() {
diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts
index 1f44781..a69601f 100644
--- a/overlay/src/app/utils/backgroundSync.ts
+++ b/overlay/src/app/utils/backgroundSync.ts
@@ -2,14 +2,16 @@ 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. */
+ /** Persist credentials used by push-triggered one-shot sync fetches. */
start(options: {
homeserverUrl: string;
accessToken: string;
userId: string;
deviceId: string;
}): Promise;
- /** Stop the background sync service and clear persisted credentials. */
+ /** Trigger a one-shot fetch as if a push ping arrived. */
+ triggerPing(options: { reason?: string }): Promise;
+ /** Stop any in-flight fetch and clear persisted credentials. */
stop(): Promise;
/**
* Notify the service whether the app UI is visible.
@@ -28,9 +30,8 @@ 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.
+ * Persists Matrix credentials for Android push-ping wake handling.
+ * Safe to call on every login.
* @param mx Authenticated Matrix client
*/
export const startBackgroundSync = async (mx: MatrixClient): Promise => {
@@ -53,15 +54,29 @@ export const startBackgroundSync = async (mx: MatrixClient): Promise => {
userId,
deviceId: deviceId ?? '',
});
- console.log('[BackgroundSync] Service started');
+ console.log('[BackgroundSync] Push wake credentials configured');
} 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.
+ * Manually triggers a one-shot native fetch.
+ * Useful for diagnostics and bridge testing.
+ */
+export const triggerBackgroundSyncPing = async (reason?: string): Promise => {
+ if (!isBackgroundSyncSupported()) return;
+
+ try {
+ await MatrixBackgroundSync.triggerPing({ reason });
+ } catch (err) {
+ console.warn('[BackgroundSync] triggerPing failed:', err);
+ }
+};
+
+/**
+ * Stops native background sync handling and clears persisted credentials.
+ * Call on logout.
*/
export const stopBackgroundSync = async (): Promise => {
if (!isBackgroundSyncSupported()) return;