From 79d46857f92fe87c1231daa2729336d5a156e73f Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 26 Apr 2026 10:56:10 +1000 Subject: [PATCH] feat: integrate UnifiedPush for background synchronization and event handling Co-authored-by: Copilot --- android/app/build.gradle | 1 + android/app/src/main/AndroidManifest.xml | 17 +- .../main/java/com/paarrot/app/BootReceiver.kt | 8 +- .../java/com/paarrot/app/MatrixSyncService.kt | 10 +- .../java/com/paarrot/app/PushPingReceiver.kt | 25 -- .../java/com/paarrot/app/SyncServicePlugin.kt | 28 +- .../com/paarrot/app/UnifiedPushManager.kt | 149 ++++++++ .../com/paarrot/app/UnifiedPushService.kt | 35 ++ android/build.gradle | 2 +- overlay/src/app/utils/backgroundSync.ts | 329 ++++++++++++++++-- 10 files changed, 519 insertions(+), 85 deletions(-) delete mode 100644 android/app/src/main/java/com/paarrot/app/PushPingReceiver.kt create mode 100644 android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt create mode 100644 android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 4e8e01f..800e8eb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -65,6 +65,7 @@ dependencies { implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation project(':capacitor-android') implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'org.unifiedpush.android:connector:3.3.2' 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 56aaa56..2c6d96c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,14 @@ android:exported="false" android:stopWithTask="false" /> + + + + + + @@ -47,15 +55,6 @@ - - - - - - - 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 8ae90ed..1195590 100644 --- a/android/app/src/main/java/com/paarrot/app/BootReceiver.kt +++ b/android/app/src/main/java/com/paarrot/app/BootReceiver.kt @@ -5,13 +5,17 @@ import android.content.Context import android.content.Intent /** - * Triggers a one-shot sync fetch after boot if credentials exist. + * Re-registers UnifiedPush after boot when credentials still exist. */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intent.ACTION_BOOT_COMPLETED) return - MatrixSyncService.requestSyncFetch(context, "boot_completed") + val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) + prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return + prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return + + UnifiedPushManager.register(context, activity = null) } } 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 6eab5e8..85096d7 100644 --- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -23,10 +23,8 @@ import java.net.URL import java.net.URLEncoder /** - * ForegroundService that performs a short, one-shot Matrix /sync fetch. - * - * This service is wake-triggered by push pings (UnifiedPush/FCM bridge) and - * then fetches message content from Matrix before posting local notifications. + * ForegroundService that performs a short, one-shot Matrix /sync fetch after + * a wake ping from UnifiedPush or a manual diagnostic trigger. */ class MatrixSyncService : Service() { @@ -53,7 +51,7 @@ class MatrixSyncService : Service() { return START_NOT_STICKY } - val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: "unknown" + val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: MODE_ONE_SHOT startForeground(NOTIF_ID_STATUS, buildStatusNotification()) serviceScope.launch { @@ -245,7 +243,7 @@ class MatrixSyncService : Service() { private const val CHANNEL_STATUS = "sync_status" private const val CHANNEL_MESSAGES = "messages" private const val MIN_WAKE_INTERVAL_MS = 7_500L - const val ACTION_PUSH_PING = "com.paarrot.app.ACTION_PUSH_PING" + const val MODE_ONE_SHOT = "one_shot" /** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */ @Volatile diff --git a/android/app/src/main/java/com/paarrot/app/PushPingReceiver.kt b/android/app/src/main/java/com/paarrot/app/PushPingReceiver.kt deleted file mode 100644 index aead9c1..0000000 --- a/android/app/src/main/java/com/paarrot/app/PushPingReceiver.kt +++ /dev/null @@ -1,25 +0,0 @@ -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 ad4b8ea..f2aeffd 100644 --- a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt +++ b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt @@ -12,16 +12,30 @@ import com.getcapacitor.annotation.CapacitorPlugin * Capacitor plugin that controls [MatrixSyncService] from JS. * * JS API: - * - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist/update sync credentials + * - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist credentials and register UnifiedPush * - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake) - * - `stop()` — clear persisted credentials and stop any in-flight fetch + * - `stop()` — clear persisted credentials and unregister UnifiedPush * - `setAppForeground({ foreground })` — tell the service whether the app UI is visible - * - `getStatus()` — returns `{ running: boolean }` + * - `getStatus()` — returns current fetch and UnifiedPush state */ @CapacitorPlugin(name = "MatrixBackgroundSync") class SyncServicePlugin : Plugin() { - /** Persists Matrix credentials used later by push-triggered sync fetches. */ + fun emitUnifiedPushEvent(eventName: String, payload: JSObject) { + notifyListeners(eventName, payload, true) + } + + override fun load() { + super.load() + UnifiedPushManager.setPlugin(this) + } + + override fun handleOnDestroy() { + UnifiedPushManager.clearPlugin(this) + super.handleOnDestroy() + } + + /** Persists Matrix credentials and starts UnifiedPush registration. */ @PluginMethod fun start(call: PluginCall) { val homeserver = call.getString("homeserverUrl") @@ -38,6 +52,8 @@ class SyncServicePlugin : Plugin() { .putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId) .apply() + UnifiedPushManager.register(context, activity) + call.resolve() } @@ -54,6 +70,7 @@ class SyncServicePlugin : Plugin() { fun stop(call: PluginCall) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() context.stopService(Intent(context, MatrixSyncService::class.java)) + UnifiedPushManager.unregister(context) call.resolve() } @@ -76,7 +93,8 @@ class SyncServicePlugin : Plugin() { @Suppress("DEPRECATION") val running = am.getRunningServices(Int.MAX_VALUE) .any { it.service.className == MatrixSyncService::class.java.name } - call.resolve(JSObject().apply { put("running", running) }) + val result = UnifiedPushManager.getStatus(context).apply { put("running", running) } + call.resolve(result) } companion object { diff --git a/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt b/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt new file mode 100644 index 0000000..07ad961 --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt @@ -0,0 +1,149 @@ +package com.paarrot.app + +import android.app.Activity +import android.content.Context +import android.util.Log +import com.getcapacitor.JSObject +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.INSTANCE_DEFAULT +import org.unifiedpush.android.connector.UnifiedPush + +/** Coordinates UnifiedPush registration state and bridges events back to JS. */ +object UnifiedPushManager { + private const val TAG = "UnifiedPushManager" + private const val PREFS = "unifiedpush_prefs" + private const val KEY_ENDPOINT = "endpoint" + private const val KEY_INSTANCE = "instance" + private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint" + private const val EVENT_UNREGISTERED = "unifiedPushUnregistered" + private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed" + private const val DEFAULT_MESSAGE = "Paarrot notifications" + + @Volatile + private var plugin: SyncServicePlugin? = null + + fun setPlugin(plugin: SyncServicePlugin) { + this.plugin = plugin + } + + fun clearPlugin(plugin: SyncServicePlugin) { + if (this.plugin === plugin) { + this.plugin = null + } + } + + fun register(context: Context, activity: Activity?) { + val savedDistributor = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty() + if (savedDistributor.isNotBlank()) { + requestRegistration(context) + return + } + + if (activity == null) { + dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT) + return + } + + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success -> + if (success) { + requestRegistration(context) + } else { + dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT) + } + } + } + + fun unregister(context: Context) { + runCatching { + UnifiedPush.unregister(context, INSTANCE_DEFAULT) + }.onFailure { + Log.w(TAG, "Failed to unregister UnifiedPush: ${it.message}") + } + val previousEndpoint = getEndpoint(context) + clearEndpoint(context) + dispatchUnregistered(previousEndpoint, INSTANCE_DEFAULT) + } + + fun getStatus(context: Context): JSObject { + val status = JSObject() + status.put("endpoint", getEndpoint(context) ?: "") + status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT) + status.put("registered", !getEndpoint(context).isNullOrBlank()) + status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault("")) + status.put( + "distributors", + runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList()) + ) + return status + } + + fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + val previousEndpoint = getEndpoint(context) + persistEndpoint(context, endpoint, instance) + val payload = JSObject().apply { + put("endpoint", endpoint) + put("instance", instance) + put("previousEndpoint", previousEndpoint ?: "") + } + dispatch(EVENT_NEW_ENDPOINT, payload) + } + + fun onRegistrationFailed(reason: String, instance: String) { + dispatchRegistrationFailed(reason, instance) + } + + fun onUnregistered(context: Context, instance: String) { + val previousEndpoint = getEndpoint(context) + clearEndpoint(context) + dispatchUnregistered(previousEndpoint, instance) + } + + private fun requestRegistration(context: Context) { + runCatching { + UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null) + }.onFailure { + Log.w(TAG, "UnifiedPush register failed: ${it.message}") + dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT) + } + } + + private fun dispatchUnregistered(previousEndpoint: String?, instance: String) { + val payload = JSObject().apply { + put("previousEndpoint", previousEndpoint ?: "") + put("instance", instance) + } + dispatch(EVENT_UNREGISTERED, payload) + } + + private fun dispatchRegistrationFailed(reason: String, instance: String) { + val payload = JSObject().apply { + put("reason", reason) + put("instance", instance) + } + dispatch(EVENT_REGISTRATION_FAILED, payload) + } + + private fun dispatch(eventName: String, payload: JSObject) { + plugin?.emitUnifiedPushEvent(eventName, payload) + } + + private fun persistEndpoint(context: Context, endpoint: String, instance: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_ENDPOINT, endpoint) + .putString(KEY_INSTANCE, instance) + .apply() + } + + private fun clearEndpoint(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .remove(KEY_ENDPOINT) + .remove(KEY_INSTANCE) + .apply() + } + + private fun getEndpoint(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null) + + private fun getInstance(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_INSTANCE, null) +} diff --git a/android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt b/android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt new file mode 100644 index 0000000..7c78942 --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt @@ -0,0 +1,35 @@ +package com.paarrot.app + +import android.util.Log +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +/** Receives UnifiedPush events and turns push messages into Matrix fetch wakes. */ +class UnifiedPushService : PushService() { + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + Log.i(TAG, "UnifiedPush endpoint updated for instance=$instance") + UnifiedPushManager.onNewEndpoint(applicationContext, endpoint.url, instance) + } + + override fun onMessage(message: PushMessage, instance: String) { + Log.d(TAG, "UnifiedPush wake ping received for instance=$instance, bytes=${message.content.size}") + MatrixSyncService.requestSyncFetch(applicationContext, "unifiedpush_message") + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.w(TAG, "UnifiedPush registration failed: $reason") + UnifiedPushManager.onRegistrationFailed(reason.name, instance) + } + + override fun onUnregistered(instance: String) { + Log.i(TAG, "UnifiedPush unregistered for instance=$instance") + UnifiedPushManager.onUnregistered(applicationContext, instance) + } + + private companion object { + const val TAG = "UnifiedPushService" + } +} diff --git a/android/build.gradle b/android/build.gradle index d091be0..6091109 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { - kotlin_version = '1.9.25' + kotlin_version = '2.3.0' } repositories { diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts index a69601f..f4cdea7 100644 --- a/overlay/src/app/utils/backgroundSync.ts +++ b/overlay/src/app/utils/backgroundSync.ts @@ -1,8 +1,33 @@ -import { Capacitor, registerPlugin } from '@capacitor/core'; -import type { MatrixClient } from 'matrix-js-sdk'; - +import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; +import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk'; + +type UnifiedPushStatus = { + running: boolean; + endpoint: string; + instance: string; + registered: boolean; + distributor: string; + distributors: string[]; +}; + +type UnifiedPushEndpointEvent = { + endpoint: string; + previousEndpoint: string; + instance: string; +}; + +type UnifiedPushUnregisteredEvent = { + previousEndpoint: string; + instance: string; +}; + +type UnifiedPushRegistrationFailedEvent = { + reason: string; + instance: string; +}; + interface MatrixBackgroundSyncPlugin { - /** Persist credentials used by push-triggered one-shot sync fetches. */ + /** Persist credentials and request UnifiedPush registration. */ start(options: { homeserverUrl: string; accessToken: string; @@ -11,7 +36,7 @@ interface MatrixBackgroundSyncPlugin { }): Promise; /** 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 any in-flight fetch, clear credentials, and unregister UnifiedPush. */ stop(): Promise; /** * Notify the service whether the app UI is visible. @@ -19,45 +44,285 @@ interface MatrixBackgroundSyncPlugin { * 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 }>; + /** Returns fetch state and current UnifiedPush registration details. */ + getStatus(): Promise; + addListener( + eventName: 'unifiedPushNewEndpoint', + listenerFunc: (event: UnifiedPushEndpointEvent) => void + ): Promise; + addListener( + eventName: 'unifiedPushUnregistered', + listenerFunc: (event: UnifiedPushUnregisteredEvent) => void + ): Promise; + addListener( + eventName: 'unifiedPushRegistrationFailed', + listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void + ): Promise; } +type StoredPusherState = { + endpoint: string; + appId: string; +}; + const MatrixBackgroundSync = registerPlugin('MatrixBackgroundSync'); +const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; +const PUSHER_APP_ID_BASE = 'com.paarrot.app.android'; +const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush'; /** Returns true when the current platform is Android Capacitor. */ export const isBackgroundSyncSupported = (): boolean => Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; -/** - * 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 => { - if (!isBackgroundSyncSupported()) return; +const getStoredPusherKey = (userId: string | null, deviceId: string | null): string => + `${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`; - const homeserverUrl = mx.getHomeserverUrl(); - const accessToken = mx.getAccessToken(); - const userId = mx.getUserId(); - const deviceId = mx.getDeviceId(); +const buildPusherAppId = (deviceId: string | null): string => { + const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE; + return raw.length > 64 ? raw.slice(0, 64) : raw; +}; - if (!homeserverUrl || !accessToken || !userId) { - console.warn('[BackgroundSync] Missing credentials, not starting'); - return; - } +const loadStoredPusherState = ( + userId: string | null, + deviceId: string | null +): StoredPusherState | undefined => { + if (typeof window === 'undefined' || !window.localStorage) return undefined; + + const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId)); + if (!raw) return undefined; try { + return JSON.parse(raw) as StoredPusherState; + } catch { + return undefined; + } +}; + +const saveStoredPusherState = ( + userId: string | null, + deviceId: string | null, + state: StoredPusherState +): void => { + if (typeof window === 'undefined' || !window.localStorage) return; + window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state)); +}; + +const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => { + if (typeof window === 'undefined' || !window.localStorage) return; + window.localStorage.removeItem(getStoredPusherKey(userId, deviceId)); +}; + +const resolveUnifiedPushGateway = async (endpoint: string): Promise => { + try { + const discoveryUrl = new URL(endpoint); + discoveryUrl.pathname = '/_matrix/push/v1/notify'; + discoveryUrl.search = ''; + + const response = await fetch(discoveryUrl.toString(), { method: 'GET' }); + if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY; + + const body = (await response.json()) as { + gateway?: string; + unifiedpush?: { gateway?: string }; + }; + + if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') { + return discoveryUrl.toString(); + } + } catch (err) { + console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err); + } + + return DEFAULT_UNIFIED_PUSH_GATEWAY; +}; + +class AndroidUnifiedPushManager { + private client: MatrixClient | undefined; + + private listenerHandles: PluginListenerHandle[] = []; + + private listenersReady = false; + + /** Start native UnifiedPush registration and synchronize the Matrix pusher. */ + async start(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; + } + + this.client = mx; + await this.ensureListeners(); + await MatrixBackgroundSync.start({ homeserverUrl, accessToken, userId, deviceId: deviceId ?? '', }); - console.log('[BackgroundSync] Push wake credentials configured'); - } catch (err) { - console.warn('[BackgroundSync] Failed to start:', err); + + await this.syncExistingEndpoint(); + console.log('[BackgroundSync] UnifiedPush registration requested'); } + + /** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */ + async stop(): Promise { + if (!isBackgroundSyncSupported()) return; + + const client = this.client; + if (client) { + const deviceId = client.getDeviceId(); + const userId = client.getUserId(); + const status = await this.safeGetStatus(); + const stored = loadStoredPusherState(userId, deviceId); + const endpoint = status?.endpoint || stored?.endpoint; + const appId = stored?.appId ?? buildPusherAppId(deviceId); + + if (endpoint) { + await this.removePusher(client, endpoint, appId); + } + clearStoredPusherState(userId, deviceId); + } + + await MatrixBackgroundSync.stop(); + await this.disposeListeners(); + this.client = undefined; + console.log('[BackgroundSync] UnifiedPush stopped'); + } + + /** Update the Matrix pusher when a new native endpoint is published. */ + private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise { + const client = this.client; + if (!client) return; + + if (event.previousEndpoint) { + await this.removePusher(client, event.previousEndpoint); + } + + await this.upsertPusher(client, event.endpoint); + } + + /** Remove the Matrix pusher when UnifiedPush unregisters this instance. */ + private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise { + const client = this.client; + if (!client) return; + + const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId()); + const endpoint = event.previousEndpoint || stored?.endpoint; + const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId()); + + if (endpoint) { + await this.removePusher(client, endpoint, appId); + } + + clearStoredPusherState(client.getUserId(), client.getDeviceId()); + } + + /** Log native registration failures so the missing distributor path is visible. */ + private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void { + console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason); + } + + /** Install plugin listeners once for the active Matrix client. */ + private async ensureListeners(): Promise { + if (this.listenersReady) return; + + this.listenerHandles = [ + await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => { + void this.handleNewEndpoint(event); + }), + await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => { + void this.handleUnregistered(event); + }), + await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => { + this.handleRegistrationFailed(event); + }), + ]; + this.listenersReady = true; + } + + /** Remove all plugin listeners when the manager stops. */ + private async disposeListeners(): Promise { + await Promise.all(this.listenerHandles.map((handle) => handle.remove())); + this.listenerHandles = []; + this.listenersReady = false; + } + + /** Reconcile an already-persisted native endpoint after app startup. */ + private async syncExistingEndpoint(): Promise { + const client = this.client; + if (!client) return; + + const status = await this.safeGetStatus(); + if (status?.registered && status.endpoint) { + await this.upsertPusher(client, status.endpoint); + } + } + + /** Create or refresh the Matrix HTTP pusher for the current device. */ + private async upsertPusher(mx: MatrixClient, endpoint: string): Promise { + const deviceId = mx.getDeviceId(); + const userId = mx.getUserId(); + const appId = buildPusherAppId(deviceId); + const gatewayUrl = await resolveUnifiedPushGateway(endpoint); + + await mx.setPusher({ + kind: 'http', + app_id: appId, + pushkey: endpoint, + app_display_name: 'Paarrot', + device_display_name: deviceId ?? 'Android', + lang: 'en', + data: { + url: gatewayUrl, + format: 'event_id_only', + }, + append: false, + device_id: deviceId ?? undefined, + } as unknown as IPusherRequest); + + saveStoredPusherState(userId, deviceId, { endpoint, appId }); + } + + /** Remove a previously-registered Matrix HTTP pusher for this device. */ + private async removePusher( + mx: MatrixClient, + endpoint: string, + appId = buildPusherAppId(mx.getDeviceId()) + ): Promise { + try { + await mx.setPusher({ + pushkey: endpoint, + app_id: appId, + kind: null, + } as unknown as IPusherRequest); + } catch (err) { + console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err); + } + } + + /** Read native registration state without failing the caller. */ + private async safeGetStatus(): Promise { + try { + return await MatrixBackgroundSync.getStatus(); + } catch (err) { + console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err); + return undefined; + } + } +} + +const unifiedPushManager = new AndroidUnifiedPushManager(); + +/** Start native UnifiedPush registration and sync the Matrix pusher. */ +export const startBackgroundSync = async (mx: MatrixClient): Promise => { + await unifiedPushManager.start(mx); }; /** @@ -74,19 +339,9 @@ export const triggerBackgroundSyncPing = async (reason?: string): Promise } }; -/** - * Stops native background sync handling and clears persisted credentials. - * Call on logout. - */ +/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */ 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); - } + await unifiedPushManager.stop(); }; /**