feat: integrate UnifiedPush for background synchronization and event handling
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
149
android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
Normal file
149
android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user