Compare commits

..

14 Commits

Author SHA1 Message Date
GitHub Actions
47f1b2c40f chore: bump version to 4.11.83 [skip ci] 2026-04-07 12:48:26 +00:00
d04c1e8f77 fix: rename build job and streamline APK upload process
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-and-release (push) Successful in 4m45s
2026-04-07 22:48:17 +10:00
GitHub Actions
76a19f7e02 chore: bump version to 4.11.82 [skip ci] 2026-04-07 10:57:10 +00:00
3d35ea5930 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 4m37s
Build / create-release (push) Has been skipped
2026-04-07 20:57:03 +10:00
9fe79de3b5 fix: update upload artifact action to v4 and adjust APK naming convention 2026-04-07 20:57:02 +10:00
GitHub Actions
044631b05d chore: bump version to 4.11.81 [skip ci] 2026-04-07 10:45:54 +00:00
4bc5b4d790 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 5m11s
Build / create-release (push) Failing after 3m7s
2026-04-07 20:45:46 +10:00
da52ec84b7 fix: improve handling of joined rooms and event notifications in MatrixSyncService 2026-04-07 20:45:44 +10:00
GitHub Actions
f758796d83 chore: bump version to 4.11.80 [skip ci] 2026-04-07 10:42:53 +00:00
77d4071728 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 6s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
2026-04-07 20:42:44 +10:00
ca855a6945 feat: sync version from package.json into build.gradle and set APK output name 2026-04-07 20:42:43 +10:00
GitHub Actions
a56662ab69 chore: bump version to 4.11.79 [skip ci] 2026-04-07 10:36:35 +00:00
f870fd1c2a Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 4m21s
Build / create-release (push) Has been skipped
2026-04-07 20:36:27 +10:00
70f7d41d9a feat: implement background sync service for Matrix integration 2026-04-07 20:36:25 +10:00
12 changed files with 563 additions and 53 deletions

View File

@@ -47,7 +47,7 @@ jobs:
git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]"
git push
build-android:
build-and-release:
runs-on: ubuntu-latest
needs: increment-version
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
@@ -56,6 +56,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: false
ref: ${{ github.ref_name }}
@@ -92,28 +93,6 @@ jobs:
- name: Apply overlay and build Android APK
run: npm run android:apk:linux
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: Paarrot-Android-debug.apk
path: android/app/build/outputs/apk/debug/app-debug.apk
create-release:
runs-on: ubuntu-latest
needs: [increment-version, build-android]
if: always() && needs.build-android.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/master'
run: git pull origin master
- name: Get version
id: version
run: |
@@ -143,21 +122,6 @@ jobs:
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release-files
find artifacts -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.nupkg" -o -name "RELEASES*" \) -exec cp {} release-files/ \;
APK=$(find artifacts -type f -name "*.apk" | head -1)
if [ -n "$APK" ]; then
cp "$APK" "release-files/Paarrot.apk"
fi
ls -la release-files/
- name: Create or Update Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -167,6 +131,13 @@ jobs:
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
REPO="litruv/cinny-mobile"
APK=$(find android/app/build/outputs/apk/debug -name "Paarrot-*.apk" | head -1)
if [ -z "$APK" ]; then
echo "No APK found!"
exit 1
fi
echo "Found APK: $APK"
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
@@ -188,16 +159,13 @@ jobs:
done
fi
echo "Uploading assets..."
for FILE in release-files/*; do
FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME}..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${FILE}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo ""
done
FILENAME="Paarrot-${VERSION}.apk"
echo "Uploading ${FILENAME}..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${APK}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo ""
echo "Release complete!"

View File

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

View File

@@ -33,12 +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" />

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

View File

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

View 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
}
}

View File

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

View File

@@ -1,7 +1,10 @@
// 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()
mavenCentral()
@@ -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

View File

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

View 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);
}
};

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.78",
"version": "4.11.83",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"

View File

@@ -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...');