Compare commits

...

23 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
GitHub Actions
eba647c9c5 chore: bump version to 4.11.78 [skip ci] 2026-04-07 09:42:27 +00:00
08b6dce4ee Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m31s
Build / create-release (push) Successful in 8s
2026-04-07 19:42:19 +10:00
226f4863ff feat: add POST_NOTIFICATIONS permission to AndroidManifest.xml 2026-04-07 19:42:17 +10:00
GitHub Actions
e88bebad3d chore: bump version to 4.11.77 [skip ci] 2026-04-07 07:13:09 +00:00
a0eb652098 Add new 64x64 icon image in PNG format
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m16s
Build / create-release (push) Successful in 8s
2026-04-07 17:12:59 +10:00
GitHub Actions
53bfd1c84e chore: bump version to 4.11.76 [skip ci] 2026-04-07 06:46:45 +00:00
b179c46e35 chore: update subproject commit reference to 0659f3c
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m23s
Build / create-release (push) Successful in 7s
2026-04-07 16:45:29 +10:00
GitHub Actions
338e51bcbc chore: bump version to 4.11.75 [skip ci] 2026-04-07 06:18:20 +00:00
cce84abfe1 feat: rename release APK to Paarrot.apk, add obtainium.json
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 8s
2026-04-07 16:18:13 +10:00
43 changed files with 636 additions and 6550 deletions

View File

@@ -47,7 +47,7 @@ jobs:
git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]" git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]"
git push git push
build-android: build-and-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: increment-version needs: increment-version
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped') if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
@@ -56,6 +56,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0
submodules: false submodules: false
ref: ${{ github.ref_name }} ref: ${{ github.ref_name }}
@@ -92,28 +93,6 @@ jobs:
- name: Apply overlay and build Android APK - name: Apply overlay and build Android APK
run: npm run android:apk:linux 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 - name: Get version
id: version id: version
run: | run: |
@@ -143,17 +122,6 @@ jobs:
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}" git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "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 "*.apk" -o -name "RELEASES*" \) -exec cp {} release-files/ \;
ls -la release-files/
- name: Create or Update Release - name: Create or Update Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -163,6 +131,13 @@ jobs:
API_BASE="http://synbox.ruv.wtf:8418/api/v1" API_BASE="http://synbox.ruv.wtf:8418/api/v1"
REPO="litruv/cinny-mobile" 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}" \ RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty') "${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
@@ -184,16 +159,13 @@ jobs:
done done
fi fi
echo "Uploading assets..." FILENAME="Paarrot-${VERSION}.apk"
for FILE in release-files/*; do
FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME}..." echo "Uploading ${FILENAME}..."
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@${FILE}" \ --data-binary "@${APK}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" "${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo "" echo ""
done
echo "Release complete!" echo "Release complete!"

View File

@@ -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"
@@ -22,6 +23,11 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }
applicationVariants.all { variant ->
variant.outputs.all { output ->
outputFileName = "Paarrot-${variant.versionName}.apk"
}
}
} }
repositories { repositories {
@@ -36,6 +42,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"

View File

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#00000000</color>
</resources> </resources>

View File

@@ -1,6 +1,9 @@
// 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()
@@ -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

2
cinny

Submodule cinny updated: f64ce6168d...0659f3c1b0

BIN
icons/1024x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

BIN
icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

BIN
icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 138 KiB

8
obtainium.json Normal file
View File

@@ -0,0 +1,8 @@
{
"id": "com.paarrot.app",
"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}"
}

View File

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

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

6503
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.74", "version": "4.11.83",
"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"

View File

@@ -120,7 +120,23 @@ if (!existsSync(androidJunction)) {
console.log(` android/ link already exists, skipping`); 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; const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
if (androidHome) { if (androidHome) {
console.log('[apply-overlay] Writing android/local.properties...'); console.log('[apply-overlay] Writing android/local.properties...');

View File

@@ -49,6 +49,56 @@ async function generateElectronIcons() {
console.log('Generated: icon.icns'); 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() { async function main() {
console.log('Generating icons from:', sourceIcon); console.log('Generating icons from:', sourceIcon);
console.log(''); console.log('');
@@ -57,6 +107,10 @@ async function main() {
await generateElectronIcons(); await generateElectronIcons();
console.log(''); console.log('');
console.log('=== Android Mipmap Icons ===');
await generateAndroidIcons();
console.log('');
console.log('Done!'); console.log('Done!');
} }