Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f583d1227 | ||
| 6df60c8348 | |||
| 79d46857f9 | |||
|
|
c9ac92a0c4 | ||
| cb44bc92e4 | |||
| bc07974b4b | |||
|
|
abe44f6ad1 | ||
| c17e1c74ee | |||
|
|
255bfe2262 | ||
| 8eea325e21 | |||
| 3afb2ee80d |
@@ -65,6 +65,7 @@ dependencies {
|
|||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||||
|
implementation 'org.unifiedpush.android:connector:3.3.2'
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|||||||
@@ -40,6 +40,14 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:stopWithTask="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
|
<receiver
|
||||||
android:name=".BootReceiver"
|
android:name=".BootReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ package com.paarrot.app
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restarts [MatrixSyncService] after the device boots, using credentials
|
* Re-registers UnifiedPush after boot when credentials still exist.
|
||||||
* previously persisted by [SyncServicePlugin].
|
|
||||||
*/
|
*/
|
||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@@ -15,22 +13,9 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
|
||||||
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
val homeserver = prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
|
prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
|
||||||
val token = prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
|
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 {
|
UnifiedPushManager.register(context, activity = null)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -25,12 +23,8 @@ import java.net.URL
|
|||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent ForegroundService that maintains a Matrix /sync long-poll loop,
|
* ForegroundService that performs a short, one-shot Matrix /sync fetch after
|
||||||
* showing native notifications for new messages when the main app process is alive
|
* a wake ping from UnifiedPush or a manual diagnostic trigger.
|
||||||
* but the UI is not in the foreground, and when the app has been swiped away.
|
|
||||||
*
|
|
||||||
* Start via [SyncServicePlugin]. Credentials are persisted in SharedPreferences
|
|
||||||
* so [BootReceiver] can restart it after a device reboot.
|
|
||||||
*/
|
*/
|
||||||
class MatrixSyncService : Service() {
|
class MatrixSyncService : Service() {
|
||||||
|
|
||||||
@@ -57,13 +51,20 @@ class MatrixSyncService : Service() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: MODE_ONE_SHOT
|
||||||
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
|
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
|
||||||
|
|
||||||
serviceScope.launch {
|
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() {
|
override fun onDestroy() {
|
||||||
@@ -71,44 +72,44 @@ class MatrixSyncService : Service() {
|
|||||||
job.cancel()
|
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)
|
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
var since = prefs.getString(KEY_SINCE, null)
|
val since = prefs.getString(KEY_SINCE, null)
|
||||||
var isFirstSync = since == null
|
val isFirstSync = since == null
|
||||||
|
|
||||||
while (serviceScope.isActive) {
|
try {
|
||||||
try {
|
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
|
||||||
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
|
val (responseCode, body) = doHttpGet(url, token)
|
||||||
val (responseCode, body) = doHttpGet(url, token)
|
|
||||||
|
|
||||||
when (responseCode) {
|
when (responseCode) {
|
||||||
200 -> {
|
200 -> {
|
||||||
val json = JSONObject(body ?: "{}")
|
val json = JSONObject(body ?: "{}")
|
||||||
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
|
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
if (nextBatch != null) {
|
if (nextBatch != null) {
|
||||||
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
|
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
|
||||||
|
|
||||||
if (!isFirstSync && !appInForeground) {
|
if (!isFirstSync && !appInForeground) {
|
||||||
processRoomEvents(json, userId)
|
processRoomEvents(json, userId)
|
||||||
}
|
|
||||||
isFirstSync = false
|
|
||||||
since = nextBatch
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
401, 403 -> {
|
|
||||||
Log.w(TAG, "Auth error $responseCode — stopping sync")
|
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
else -> delay(BACKOFF_ERRS_MS)
|
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
401, 403 -> {
|
||||||
throw e
|
Log.w(TAG, "Auth error $responseCode — clearing credentials")
|
||||||
} catch (e: Exception) {
|
applicationContext
|
||||||
Log.w(TAG, "Sync error: ${e.message}")
|
.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
delay(BACKOFF_ERRS_MS)
|
.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 +117,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 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 encodedFilter = URLEncoder.encode(filter, "UTF-8")
|
||||||
val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
|
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<Int, String?> =
|
private suspend fun doHttpGet(urlString: String, token: String): Pair<Int, String?> =
|
||||||
@@ -203,12 +204,21 @@ class MatrixSyncService : Service() {
|
|||||||
return NotificationCompat.Builder(this, CHANNEL_STATUS)
|
return NotificationCompat.Builder(this, CHANNEL_STATUS)
|
||||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
.setContentTitle("Paarrot")
|
.setContentTitle("Paarrot")
|
||||||
.setContentText("Connected")
|
.setContentText("Checking for new messages")
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.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) {
|
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
@@ -225,15 +235,52 @@ class MatrixSyncService : Service() {
|
|||||||
const val EXTRA_TOKEN = "access_token"
|
const val EXTRA_TOKEN = "access_token"
|
||||||
const val EXTRA_USER_ID = "user_id"
|
const val EXTRA_USER_ID = "user_id"
|
||||||
const val EXTRA_DEVICE_ID = "device_id"
|
const val EXTRA_DEVICE_ID = "device_id"
|
||||||
|
const val EXTRA_TRIGGER_REASON = "trigger_reason"
|
||||||
const val PREFS = "matrix_sync_prefs"
|
const val PREFS = "matrix_sync_prefs"
|
||||||
const val KEY_SINCE = "since_token"
|
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 NOTIF_ID_STATUS = 1001
|
||||||
private const val CHANNEL_STATUS = "sync_status"
|
private const val CHANNEL_STATUS = "sync_status"
|
||||||
private const val CHANNEL_MESSAGES = "messages"
|
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 MODE_ONE_SHOT = "one_shot"
|
||||||
|
|
||||||
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
|
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
|
||||||
@Volatile
|
@Volatile
|
||||||
var appInForeground = false
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.paarrot.app
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import com.getcapacitor.Plugin
|
import com.getcapacitor.Plugin
|
||||||
import com.getcapacitor.PluginCall
|
import com.getcapacitor.PluginCall
|
||||||
@@ -13,19 +12,30 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|||||||
* Capacitor plugin that controls [MatrixSyncService] from JS.
|
* Capacitor plugin that controls [MatrixSyncService] from JS.
|
||||||
*
|
*
|
||||||
* JS API:
|
* JS API:
|
||||||
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — start/update sync service
|
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist credentials and register UnifiedPush
|
||||||
* - `stop()` — stop sync service and clear persisted credentials
|
* - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake)
|
||||||
|
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
||||||
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
* - `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")
|
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||||
class SyncServicePlugin : Plugin() {
|
class SyncServicePlugin : Plugin() {
|
||||||
|
|
||||||
/**
|
fun emitUnifiedPushEvent(eventName: String, payload: JSObject) {
|
||||||
* Starts the [MatrixSyncService] with the provided Matrix credentials.
|
notifyListeners(eventName, payload, true)
|
||||||
* Persists credentials in SharedPreferences so [BootReceiver] can restart
|
}
|
||||||
* the service after a device reboot.
|
|
||||||
*/
|
override fun load() {
|
||||||
|
super.load()
|
||||||
|
UnifiedPushManager.setPlugin(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnDestroy() {
|
||||||
|
UnifiedPushManager.clearPlugin(this)
|
||||||
|
super.handleOnDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists Matrix credentials and starts UnifiedPush registration. */
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun start(call: PluginCall) {
|
fun start(call: PluginCall) {
|
||||||
val homeserver = call.getString("homeserverUrl")
|
val homeserver = call.getString("homeserverUrl")
|
||||||
@@ -42,19 +52,16 @@ class SyncServicePlugin : Plugin() {
|
|||||||
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||||
.apply()
|
.apply()
|
||||||
|
|
||||||
val intent = Intent(context, MatrixSyncService::class.java).apply {
|
UnifiedPushManager.register(context, activity)
|
||||||
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) {
|
call.resolve()
|
||||||
context.startForegroundService(intent)
|
}
|
||||||
} else {
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** 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()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +70,7 @@ class SyncServicePlugin : Plugin() {
|
|||||||
fun stop(call: PluginCall) {
|
fun stop(call: PluginCall) {
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
context.stopService(Intent(context, MatrixSyncService::class.java))
|
context.stopService(Intent(context, MatrixSyncService::class.java))
|
||||||
|
UnifiedPushManager.unregister(context)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +93,8 @@ class SyncServicePlugin : Plugin() {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val running = am.getRunningServices(Int.MAX_VALUE)
|
val running = am.getRunningServices(Int.MAX_VALUE)
|
||||||
.any { it.service.className == MatrixSyncService::class.java.name }
|
.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 {
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
kotlin_version = '1.9.25'
|
kotlin_version = '2.3.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
2
cinny
2
cinny
Submodule cinny updated: 6853e04db0...7b69ed6df8
@@ -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,
|
* 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.
|
* Only active on Android Capacitor builds.
|
||||||
*/
|
*/
|
||||||
function BackgroundSyncSetup() {
|
function BackgroundSyncSetup() {
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
import type { MatrixClient } from 'matrix-js-sdk';
|
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 {
|
interface MatrixBackgroundSyncPlugin {
|
||||||
/** Start the background sync service with the given Matrix credentials. */
|
/** Persist credentials and request UnifiedPush registration. */
|
||||||
start(options: {
|
start(options: {
|
||||||
homeserverUrl: string;
|
homeserverUrl: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
/** 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<void>;
|
||||||
|
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Notify the service whether the app UI is visible.
|
* Notify the service whether the app UI is visible.
|
||||||
@@ -17,63 +44,306 @@ interface MatrixBackgroundSyncPlugin {
|
|||||||
* because the JS layer handles them via LocalNotifications.
|
* because the JS layer handles them via LocalNotifications.
|
||||||
*/
|
*/
|
||||||
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
/** Returns whether the sync service is currently running. */
|
/** Returns fetch state and current UnifiedPush registration details. */
|
||||||
getStatus(): Promise<{ running: boolean }>;
|
getStatus(): Promise<UnifiedPushStatus>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushNewEndpoint',
|
||||||
|
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushUnregistered',
|
||||||
|
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushRegistrationFailed',
|
||||||
|
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoredPusherState = {
|
||||||
|
endpoint: string;
|
||||||
|
appId: string;
|
||||||
|
};
|
||||||
|
|
||||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('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. */
|
/** Returns true when the current platform is Android Capacitor. */
|
||||||
export const isBackgroundSyncSupported = (): boolean =>
|
export const isBackgroundSyncSupported = (): boolean =>
|
||||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
/**
|
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
|
||||||
* Starts the native Matrix background sync service.
|
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
|
||||||
* Reads credentials directly from the active MatrixClient.
|
|
||||||
* Safe to call on every login — the service is idempotent.
|
|
||||||
* @param mx Authenticated Matrix client
|
|
||||||
*/
|
|
||||||
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
|
|
||||||
if (!isBackgroundSyncSupported()) return;
|
|
||||||
|
|
||||||
const homeserverUrl = mx.getHomeserverUrl();
|
const buildPusherAppId = (deviceId: string | null): string => {
|
||||||
const accessToken = mx.getAccessToken();
|
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
|
||||||
const userId = mx.getUserId();
|
return raw.length > 64 ? raw.slice(0, 64) : raw;
|
||||||
const deviceId = mx.getDeviceId();
|
};
|
||||||
|
|
||||||
if (!homeserverUrl || !accessToken || !userId) {
|
const loadStoredPusherState = (
|
||||||
console.warn('[BackgroundSync] Missing credentials, not starting');
|
userId: string | null,
|
||||||
return;
|
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 {
|
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<string> => {
|
||||||
|
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<void> {
|
||||||
|
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({
|
await MatrixBackgroundSync.start({
|
||||||
homeserverUrl,
|
homeserverUrl,
|
||||||
accessToken,
|
accessToken,
|
||||||
userId,
|
userId,
|
||||||
deviceId: deviceId ?? '',
|
deviceId: deviceId ?? '',
|
||||||
});
|
});
|
||||||
console.log('[BackgroundSync] Service started');
|
|
||||||
} catch (err) {
|
await this.syncExistingEndpoint();
|
||||||
console.warn('[BackgroundSync] Failed to start:', err);
|
console.log('[BackgroundSync] UnifiedPush registration requested');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<UnifiedPushStatus | undefined> {
|
||||||
|
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<void> => {
|
||||||
|
await unifiedPushManager.start(mx);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the native Matrix background sync service.
|
* Manually triggers a one-shot native fetch.
|
||||||
* Call on logout so the service stops and credentials are wiped.
|
* Useful for diagnostics and bridge testing.
|
||||||
*/
|
*/
|
||||||
export const stopBackgroundSync = async (): Promise<void> => {
|
export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
|
||||||
if (!isBackgroundSyncSupported()) return;
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await MatrixBackgroundSync.stop();
|
await MatrixBackgroundSync.triggerPing({ reason });
|
||||||
console.log('[BackgroundSync] Service stopped');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[BackgroundSync] Failed to stop:', err);
|
console.warn('[BackgroundSync] triggerPing failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
|
||||||
|
export const stopBackgroundSync = async (): Promise<void> => {
|
||||||
|
await unifiedPushManager.stop();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells the native service whether the app UI is in the foreground.
|
* Tells the native service whether the app UI is in the foreground.
|
||||||
* Call when document visibility changes so the service can suppress
|
* Call when document visibility changes so the service can suppress
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.85",
|
"version": "4.11.89",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user