Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
044631b05d | ||
| 4bc5b4d790 | |||
| da52ec84b7 | |||
|
|
f758796d83 | ||
| 77d4071728 | |||
| ca855a6945 | |||
|
|
a56662ab69 | ||
| f870fd1c2a | |||
| 70f7d41d9a | |||
|
|
eba647c9c5 | ||
| 08b6dce4ee | |||
| 226f4863ff | |||
|
|
e88bebad3d | ||
| a0eb652098 |
@@ -1,4 +1,5 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
namespace = "com.paarrot.app"
|
||||
@@ -22,6 +23,11 @@ android {
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all { output ->
|
||||
outputFileName = "Paarrot-${variant.versionName}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -36,6 +42,7 @@ dependencies {
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
|
||||
@@ -33,11 +33,29 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</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>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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.CAMERA" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
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;
|
||||
|
||||
import android.os.Bundle;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
239
android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
Normal file
@@ -0,0 +1,239 @@
|
||||
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)
|
||||
|
||||
val roomIds = joinedRooms.keys().asSequence().toList()
|
||||
for (roomId in roomIds) {
|
||||
val timeline = joinedRooms.optJSONObject(roomId)
|
||||
?.optJSONObject("timeline") ?: continue
|
||||
val events = timeline.optJSONArray("events") ?: continue
|
||||
|
||||
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").takeIf { it.isNotBlank() } ?: 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
<color name="ic_launcher_background">#00000000</color>
|
||||
</resources>
|
||||
@@ -1,6 +1,9 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext {
|
||||
kotlin_version = '1.9.25'
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,6 +12,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
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
|
||||
// in the individual module build.gradle files
|
||||
|
||||
BIN
icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
icons/128x128.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
icons/16x16.png
Normal file
|
After Width: | Height: | Size: 839 B |
BIN
icons/24x24.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
icons/256x256.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/48x48.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
icons/512x512.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
icons/icon.icns
BIN
icons/icon.ico
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 138 KiB |
@@ -3,5 +3,6 @@
|
||||
"url": "http://synbox.ruv.wtf:8418/litruv/cinny-mobile",
|
||||
"author": "litruv",
|
||||
"name": "Paarrot",
|
||||
"overrideSource": "Forgejo (Codeberg)",
|
||||
"additionalSettings": "{\"apkFilterRegEx\":\"Paarrot\\\\.apk\",\"invertAPKFilter\":false,\"autoApkFilterByArch\":true,\"releaseIdSkipPatterns\":[],\"sortMethodChoice\":0}"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ import {
|
||||
setupNotificationTapListener,
|
||||
} from '../../utils/tauri';
|
||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||
import {
|
||||
startBackgroundSync,
|
||||
stopBackgroundSync,
|
||||
setAppForegroundState,
|
||||
} from '../../utils/backgroundSync';
|
||||
|
||||
/**
|
||||
* Applies the selected emoji style font to the document.
|
||||
@@ -413,6 +418,31 @@ function MessageNotifications() {
|
||||
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
|
||||
* Registers the navigate function and sets up IPC handlers
|
||||
@@ -475,6 +505,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<FaviconUpdater />
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<BackgroundSyncSetup />
|
||||
<PaarrotAPIInitializer />
|
||||
<TaskbarFlashStopper />
|
||||
{children}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
6503
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.76",
|
||||
"version": "4.11.81",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -120,7 +120,23 @@ if (!existsSync(androidJunction)) {
|
||||
console.log(` android/ link already exists, skipping`);
|
||||
}
|
||||
|
||||
// 7. Write android/local.properties with the current machine's Android SDK path
|
||||
// 8. Sync version from root package.json into android/app/build.gradle
|
||||
console.log('[apply-overlay] Syncing version into build.gradle...');
|
||||
const rootPkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
const version = rootPkg.version ?? '1.0.0';
|
||||
const parts = version.split('.').map(Number);
|
||||
// versionCode: major*10000 + minor*100 + patch (e.g. 4.11.78 -> 41178)
|
||||
const versionCode = (parts[0] ?? 0) * 10000 + (parts[1] ?? 0) * 100 + (parts[2] ?? 0);
|
||||
|
||||
const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
|
||||
let buildGradle = readFileSync(buildGradlePath, 'utf8');
|
||||
buildGradle = buildGradle
|
||||
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
|
||||
.replace(/versionName\s+"[^"]+"/, `versionName "${version}"`);
|
||||
writeFileSync(buildGradlePath, buildGradle, 'utf8');
|
||||
console.log(` versionName "${version}", versionCode ${versionCode}`);
|
||||
|
||||
// 9. Write android/local.properties with the current machine's Android SDK path
|
||||
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||
if (androidHome) {
|
||||
console.log('[apply-overlay] Writing android/local.properties...');
|
||||
|
||||
@@ -49,6 +49,56 @@ async function generateElectronIcons() {
|
||||
console.log('Generated: icon.icns');
|
||||
}
|
||||
|
||||
/**
|
||||
* Android adaptive icon density configs.
|
||||
* Foreground canvas = 108dp equivalent. Safe zone = center 72dp (66.7%).
|
||||
* Legacy launcher icon = 48dp equivalent.
|
||||
* @type {Array<{density: string, foregroundSize: number, launcherSize: number}>}
|
||||
*/
|
||||
const androidDensities = [
|
||||
{ density: 'mipmap-mdpi', foregroundSize: 108, launcherSize: 48 },
|
||||
{ density: 'mipmap-hdpi', foregroundSize: 162, launcherSize: 72 },
|
||||
{ density: 'mipmap-xhdpi', foregroundSize: 216, launcherSize: 96 },
|
||||
{ density: 'mipmap-xxhdpi', foregroundSize: 324, launcherSize: 144 },
|
||||
{ density: 'mipmap-xxxhdpi', foregroundSize: 432, launcherSize: 192 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Generates Android mipmap launcher icons from the source icon.
|
||||
* The foreground layer centers the content within the adaptive icon safe zone
|
||||
* (72/108dp = 66.7% of canvas), leaving transparent padding for the system mask.
|
||||
*/
|
||||
async function generateAndroidIcons() {
|
||||
const resDir = path.join(rootDir, 'android', 'app', 'src', 'main', 'res');
|
||||
|
||||
for (const { density, foregroundSize, launcherSize } of androidDensities) {
|
||||
const dir = path.join(resDir, density);
|
||||
|
||||
// Foreground layer: content occupies 72/108 of the canvas (safe zone), centered
|
||||
const contentSize = Math.round(foregroundSize * (72 / 108));
|
||||
const padding = Math.round((foregroundSize - contentSize) / 2);
|
||||
|
||||
const foregroundBuffer = await sharp(sourceIcon)
|
||||
.resize(contentSize, contentSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.extend({ top: padding, bottom: padding, left: padding, right: padding, background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await fs.writeFile(path.join(dir, 'ic_launcher_foreground.png'), foregroundBuffer);
|
||||
console.log(`${density}/ic_launcher_foreground.png (${foregroundSize}x${foregroundSize}, content ${contentSize}px)`);
|
||||
|
||||
// Legacy launcher icon (ic_launcher, ic_launcher_round) — plain square resize
|
||||
const legacyBuffer = await sharp(sourceIcon)
|
||||
.resize(launcherSize, launcherSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await fs.writeFile(path.join(dir, 'ic_launcher.png'), legacyBuffer);
|
||||
await fs.writeFile(path.join(dir, 'ic_launcher_round.png'), legacyBuffer);
|
||||
console.log(`${density}/ic_launcher.png + ic_launcher_round.png (${launcherSize}x${launcherSize})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Generating icons from:', sourceIcon);
|
||||
console.log('');
|
||||
@@ -57,6 +107,10 @@ async function main() {
|
||||
await generateElectronIcons();
|
||||
console.log('');
|
||||
|
||||
console.log('=== Android Mipmap Icons ===');
|
||||
await generateAndroidIcons();
|
||||
console.log('');
|
||||
|
||||
console.log('Done!');
|
||||
}
|
||||
|
||||
|
||||