feat: implement background sync service for Matrix integration
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.paarrot.app"
|
namespace = "com.paarrot.app"
|
||||||
@@ -36,6 +37,7 @@ dependencies {
|
|||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
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'
|
||||||
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"
|
||||||
|
|||||||
@@ -33,12 +33,29 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".MatrixSyncService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false"
|
||||||
|
android:stopWithTask="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|||||||
36
android/app/src/main/java/com/paarrot/app/BootReceiver.kt
Normal file
36
android/app/src/main/java/com/paarrot/app/BootReceiver.kt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restarts [MatrixSyncService] after the device boots, using credentials
|
||||||
|
* previously persisted by [SyncServicePlugin].
|
||||||
|
*/
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
|
||||||
|
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
val homeserver = prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
|
||||||
|
val token = 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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
package com.paarrot.app;
|
package com.paarrot.app;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
public class MainActivity extends BridgeActivity {
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(SyncServicePlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
238
android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
Normal file
238
android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent ForegroundService that maintains a Matrix /sync long-poll loop,
|
||||||
|
* showing native notifications for new messages when the main app process is alive
|
||||||
|
* 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() {
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
|
||||||
|
private val shownEventIds = HashSet<String>(64)
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val homeserver = intent?.getStringExtra(EXTRA_HOMESERVER)
|
||||||
|
?: prefs.getString(EXTRA_HOMESERVER, null)
|
||||||
|
val token = intent?.getStringExtra(EXTRA_TOKEN)
|
||||||
|
?: prefs.getString(EXTRA_TOKEN, null)
|
||||||
|
val userId = intent?.getStringExtra(EXTRA_USER_ID)
|
||||||
|
?: prefs.getString(EXTRA_USER_ID, null) ?: ""
|
||||||
|
|
||||||
|
if (homeserver == null || token == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
|
||||||
|
|
||||||
|
serviceScope.launch {
|
||||||
|
runSyncLoop(homeserver, token, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSyncLoop(homeserver: String, token: String, userId: String) {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
var since = prefs.getString(KEY_SINCE, null)
|
||||||
|
var isFirstSync = since == null
|
||||||
|
|
||||||
|
while (serviceScope.isActive) {
|
||||||
|
try {
|
||||||
|
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
|
||||||
|
val (responseCode, body) = doHttpGet(url, token)
|
||||||
|
|
||||||
|
when (responseCode) {
|
||||||
|
200 -> {
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
if (nextBatch != null) {
|
||||||
|
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
|
||||||
|
|
||||||
|
if (!isFirstSync && !appInForeground) {
|
||||||
|
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) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Sync error: ${e.message}")
|
||||||
|
delay(BACKOFF_ERRS_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSyncUrl(base: String, since: String?): String {
|
||||||
|
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 sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
|
||||||
|
return "$base/_matrix/client/v3/sync?timeout=30000&filter=$encodedFilter$sinceParam"
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doHttpGet(urlString: String, token: String): Pair<Int, String?> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
conn.connectTimeout = 5_000
|
||||||
|
conn.readTimeout = 35_000
|
||||||
|
val code = conn.responseCode
|
||||||
|
val body = if (code == 200) conn.inputStream.bufferedReader().readText() else null
|
||||||
|
Pair(code, body)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Pair(-1, null)
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||||
|
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
ensureMessageChannel(nm)
|
||||||
|
|
||||||
|
joinedRooms.keys().forEach { roomId ->
|
||||||
|
val timeline = joinedRooms.optJSONObject(roomId)
|
||||||
|
?.optJSONObject("timeline") ?: return@forEach
|
||||||
|
val events = timeline.optJSONArray("events") ?: return@forEach
|
||||||
|
|
||||||
|
for (i in 0 until events.length()) {
|
||||||
|
val event = events.optJSONObject(i) ?: continue
|
||||||
|
val eventId = event.optString("event_id")
|
||||||
|
|
||||||
|
if (event.optString("type") != "m.room.message") continue
|
||||||
|
if (event.optString("sender") == myUserId) continue
|
||||||
|
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||||
|
|
||||||
|
val content = event.optJSONObject("content") ?: continue
|
||||||
|
val body = content.optString("body").ifBlank { continue }
|
||||||
|
val sender = event.optString("sender")
|
||||||
|
val senderName = sender.substringAfter("@").substringBefore(":")
|
||||||
|
|
||||||
|
showMessageNotification(nm, senderName, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
?: Intent(this, MainActivity::class.java)
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0, launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle(sender)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
nm.notify(System.currentTimeMillis().toInt(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStatusNotification(): Notification {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_STATUS, "Sync Status",
|
||||||
|
NotificationManager.IMPORTANCE_MIN,
|
||||||
|
).apply {
|
||||||
|
description = "Paarrot background sync status"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_STATUS)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle("Paarrot")
|
||||||
|
.setContentText("Connected")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_MESSAGES, "Messages",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply { description = "Matrix message notifications" }
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MatrixSyncService"
|
||||||
|
const val EXTRA_HOMESERVER = "homeserver_url"
|
||||||
|
const val EXTRA_TOKEN = "access_token"
|
||||||
|
const val EXTRA_USER_ID = "user_id"
|
||||||
|
const val EXTRA_DEVICE_ID = "device_id"
|
||||||
|
const val PREFS = "matrix_sync_prefs"
|
||||||
|
const val KEY_SINCE = "since_token"
|
||||||
|
private const val NOTIF_ID_STATUS = 1001
|
||||||
|
private const val CHANNEL_STATUS = "sync_status"
|
||||||
|
private const val CHANNEL_MESSAGES = "messages"
|
||||||
|
private const val BACKOFF_ERRS_MS = 10_000L
|
||||||
|
|
||||||
|
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
|
||||||
|
@Volatile
|
||||||
|
var appInForeground = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacitor plugin that controls [MatrixSyncService] from JS.
|
||||||
|
*
|
||||||
|
* JS API:
|
||||||
|
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — start/update sync service
|
||||||
|
* - `stop()` — stop sync service and clear persisted credentials
|
||||||
|
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||||
|
* - `getStatus()` — returns `{ running: boolean }`
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||||
|
class SyncServicePlugin : Plugin() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the [MatrixSyncService] with the provided Matrix credentials.
|
||||||
|
* Persists credentials in SharedPreferences so [BootReceiver] can restart
|
||||||
|
* the service after a device reboot.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun start(call: PluginCall) {
|
||||||
|
val homeserver = call.getString("homeserverUrl")
|
||||||
|
?: return call.reject("homeserverUrl required")
|
||||||
|
val token = call.getString("accessToken")
|
||||||
|
?: return call.reject("accessToken required")
|
||||||
|
val userId = call.getString("userId") ?: ""
|
||||||
|
val deviceId = call.getString("deviceId") ?: ""
|
||||||
|
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putString(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
|
||||||
|
.putString(MatrixSyncService.EXTRA_TOKEN, token)
|
||||||
|
.putString(MatrixSyncService.EXTRA_USER_ID, userId)
|
||||||
|
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
val intent = Intent(context, MatrixSyncService::class.java).apply {
|
||||||
|
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(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the sync service and erases persisted credentials. */
|
||||||
|
@PluginMethod
|
||||||
|
fun stop(call: PluginCall) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
context.stopService(Intent(context, MatrixSyncService::class.java))
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the service whether the Capacitor WebView UI is currently visible.
|
||||||
|
* When `foreground == true` the service skips firing notifications because
|
||||||
|
* the JS layer handles them directly via LocalNotifications.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun setAppForeground(call: PluginCall) {
|
||||||
|
MatrixSyncService.appInForeground = call.getBoolean("foreground", false) ?: false
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether the sync service is currently running. */
|
||||||
|
@PluginMethod
|
||||||
|
fun getStatus(call: PluginCall) {
|
||||||
|
val am = context.getSystemService(Context.ACTIVITY_SERVICE)
|
||||||
|
as android.app.ActivityManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val running = am.getRunningServices(Int.MAX_VALUE)
|
||||||
|
.any { it.service.className == MatrixSyncService::class.java.name }
|
||||||
|
call.resolve(JSObject().apply { put("running", running) })
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFS = "sync_service_prefs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
|
ext {
|
||||||
|
kotlin_version = '1.9.25'
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -9,6 +12,7 @@ buildscript {
|
|||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
classpath 'com.google.gms:google-services:4.4.4'
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import {
|
|||||||
setupNotificationTapListener,
|
setupNotificationTapListener,
|
||||||
} from '../../utils/tauri';
|
} from '../../utils/tauri';
|
||||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||||
|
import {
|
||||||
|
startBackgroundSync,
|
||||||
|
stopBackgroundSync,
|
||||||
|
setAppForegroundState,
|
||||||
|
} from '../../utils/backgroundSync';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* Applies the selected emoji style font to the document.
|
||||||
@@ -413,6 +418,31 @@ function MessageNotifications() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the native Android background sync service on login, keeps it
|
||||||
|
* informed of foreground state so it doesn't double-fire notifications,
|
||||||
|
* and stops it cleanly on unmount (logout).
|
||||||
|
* Only active on Android Capacitor builds.
|
||||||
|
*/
|
||||||
|
function BackgroundSyncSetup() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startBackgroundSync(mx);
|
||||||
|
|
||||||
|
const onVisibility = () => setAppForegroundState(!document.hidden);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
setAppForegroundState(!document.hidden);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
stopBackgroundSync();
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the Paarrot API for Electron integration
|
* Initializes the Paarrot API for Electron integration
|
||||||
* Registers the navigate function and sets up IPC handlers
|
* Registers the navigate function and sets up IPC handlers
|
||||||
@@ -475,6 +505,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<BackgroundSyncSetup />
|
||||||
<PaarrotAPIInitializer />
|
<PaarrotAPIInitializer />
|
||||||
<TaskbarFlashStopper />
|
<TaskbarFlashStopper />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
91
overlay/src/app/utils/backgroundSync.ts
Normal file
91
overlay/src/app/utils/backgroundSync.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
import type { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
interface MatrixBackgroundSyncPlugin {
|
||||||
|
/** Start the background sync service with the given Matrix credentials. */
|
||||||
|
start(options: {
|
||||||
|
homeserverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
/** Stop the background sync service and clear persisted credentials. */
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Notify the service whether the app UI is visible.
|
||||||
|
* When foreground is true, the service suppresses native notifications
|
||||||
|
* because the JS layer handles them via LocalNotifications.
|
||||||
|
*/
|
||||||
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
|
/** Returns whether the sync service is currently running. */
|
||||||
|
getStatus(): Promise<{ running: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
|
||||||
|
/** Returns true when the current platform is Android Capacitor. */
|
||||||
|
export const isBackgroundSyncSupported = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the native Matrix background sync service.
|
||||||
|
* 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 accessToken = mx.getAccessToken();
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
const deviceId = mx.getDeviceId();
|
||||||
|
|
||||||
|
if (!homeserverUrl || !accessToken || !userId) {
|
||||||
|
console.warn('[BackgroundSync] Missing credentials, not starting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.start({
|
||||||
|
homeserverUrl,
|
||||||
|
accessToken,
|
||||||
|
userId,
|
||||||
|
deviceId: deviceId ?? '',
|
||||||
|
});
|
||||||
|
console.log('[BackgroundSync] Service started');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to start:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the native Matrix background sync service.
|
||||||
|
* Call on logout so the service stops and credentials are wiped.
|
||||||
|
*/
|
||||||
|
export const stopBackgroundSync = async (): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.stop();
|
||||||
|
console.log('[BackgroundSync] Service stopped');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to stop:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the native service whether the app UI is in the foreground.
|
||||||
|
* Call when document visibility changes so the service can suppress
|
||||||
|
* duplicate notifications while the JS layer is active.
|
||||||
|
* @param foreground true if the WebView UI is currently visible
|
||||||
|
*/
|
||||||
|
export const setAppForegroundState = async (foreground: boolean): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.setAppForeground({ foreground });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user