feat: integrate UnifiedPush for background synchronization and event handling

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-26 10:56:10 +10:00
parent cb44bc92e4
commit 79d46857f9
10 changed files with 519 additions and 85 deletions

View File

@@ -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"

View File

@@ -40,6 +40,14 @@
android:exported="false"
android:stopWithTask="false" />
<service
android:name=".UnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".BootReceiver"
android:exported="true">
@@ -47,15 +55,6 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".PushPingReceiver"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
<action android:name="com.paarrot.app.ACTION_PUSH_PING" />
</intent-filter>
</receiver>
</application>
<!-- Permissions -->

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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<String>())
)
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)
}

View File

@@ -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"
}
}

View File

@@ -2,7 +2,7 @@
buildscript {
ext {
kotlin_version = '1.9.25'
kotlin_version = '2.3.0'
}
repositories {