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;