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();
};
/**