Compare commits

..

42 Commits

Author SHA1 Message Date
GitHub Actions
e11304d093 chore: bump version to 4.11.105 [skip ci] 2026-05-13 00:15:56 +00:00
60d76d5165 feat(mobile): add confirmation dialog back to share picker with backdrop
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m35s
Build / create-release (push) Successful in 14s
2026-05-13 10:15:47 +10:00
GitHub Actions
373e700a07 chore: bump version to 4.11.104 [skip ci] 2026-05-13 00:13:42 +00:00
f7b25261cc fix(mobile): make share picker fullscreen with full-width search, remove confirmation dialog
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Has started running
Build / create-release (push) Has been cancelled
2026-05-13 10:13:35 +10:00
GitHub Actions
41e1b0db9d chore: bump version to 4.11.103 [skip ci] 2026-05-13 00:08:24 +00:00
d1022635d2 feat(mobile): improve share dialog UI with padding, full-width search, and confirmation
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-05-13 10:08:16 +10:00
GitHub Actions
2e6be4b0b3 chore: bump version to 4.11.102 [skip ci] 2026-05-12 23:59:14 +00:00
0d97676edd fix(mobile): send shared content immediately instead of staging in draft
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m51s
Build / create-release (push) Successful in 14s
2026-05-13 09:59:07 +10:00
GitHub Actions
f8e8058de3 chore: bump version to 4.11.101 [skip ci] 2026-05-12 23:54:07 +00:00
a1577a3b97 fix(mobile): navigate to room after share picker selection
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m28s
Build / create-release (push) Successful in 15s
2026-05-13 09:53:57 +10:00
GitHub Actions
80f1df6588 chore: bump version to 4.11.100 [skip ci] 2026-05-12 23:44:23 +00:00
375fa74309 fix: restore live server validation while keeping input stable during typing
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-05-13 09:44:14 +10:00
GitHub Actions
0862094e00 chore: bump version to 4.11.99 [skip ci] 2026-05-12 23:41:58 +00:00
45d66bbe28 fix(mobile): prevent server input focus loss and text modification while typing
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-05-13 09:41:49 +10:00
GitHub Actions
919bc310a7 chore: bump version to 4.11.98 [skip ci] 2026-05-12 23:32:30 +00:00
badcfb160c fix(mobile): handle Android keyboard autocorrect text duplication in overlay
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s
2026-05-13 09:32:22 +10:00
GitHub Actions
1b35f71bb8 chore: bump version to 4.11.97 [skip ci] 2026-05-12 22:59:45 +00:00
96349bf200 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m26s
Build / create-release (push) Successful in 14s
2026-05-13 08:59:38 +10:00
472d30940d chore: remove extra whitespace in README.md 2026-05-13 08:59:36 +10:00
GitHub Actions
ae9d2bfeb6 chore: bump version to 4.11.96 [skip ci] 2026-05-12 22:52:08 +00:00
61b82d1f40 fix: use consistent signing cert for all builds to prevent update conflicts
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m53s
Build / create-release (push) Successful in 14s
2026-05-13 08:51:57 +10:00
GitHub Actions
b008011d5f chore: bump version to 4.11.95 [skip ci] 2026-05-12 22:42:45 +00:00
938f000f65 chore: remove extra newline in README.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 08:42:35 +10:00
GitHub Actions
7fdf369883 chore: bump version to 4.11.94 [skip ci] 2026-05-12 22:28:54 +00:00
c9883a1279 chore: remove extra newline in SIGNING_SETUP.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m49s
Build / create-release (push) Successful in 23s
2026-05-13 08:28:45 +10:00
GitHub Actions
5f09b0dd71 chore: bump version to 4.11.93 [skip ci] 2026-05-12 22:20:57 +00:00
0e436f076e 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 5m7s
Build / create-release (push) Successful in 14s
2026-05-13 08:20:49 +10:00
2281b31894 feat: add signing configuration for debug and release builds based on keystore properties 2026-05-13 08:20:47 +10:00
GitHub Actions
0eda439897 chore: bump version to 4.11.92 [skip ci] 2026-05-12 22:03:52 +00:00
a1bd03b277 chore: update subproject commit reference to 7ef9939
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m42s
Build / create-release (push) Successful in 14s
2026-05-13 08:03:43 +10:00
GitHub Actions
1c2904898f chore: bump version to 4.11.91 [skip ci] 2026-04-28 05:46:30 +00:00
0fe41a55c1 feat: add custom notification sound for message alerts in MatrixSyncService
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m56s
Build / create-release (push) Successful in 14s
2026-04-28 15:46:20 +10:00
af2f7dbcbc feat: enhance MatrixSyncService with user profile handling and image notifications 2026-04-26 18:54:58 +10:00
GitHub Actions
0a4a9aa636 chore: bump version to 4.11.90 [skip ci] 2026-04-26 04:32:30 +00:00
746d0aa384 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 5m42s
Build / create-release (push) Successful in 14s
2026-04-26 14:32:22 +10:00
c263b681d4 feat: add ShareRoomPicker component for selecting rooms to share Android content
- Implemented ShareRoomPicker component to allow users to pick a room for sharing content received from Android intents.
- Integrated search functionality to filter rooms and directs.
- Added virtualized list for efficient rendering of room options.
- Created utility functions for handling Android share payloads, including fetching and clearing pending shares.

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:32:19 +10:00
GitHub Actions
6f583d1227 chore: bump version to 4.11.89 [skip ci] 2026-04-26 00:56:21 +00:00
6df60c8348 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-04-26 10:56:12 +10:00
79d46857f9 feat: integrate UnifiedPush for background synchronization and event handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 10:56:10 +10:00
GitHub Actions
c9ac92a0c4 chore: bump version to 4.11.88 [skip ci] 2026-04-26 00:18:07 +00:00
cb44bc92e4 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 5m9s
Build / create-release (push) Successful in 13s
2026-04-26 10:17:58 +10:00
bc07974b4b feat: implement push ping receiver and one-shot sync fetch functionality
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 10:17:56 +10:00
27 changed files with 2333 additions and 164 deletions

4
android/.gitignore vendored
View File

@@ -52,10 +52,8 @@ captures/
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files - NEVER commit these!
# Keystore files
*.jks
*.keystore
keystore.properties
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild

View File

@@ -1,5 +1,5 @@
# Android App Signing Setup
## Why This Matters
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.

View File

@@ -27,16 +27,24 @@ android {
applicationId "com.paarrot.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 1777176952
versionName "2026-04-26.041552.151"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
buildTypes {
debug {
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@@ -65,6 +73,7 @@ dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.unifiedpush.android:connector:3.3.2'
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

@@ -22,6 +22,24 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<provider
@@ -40,6 +58,14 @@
android:exported="false"
android:stopWithTask="false" />
<service
android:name=".UnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".BootReceiver"
android:exported="true">

View File

@@ -3,11 +3,9 @@ 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].
* Re-registers UnifiedPush after boot when credentials still exist.
*/
class BootReceiver : BroadcastReceiver() {
@@ -15,22 +13,9 @@ class BootReceiver : BroadcastReceiver() {
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) ?: ""
prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
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)
}
UnifiedPushManager.register(context, activity = null)
}
}

View File

@@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(SyncServicePlugin.class);
registerPlugin(ShareHandlerPlugin.class);
super.onCreate(savedInstanceState);
}
}
}

View File

@@ -5,18 +5,20 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.media.AudioAttributes
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
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
@@ -25,12 +27,8 @@ 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.
* ForegroundService that performs a short, one-shot Matrix /sync fetch after
* a wake ping from UnifiedPush or a manual diagnostic trigger.
*/
class MatrixSyncService : Service() {
@@ -40,6 +38,14 @@ class MatrixSyncService : Service() {
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
private val shownEventIds = HashSet<String>(64)
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
private val displayNameCache = HashMap<String, String>(16)
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
private val avatarCache = HashMap<String, Bitmap?>(16)
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -57,13 +63,20 @@ class MatrixSyncService : Service() {
return START_NOT_STICKY
}
val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: MODE_ONE_SHOT
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
serviceScope.launch {
runSyncLoop(homeserver, token, userId)
try {
runSingleSyncFetch(homeserver, token, userId)
Log.d(TAG, "One-shot sync completed (reason=$triggerReason)")
} finally {
stopForegroundCompat()
stopSelf(startId)
}
}
return START_STICKY
return START_NOT_STICKY
}
override fun onDestroy() {
@@ -71,44 +84,44 @@ class MatrixSyncService : Service() {
job.cancel()
}
private suspend fun runSyncLoop(homeserver: String, token: String, userId: String) {
private suspend fun runSingleSyncFetch(homeserver: String, token: String, userId: String) {
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var since = prefs.getString(KEY_SINCE, null)
var isFirstSync = since == null
val since = prefs.getString(KEY_SINCE, null)
val isFirstSync = since == null
while (serviceScope.isActive) {
try {
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
val (responseCode, body) = doHttpGet(url, token)
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() }
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 (nextBatch != null) {
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
if (!isFirstSync && !appInForeground) {
processRoomEvents(json, userId)
}
isFirstSync = false
since = nextBatch
if (!isFirstSync && !appInForeground) {
processRoomEvents(json, userId)
}
}
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)
401, 403 -> {
Log.w(TAG, "Auth error $responseCode — clearing credentials")
applicationContext
.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
.edit()
.clear()
.apply()
}
else -> {
Log.w(TAG, "Sync fetch failed with HTTP $responseCode")
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Sync error: ${e.message}")
}
}
@@ -116,7 +129,7 @@ class MatrixSyncService : Service() {
val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}"""
val 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"
return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam"
}
private suspend fun doHttpGet(urlString: String, token: String): Pair<Int, String?> =
@@ -139,6 +152,9 @@ class MatrixSyncService : Service() {
}
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannel(nm)
@@ -158,16 +174,119 @@ class MatrixSyncService : Service() {
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(":")
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
val body = when (msgtype) {
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
}
showMessageNotification(nm, senderName, body)
val sender = event.optString("sender")
val profile = resolveProfile(sender, homeserver, token)
// For unencrypted image/sticker messages, try to download a preview bitmap.
// Encrypted messages have a `file` object instead of a top-level `url`.
val inlineImage: Bitmap? = if (
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
) {
content.optString("url").takeIf { it.startsWith("mxc://") }
?.let { mxcToDownloadUrl(it, homeserver) }
?.let { downloadBitmap(it, token) }
} else null
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
}
}
}
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
val cachedName = displayNameCache[mxid]
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
val fallback = mxid.substringAfter("@").substringBefore(":")
return try {
val encodedId = URLEncoder.encode(mxid, "UTF-8")
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
val conn = URL(url).openConnection() as HttpURLConnection
val (displayName, avatarMxc) = try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
conn.connectTimeout = 4_000
conn.readTimeout = 4_000
if (conn.responseCode == 200) {
val body = conn.inputStream.bufferedReader().readText()
val obj = JSONObject(body)
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
Pair(name, mxc)
} else {
Pair(fallback, null)
}
} finally {
conn.disconnect()
}
displayNameCache[mxid] = displayName
val avatar = avatarMxc
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
avatarCache[mxid] = avatar
UserProfile(displayName, avatar)
} catch (e: Exception) {
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
displayNameCache[mxid] = fallback
avatarCache[mxid] = null
UserProfile(fallback, null)
}
}
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
}
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
}
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.connectTimeout = 5_000
conn.readTimeout = 10_000
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
null
}
}
private fun showMessageNotification(
nm: NotificationManager,
sender: String,
body: String,
largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null,
) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?: Intent(this, MainActivity::class.java)
val pi = PendingIntent.getActivity(
@@ -175,16 +294,25 @@ class MatrixSyncService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
val builder = 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)
if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
}
nm.notify(System.currentTimeMillis().toInt(), builder.build())
}
private fun buildStatusNotification(): Notification {
@@ -203,18 +331,37 @@ class MatrixSyncService : Service() {
return NotificationCompat.Builder(this, CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle("Paarrot")
.setContentText("Connected")
.setContentText("Checking for new messages")
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true)
.build()
}
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
private fun ensureMessageChannel(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
val channel = NotificationChannel(
CHANNEL_MESSAGES, "Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply { description = "Matrix message notifications" }
).apply {
description = "Matrix message notifications"
setSound(
soundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
nm.createNotificationChannel(channel)
}
}
@@ -225,15 +372,52 @@ class MatrixSyncService : Service() {
const val EXTRA_TOKEN = "access_token"
const val EXTRA_USER_ID = "user_id"
const val EXTRA_DEVICE_ID = "device_id"
const val EXTRA_TRIGGER_REASON = "trigger_reason"
const val PREFS = "matrix_sync_prefs"
const val KEY_SINCE = "since_token"
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
private const val NOTIF_ID_STATUS = 1001
private const val CHANNEL_STATUS = "sync_status"
private const val CHANNEL_MESSAGES = "messages"
private const val BACKOFF_ERRS_MS = 10_000L
private const val CHANNEL_MESSAGES = "messages_paarrot"
private const val MIN_WAKE_INTERVAL_MS = 7_500L
const val MODE_ONE_SHOT = "one_shot"
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
@Volatile
var appInForeground = false
/**
* Starts a one-shot sync fetch if credentials are available and the call is not rate-limited.
*/
fun requestSyncFetch(context: Context, reason: String) {
val credsPrefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = credsPrefs.getString(EXTRA_HOMESERVER, null) ?: return
val token = credsPrefs.getString(EXTRA_TOKEN, null) ?: return
val userId = credsPrefs.getString(EXTRA_USER_ID, null) ?: ""
val deviceId = credsPrefs.getString(EXTRA_DEVICE_ID, null) ?: ""
val syncPrefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val now = System.currentTimeMillis()
val lastWake = syncPrefs.getLong(KEY_LAST_WAKE_MS, 0L)
if (now - lastWake < MIN_WAKE_INTERVAL_MS) {
Log.d(TAG, "Skipping wake: rate-limited ($reason)")
return
}
syncPrefs.edit().putLong(KEY_LAST_WAKE_MS, now).apply()
val intent = Intent(context, MatrixSyncService::class.java).apply {
putExtra(EXTRA_HOMESERVER, homeserver)
putExtra(EXTRA_TOKEN, token)
putExtra(EXTRA_USER_ID, userId)
putExtra(EXTRA_DEVICE_ID, deviceId)
putExtra(EXTRA_TRIGGER_REASON, reason)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
}

View File

@@ -0,0 +1,196 @@
package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
import java.io.FileOutputStream
/** Payload for a single shared file. */
data class SharedFilePayload(
val path: String,
val name: String,
val mimeType: String,
val size: Long,
)
/** Full payload delivered to JS when a share intent is received. */
data class SharePayload(
val text: String?,
val subject: String?,
val files: List<SharedFilePayload>,
val receivedAt: Long,
) {
/** Serialize to a [JSObject] for the Capacitor bridge. */
fun toJsObject(): JSObject {
val obj = JSObject()
obj.put("text", text)
obj.put("subject", subject)
val filesArray = JSArray()
for (f in files) {
val fileObj = JSObject()
fileObj.put("path", f.path)
fileObj.put("name", f.name)
fileObj.put("mimeType", f.mimeType)
fileObj.put("size", f.size)
filesArray.put(fileObj)
}
obj.put("files", filesArray)
obj.put("receivedAt", receivedAt)
return obj
}
}
/** Singleton that bridges between Android activity lifecycle and the Capacitor plugin. */
object ShareIntentStore {
var plugin: ShareHandlerPlugin? = null
var pendingShare: SharePayload? = null
/** Parse [intent] and, if it is a share action, store the payload and notify JS. */
fun handleIntent(context: Context, intent: Intent?) {
if (intent == null) return
val action = intent.action ?: return
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND_MULTIPLE) {
@Suppress("UNCHECKED_CAST")
val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
list?.let { uris.addAll(it) }
} else {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let { uris.add(it) }
}
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
val payload = SharePayload(
text = text,
subject = subject,
files = copiedFiles,
receivedAt = receivedAt,
)
pendingShare = payload
plugin?.emitShare(payload)
}
private fun copySharedUri(context: Context, uri: Uri, receivedAt: Long): SharedFilePayload? {
return try {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val originalName = resolver.query(uri, arrayOf("_display_name"), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: "shared_file"
val safeName = sanitizeFileName(originalName)
val destDir = File(context.cacheDir, "shared-intents/$receivedAt")
destDir.mkdirs()
var destFile = File(destDir, safeName)
var counter = 1
while (destFile.exists()) {
val dot = safeName.lastIndexOf('.')
val base = if (dot >= 0) safeName.substring(0, dot) else safeName
val ext = if (dot >= 0) safeName.substring(dot) else ""
destFile = File(destDir, "${base}_$counter$ext")
counter++
}
resolver.openInputStream(uri)?.use { input ->
FileOutputStream(destFile).use { output -> input.copyTo(output) }
}
SharedFilePayload(
path = destFile.absolutePath,
name = destFile.name,
mimeType = mimeType,
size = destFile.length(),
)
} catch (e: Exception) {
android.util.Log.e("ShareHandlerPlugin", "Failed to copy shared URI: $uri", e)
null
}
}
private fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
}
/** Remove cached files from a previous share and clear the stored payload. */
fun clearIntent(receivedAt: Long?) {
if (receivedAt != null) {
val dir = plugin?.context?.let { File(it.cacheDir, "shared-intents/$receivedAt") }
dir?.deleteRecursively()
}
pendingShare = null
}
}
/** Capacitor plugin that exposes Android share intents to JavaScript. */
@CapacitorPlugin(name = "AndroidShareHandler")
class ShareHandlerPlugin : Plugin() {
override fun load() {
ShareIntentStore.plugin = this
ShareIntentStore.handleIntent(context, activity.intent)
}
override fun handleOnNewIntent(intent: Intent?) {
super.handleOnNewIntent(intent)
ShareIntentStore.handleIntent(context, intent)
}
/** Fire the `shareReceived` event toward the JS layer. */
fun emitShare(payload: SharePayload) {
notifyListeners("shareReceived", payload.toJsObject())
}
/** Returns the pending share payload (if any) as a JS object. */
@PluginMethod
fun getPendingShare(call: PluginCall) {
val pending = ShareIntentStore.pendingShare
if (pending == null) {
val result = JSObject()
result.put("share", JSObject.NULL)
call.resolve(result)
} else {
val result = JSObject()
result.put("share", pending.toJsObject())
call.resolve(result)
}
}
/** Clears the pending share and removes any copied temp files. */
@PluginMethod
fun clearPendingShare(call: PluginCall) {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
call.resolve()
}
}

View File

@@ -2,7 +2,6 @@ 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
@@ -13,19 +12,30 @@ 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
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist credentials and register UnifiedPush
* - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake)
* - `stop()` — clear persisted credentials and unregister UnifiedPush
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
* - `getStatus()` — returns `{ running: boolean }`
* - `getStatus()` — returns current fetch and UnifiedPush state
*/
@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.
*/
fun emitUnifiedPushEvent(eventName: String, payload: JSObject) {
notifyListeners(eventName, payload, true)
}
override fun load() {
super.load()
UnifiedPushManager.setPlugin(this)
}
override fun handleOnDestroy() {
UnifiedPushManager.clearPlugin(this)
super.handleOnDestroy()
}
/** Persists Matrix credentials and starts UnifiedPush registration. */
@PluginMethod
fun start(call: PluginCall) {
val homeserver = call.getString("homeserverUrl")
@@ -42,19 +52,16 @@ class SyncServicePlugin : Plugin() {
.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)
}
UnifiedPushManager.register(context, activity)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
call.resolve()
}
/** Manually triggers a one-shot sync fetch (mainly for diagnostics/testing). */
@PluginMethod
fun triggerPing(call: PluginCall) {
val reason = call.getString("reason") ?: "manual_plugin_ping"
MatrixSyncService.requestSyncFetch(context, reason)
call.resolve()
}
@@ -63,6 +70,7 @@ class SyncServicePlugin : Plugin() {
fun stop(call: PluginCall) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
context.stopService(Intent(context, MatrixSyncService::class.java))
UnifiedPushManager.unregister(context)
call.resolve()
}
@@ -85,7 +93,8 @@ class SyncServicePlugin : Plugin() {
@Suppress("DEPRECATION")
val running = am.getRunningServices(Int.MAX_VALUE)
.any { it.service.className == MatrixSyncService::class.java.name }
call.resolve(JSObject().apply { put("running", running) })
val result = UnifiedPushManager.getStatus(context).apply { put("running", running) }
call.resolve(result)
}
companion object {

View File

@@ -0,0 +1,149 @@
package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.util.Log
import com.getcapacitor.JSObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
import org.unifiedpush.android.connector.UnifiedPush
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
object UnifiedPushManager {
private const val TAG = "UnifiedPushManager"
private const val PREFS = "unifiedpush_prefs"
private const val KEY_ENDPOINT = "endpoint"
private const val KEY_INSTANCE = "instance"
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
private const val DEFAULT_MESSAGE = "Paarrot notifications"
@Volatile
private var plugin: SyncServicePlugin? = null
fun setPlugin(plugin: SyncServicePlugin) {
this.plugin = plugin
}
fun clearPlugin(plugin: SyncServicePlugin) {
if (this.plugin === plugin) {
this.plugin = null
}
}
fun register(context: Context, activity: Activity?) {
val savedDistributor = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
if (savedDistributor.isNotBlank()) {
requestRegistration(context)
return
}
if (activity == null) {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
return
}
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
if (success) {
requestRegistration(context)
} else {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
}
}
}
fun unregister(context: Context) {
runCatching {
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
}.onFailure {
Log.w(TAG, "Failed to unregister UnifiedPush: ${it.message}")
}
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, INSTANCE_DEFAULT)
}
fun getStatus(context: Context): JSObject {
val status = JSObject()
status.put("endpoint", getEndpoint(context) ?: "")
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
status.put("registered", !getEndpoint(context).isNullOrBlank())
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
status.put(
"distributors",
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>())
)
return status
}
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
val previousEndpoint = getEndpoint(context)
persistEndpoint(context, endpoint, instance)
val payload = JSObject().apply {
put("endpoint", endpoint)
put("instance", instance)
put("previousEndpoint", previousEndpoint ?: "")
}
dispatch(EVENT_NEW_ENDPOINT, payload)
}
fun onRegistrationFailed(reason: String, instance: String) {
dispatchRegistrationFailed(reason, instance)
}
fun onUnregistered(context: Context, instance: String) {
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, instance)
}
private fun requestRegistration(context: Context) {
runCatching {
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
}.onFailure {
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
}
}
private fun dispatchUnregistered(previousEndpoint: String?, instance: String) {
val payload = JSObject().apply {
put("previousEndpoint", previousEndpoint ?: "")
put("instance", instance)
}
dispatch(EVENT_UNREGISTERED, payload)
}
private fun dispatchRegistrationFailed(reason: String, instance: String) {
val payload = JSObject().apply {
put("reason", reason)
put("instance", instance)
}
dispatch(EVENT_REGISTRATION_FAILED, payload)
}
private fun dispatch(eventName: String, payload: JSObject) {
plugin?.emitUnifiedPushEvent(eventName, payload)
}
private fun persistEndpoint(context: Context, endpoint: String, instance: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_ENDPOINT, endpoint)
.putString(KEY_INSTANCE, instance)
.apply()
}
private fun clearEndpoint(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_ENDPOINT)
.remove(KEY_INSTANCE)
.apply()
}
private fun getEndpoint(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)
private fun getInstance(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_INSTANCE, null)
}

View File

@@ -0,0 +1,35 @@
package com.paarrot.app
import android.util.Log
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
/** Receives UnifiedPush events and turns push messages into Matrix fetch wakes. */
class UnifiedPushService : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
Log.i(TAG, "UnifiedPush endpoint updated for instance=$instance")
UnifiedPushManager.onNewEndpoint(applicationContext, endpoint.url, instance)
}
override fun onMessage(message: PushMessage, instance: String) {
Log.d(TAG, "UnifiedPush wake ping received for instance=$instance, bytes=${message.content.size}")
MatrixSyncService.requestSyncFetch(applicationContext, "unifiedpush_message")
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UnifiedPush registration failed: $reason")
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
}
override fun onUnregistered(instance: String) {
Log.i(TAG, "UnifiedPush unregistered for instance=$instance")
UnifiedPushManager.onUnregistered(applicationContext, instance)
}
private companion object {
const val TAG = "UnifiedPushService"
}
}

View File

@@ -2,15 +2,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2,9.1C3.8,5.7 7.2,4 11.6,4c4.4,0 7.8,1.7 10.4,5.1l-2,1.6c-1.7,-1.5 -3.8,-2.4 -6.4,-2.7c-0.2,1.6 -1.6,2.9 -3.3,2.9c-1.7,0 -3.1,-1.2 -3.3,-2.8C5.1,8.5 3.5,9.1 2,10z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8.7,11.5c-2.8,0 -5.1,2.3 -5.1,5.1c0,3 2.4,5.4 5.5,5.4c2.9,0 5.4,-1.3 7,-3.7c-0.5,0.1 -0.9,0.2 -1.4,0.2c-3.5,0 -6.4,-2.8 -6.4,-6.3c0,-0.2 0,-0.4 0,-0.7z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M14.7,12.3c3,0.1 5.3,1.6 7.3,4.2c-1.8,1.5 -4.1,2.4 -6.9,2.8c1,-1 1.6,-2.3 1.6,-3.8c0,-1.2 -0.3,-2.3 -0.9,-3.2z" />
android:viewportWidth="18"
android:viewportHeight="18">
<!--
Converted from public/res/svg/notification.svg.
Transforms flattened: outer(1.05922,0,0,1.05922,-0.5628,-0.4805)
x inner(1.6762,0,0,1.6762,-4.5071,-10.7901)
= scale 1.7754, translate(-5.337,-11.908)
-->
<group
android:scaleX="1.7754"
android:scaleY="1.7754"
android:translateX="-5.337"
android:translateY="-11.908">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z" />
</group>
</vector>

Binary file not shown.

View File

@@ -2,7 +2,7 @@
buildscript {
ext {
kotlin_version = '1.9.25'
kotlin_version = '2.3.0'
}
repositories {

View File

@@ -0,0 +1,4 @@
storeFile=../paarrot-release.keystore
storePassword=paarrot2026
keyAlias=paarrot
keyPassword=paarrot2026

Binary file not shown.

2
cinny

Submodule cinny updated: a3dac17b7c...7ef9939d8a

BIN
full_logcat.txt Normal file

Binary file not shown.

View File

@@ -0,0 +1,269 @@
/* eslint-disable no-param-reassign */
import React, {
ClipboardEventHandler,
KeyboardEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import {
Slate,
Editable,
withReact,
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
editor.isInline = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
element.type
) || isInline(element);
return editor;
};
const withVoid = (editor: Editor): Editor => {
const { isVoid } = editor;
editor.isVoid = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
isVoid(element);
return editor;
};
type CommandValidatorFn = (commandName: string) => boolean;
let commandValidator: CommandValidatorFn | null = null;
export const setCommandValidator = (validator: CommandValidatorFn) => {
commandValidator = validator;
};
const withCommandAutoConvert = (editor: Editor): Editor => {
// Removed auto-conversion on space to prevent focus issues
// Commands are now highlighted visually via decorations
// and executed when Enter is pressed
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
onPaste?: ClipboardEventHandler;
};
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
placeholder,
onKeyDown,
onKeyUp,
onChange,
onPaste,
},
ref
) => {
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const decorate = useCallback(([node, path]: [any, number[]]) => {
const ranges: any[] = [];
// Only decorate text nodes in the first paragraph
if (
path.length === 2 &&
path[0] === 0 &&
path[1] === 0 &&
SlateText.isText(node)
) {
const firstChild = editor.children[0];
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
const [firstInline, secondInline] = firstChild.children;
// Don't decorate if we already have a CommandElement
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
return ranges;
}
// Check if the text matches /command pattern
const text = node.text;
const match = text.match(/^(\s*\/\w+)/);
if (match && commandValidator) {
const commandText = match[1].trim();
const commandName = commandText.substring(1);
if (commandValidator(commandName)) {
// Create decoration for the command text
ranges.push({
anchor: { path, offset: 0 },
focus: { path, offset: match[1].length },
pendingCommand: true,
});
}
}
}
}
return ranges;
}, [editor]);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault();
},
[editor, onKeyDown]
);
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
}
// Insert the replacement text
editor.insertText(data);
}
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
</Text>
</span>
),
[]
);
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
</Box>
)}
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
size="300"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
/>
</Scroll>
{after && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{after}
</Box>
)}
</Box>
{bottom}
</Slate>
</div>
);
}
);

View File

@@ -0,0 +1,310 @@
import { useAtomValue } from 'jotai';
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Input,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { allRoomsAtom } from '../state/room-list/roomList';
import { mDirectAtom } from '../state/mDirectList';
import { useDirects, useRooms } from '../state/hooks/roomList';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
import { nameInitials } from '../utils/common';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { factoryRoomIdByActivity } from '../utils/sort';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../hooks/useAsyncSearch';
import { VirtualTile } from '../components/virtualizer';
import { AndroidSharePayload } from '../utils/androidShare';
import { stopPropagation } from '../utils/keyboard';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 200,
matchOptions: {
contain: true,
},
};
type ShareRoomPickerProps = {
/** The pending share payload to display a preview of. */
share: AndroidSharePayload;
/** Called when the user picks a room. */
onPick: (roomId: string) => void;
/** Called when the user dismisses without picking. */
onDismiss: () => void;
};
/**
* Full-screen modal that lets the user pick a room to send
* an incoming Android share intent into.
*/
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const allItems = useMemo(
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
[mx, rooms, directs]
);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => getRoom(rId)?.name ?? rId,
[getRoom]
);
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
allItems,
getRoomNameStr,
SEARCH_OPTS
);
const items = searchResult ? searchResult.items : allItems;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
const vItems = virtualizer.getVirtualItems();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) {
resetSearch();
return;
}
searchRoom(value);
};
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) {
setConfirmRoomId(roomId);
}
};
const handleConfirm = useCallback(() => {
if (confirmRoomId) {
onPick(confirmRoomId);
}
}, [confirmRoomId, onPick]);
const handleCancelConfirm = useCallback(() => {
setConfirmRoomId(null);
}, []);
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
}}
>
<Box
grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: 'var(--bg-surface)',
}}
>
<Header
size="500"
style={{ padding: config.space.S400, paddingTop: config.space.S500 }}
>
<Box grow="Yes" direction="Column" gap="100">
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box
style={{
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300, paddingTop: 0 }} direction="Column">
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</FocusTrap>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleCancelConfirm,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300">
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="200">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
onClick={handleCancelConfirm}
variant="Secondary"
size="400"
>
<Text size="B400">Cancel</Text>
</Button>
<Button
onClick={handleConfirm}
variant="Primary"
size="400"
>
<Text size="B400">Share</Text>
</Button>
</Box>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
);
})()}
</Overlay>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
@@ -11,6 +11,12 @@ import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
import {
isBackgroundSyncSupported,
getBackgroundSyncStatus,
requestResetPushRegistration,
triggerBackgroundSyncPing,
} from '../../../utils/backgroundSync';
function EmailNotification() {
const mx = useMatrixClient();
@@ -147,6 +153,7 @@ export function SystemNotification() {
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
</SequenceCard>
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -158,3 +165,126 @@ export function SystemNotification() {
</Box>
);
}
type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
}
: undefined
);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
await refresh();
}, [refresh])
);
const [pingState, ping] = useAsyncCallback(
useCallback(async () => {
await triggerBackgroundSyncPing('manual-test');
}, [])
);
const isBusy =
loading ||
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void reset()}
>
{resetState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Reset</Text>
)}
</Button>
}
/>
<SettingTile
title="Test Notification"
description="Trigger a test push ping to verify the pipeline works."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void ping()}
>
{pingState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Send Test</Text>
)}
</Button>
}
/>
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,159 @@
import React, {
ChangeEventHandler,
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import {
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
import { stopPropagation } from '../../utils/keyboard';
export function ServerPicker({
server,
serverList,
allowCustomServer,
onServerChange,
}: {
server: string;
serverList: string[];
allowCustomServer?: boolean;
onServerChange: (server: string) => void;
}) {
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Only sync input when server changes externally (e.g., from menu selection)
// and input is not focused to avoid cursor jumping during typing
if (
serverInputRef.current &&
serverInputRef.current.value !== server &&
document.activeElement !== serverInputRef.current
) {
serverInputRef.current.value = server;
}
}, [server]);
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer) debounceServerSelect(inputServer);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer && inputServer !== server) {
onServerChange(inputServer);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setServerMenuAnchor(undefined);
}
if (evt.key === 'Enter') {
evt.preventDefault();
const inputServer = evt.currentTarget.value.trim();
if (inputServer) onServerChange(inputServer);
}
};
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
const selectedServer = evt.currentTarget.getAttribute('data-server');
if (selectedServer) {
onServerChange(selectedServer);
}
setServerMenuAnchor(undefined);
};
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
setServerMenuAnchor(target.getBoundingClientRect());
};
return (
<Input
ref={serverInputRef}
style={{ paddingRight: config.space.S200 }}
variant={allowCustomServer ? 'Background' : 'Surface'}
outlined
defaultValue={server}
onChange={handleServerChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
after={
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
<PopOut
anchor={serverMenuAnchor}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setServerMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Homeserver List</Text>
</Header>
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
{serverList?.map((serverName) => (
<MenuItem
key={serverName}
radii="300"
aria-pressed={serverName === server}
data-server={serverName}
onClick={handleServerSelect}
>
<Text>{serverName}</Text>
</MenuItem>
))}
</div>
</Menu>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenServerMenu}
variant={allowCustomServer ? 'Background' : 'Surface'}
size="300"
aria-pressed={!!serverMenuAnchor}
radii="300"
>
<Icon src={Icons.ChevronBottom} />
</IconButton>
</PopOut>
)
}
/>
);
}

View File

@@ -1,5 +1,6 @@
import { useAtomValue } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
@@ -28,6 +29,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { plainToEditorInput, toPlainText } from '../../components/editor';
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import {
isTauri,
isElectron,
@@ -41,6 +44,20 @@ import {
stopBackgroundSync,
setAppForegroundState,
} from '../../utils/backgroundSync';
import {
TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts';
import { encryptFile } from '../../utils/matrix';
import {
AndroidSharePayload,
clearPendingAndroidShare,
getPendingAndroidShare,
isAndroidShareSupported,
listenForAndroidShares,
materializeSharedFile,
} from '../../utils/androidShare';
/**
* Applies the selected emoji style font to the document.
@@ -419,15 +436,20 @@ function MessageNotifications() {
}
/**
* Starts the native Android background sync service on login, keeps it
* Configures native Android push-ping background sync on login, keeps it
* informed of foreground state so it doesn't double-fire notifications,
* and stops it cleanly on unmount (logout).
* and clears it cleanly on unmount (logout).
* Only active on Android Capacitor builds.
*/
function BackgroundSyncSetup() {
const mx = useMatrixClient();
// Try to make a simple fetch to verify component is rendering
try {
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
} catch {}
useEffect(() => {
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
startBackgroundSync(mx);
const onVisibility = () => setAppForegroundState(!document.hidden);
@@ -493,6 +515,167 @@ function TaskbarFlashStopper() {
return null;
}
function AndroidShareIntentHandler() {
const mx = useMatrixClient();
const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
// Send text message if present
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
const textContent = {
msgtype: 'm.text' as const,
body: nextParts.join('\n'),
};
await mx.sendMessage(roomId, textContent);
}
// Upload and send files if present
if (share.files.length > 0) {
for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile;
let encInfo: any = undefined;
if (room.hasEncryptionStateEvent()) {
const encrypted = await encryptFile(originalFile);
fileToUpload = encrypted.file;
encInfo = encrypted.encInfo;
}
// Upload file
const uploadResult = await mx.uploadContent(fileToUpload);
const mxc = uploadResult?.content_uri;
if (!mxc) continue;
// Determine message type and send
const fileType = originalFile.type;
let msgtype = 'm.file' as const;
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
const fileContent: any = {
msgtype,
body: originalFile.name,
filename: originalFile.name,
info: {
mimetype: originalFile.type,
size: originalFile.size,
},
};
if (encInfo) {
fileContent.file = {
...encInfo,
url: mxc,
};
} else {
fileContent.url = mxc;
}
await mx.sendMessage(roomId, fileContent);
}
}
await clearPendingAndroidShare();
return true;
},
[mx]
);
const handlePickRoom = useCallback(
(roomId: string) => {
if (!pendingShare) return;
applyPendingShare(pendingShare, roomId)
.then((applied) => {
if (applied) {
setPendingShare(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
navigate(getDirectRoomPath(roomId));
} else {
const parents = roomToParents.get(roomId);
const parent = parents && parents.length > 0 ? parents[0] : undefined;
if (parent) {
navigate(getSpaceRoomPath(parent, roomId));
} else {
navigate(getHomeRoomPath(roomId));
}
}
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to apply share after pick:', err);
setPendingShare(null);
});
},
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
);
const handleDismiss = useCallback(() => {
clearPendingAndroidShare().catch(() => {});
setPendingShare(null);
}, []);
useEffect(() => {
if (!isAndroidShareSupported()) return;
let mounted = true;
let listenerHandle: { remove: () => Promise<void> } | undefined;
getPendingAndroidShare()
.then((share) => {
if (mounted && share) {
setPendingShare(share);
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to get pending share:', err);
});
listenForAndroidShares((share) => {
if (mounted) {
setPendingShare(share);
}
})
.then((handle) => {
listenerHandle = handle;
})
.catch((err) => {
console.error('[AndroidShare] Failed to listen for shares:', err);
});
return () => {
mounted = false;
void listenerHandle?.remove();
};
}, []);
if (!pendingShare) return null;
return (
<ShareRoomPicker
share={pendingShare}
onPick={handlePickRoom}
onDismiss={handleDismiss}
/>
);
}
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
@@ -508,6 +691,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<BackgroundSyncSetup />
<PaarrotAPIInitializer />
<TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children}
</>
);

View File

@@ -0,0 +1,73 @@
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
/** Metadata for a file received from an Android share intent. */
export type AndroidSharedFile = {
path: string;
name: string;
mimeType: string;
size: number;
};
/** Payload persisted by the native Android share handler. */
export type AndroidSharePayload = {
text?: string;
subject?: string;
files: AndroidSharedFile[];
receivedAt: number;
};
interface AndroidShareHandlerPlugin {
/** Returns the last pending share payload, if one exists. */
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
/** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>;
addListener(
eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle>;
}
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
/** Returns true when the Android share bridge is available. */
export const isAndroidShareSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Fetches the current pending native share payload. */
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
if (!isAndroidShareSupported()) return null;
const result = await AndroidShareHandler.getPendingShare();
return result.share;
};
/** Clears the native pending share payload. */
export const clearPendingAndroidShare = async (): Promise<void> => {
if (!isAndroidShareSupported()) return;
await AndroidShareHandler.clearPendingShare();
};
/** Subscribes to new incoming native share payloads. */
export const listenForAndroidShares = async (
listener: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle | undefined> => {
if (!isAndroidShareSupported()) return undefined;
return AndroidShareHandler.addListener('shareReceived', listener);
};
/** Converts a cached native shared file into a browser File for upload. */
export const materializeSharedFile = async (
sharedFile: AndroidSharedFile,
receivedAt: number
): Promise<File> => {
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
}
const blob = await response.blob();
return new File([blob], sharedFile.name, {
type: sharedFile.mimeType || blob.type,
lastModified: receivedAt,
});
};

View File

@@ -1,15 +1,44 @@
import { Capacitor, registerPlugin } from '@capacitor/core';
import type { MatrixClient } from 'matrix-js-sdk';
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
type UnifiedPushStatus = {
running: boolean;
endpoint: string;
instance: string;
registered: boolean;
distributor: string;
distributors: string[] | string;
};
type UnifiedPushEndpointEvent = {
endpoint: string;
previousEndpoint: string;
instance: string;
};
type UnifiedPushUnregisteredEvent = {
previousEndpoint: string;
instance: string;
};
type UnifiedPushRegistrationFailedEvent = {
reason: string;
instance: string;
};
interface MatrixBackgroundSyncPlugin {
/** Start the background sync service with the given Matrix credentials. */
/** Persist credentials and request UnifiedPush registration. */
start(options: {
homeserverUrl: string;
accessToken: string;
userId: string;
deviceId: string;
}): Promise<void>;
/** Stop the background sync service and clear persisted credentials. */
/** Trigger a one-shot fetch as if a push ping arrived. */
triggerPing(options: { reason?: string }): Promise<void>;
/** Re-open distributor setup flow and retry registration. */
requestDistributorSetup(): Promise<{ success: boolean }>;
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
stop(): Promise<void>;
/**
* Notify the service whether the app UI is visible.
@@ -17,63 +46,429 @@ interface MatrixBackgroundSyncPlugin {
* 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 }>;
/** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>;
addListener(
eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushUnregistered',
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushRegistrationFailed',
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
): Promise<PluginListenerHandle>;
}
type StoredPusherState = {
endpoint: string;
appId: string;
};
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
/** Returns true when the current platform is Android Capacitor. */
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 getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
const homeserverUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getUserId();
const deviceId = mx.getDeviceId();
const buildPusherAppId = (deviceId: string | null): string => {
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
return raw.length > 64 ? raw.slice(0, 64) : raw;
};
if (!homeserverUrl || !accessToken || !userId) {
console.warn('[BackgroundSync] Missing credentials, not starting');
return;
}
const loadStoredPusherState = (
userId: string | null,
deviceId: string | null
): StoredPusherState | undefined => {
if (typeof window === 'undefined' || !window.localStorage) return undefined;
const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
if (!raw) return undefined;
try {
await MatrixBackgroundSync.start({
homeserverUrl,
accessToken,
userId,
deviceId: deviceId ?? '',
});
console.log('[BackgroundSync] Service started');
} catch (err) {
console.warn('[BackgroundSync] Failed to start:', err);
return JSON.parse(raw) as StoredPusherState;
} catch {
return undefined;
}
};
const saveStoredPusherState = (
userId: string | null,
deviceId: string | null,
state: StoredPusherState
): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
};
const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
};
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
const trimmed = raw.trim();
if (!trimmed || trimmed === '[]') return [];
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed
.slice(1, -1)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
return [trimmed];
};
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
try {
const discoveryUrl = new URL(endpoint);
discoveryUrl.pathname = '/_matrix/push/v1/notify';
discoveryUrl.search = '';
const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
const body = (await response.json()) as {
gateway?: string;
unifiedpush?: { gateway?: string };
};
if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
return discoveryUrl.toString();
}
} catch (err) {
console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
}
return DEFAULT_UNIFIED_PUSH_GATEWAY;
};
class AndroidUnifiedPushManager {
private client: MatrixClient | undefined;
private listenerHandles: PluginListenerHandle[] = [];
private listenersReady = false;
private distributorSetupAttempted = false;
private distributorPromptShown = false;
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
async start(mx: MatrixClient): Promise<void> {
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
if (!isBackgroundSyncSupported()) {
console.log('[BackgroundSync] Background sync not supported on this platform');
return;
}
const homeserverUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getUserId();
const deviceId = mx.getDeviceId();
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
if (!homeserverUrl || !accessToken || !userId) {
console.warn('[BackgroundSync] Missing credentials, not starting');
return;
}
this.client = mx;
this.distributorSetupAttempted = false;
this.distributorPromptShown = false;
await this.ensureListeners();
try {
console.log('[BackgroundSync] Calling plugin.start()...');
await MatrixBackgroundSync.start({
homeserverUrl,
accessToken,
userId,
deviceId: deviceId ?? '',
});
console.log('[BackgroundSync] plugin.start() completed');
} catch (err) {
console.error('[BackgroundSync] plugin.start() failed:', err);
throw err;
}
await this.syncExistingEndpoint();
await this.ensureDistributorPromptFromStatus();
console.log('[BackgroundSync] UnifiedPush registration requested');
}
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
async stop(): Promise<void> {
if (!isBackgroundSyncSupported()) return;
const client = this.client;
if (client) {
const deviceId = client.getDeviceId();
const userId = client.getUserId();
const status = await this.safeGetStatus();
const stored = loadStoredPusherState(userId, deviceId);
const endpoint = status?.endpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(deviceId);
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(userId, deviceId);
}
await MatrixBackgroundSync.stop();
await this.disposeListeners();
this.client = undefined;
console.log('[BackgroundSync] UnifiedPush stopped');
}
/** Update the Matrix pusher when a new native endpoint is published. */
private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise<void> {
const client = this.client;
if (!client) return;
if (event.previousEndpoint) {
await this.removePusher(client, event.previousEndpoint);
}
await this.upsertPusher(client, event.endpoint);
}
/** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise<void> {
const client = this.client;
if (!client) return;
const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
const endpoint = event.previousEndpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(client.getUserId(), client.getDeviceId());
}
/** Log native registration failures so the missing distributor path is visible. */
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
return;
}
this.distributorSetupAttempted = true;
void this.tryRequestDistributorSetup();
}
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
private async tryRequestDistributorSetup(): Promise<void> {
try {
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
const status = await this.safeGetStatus();
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
const distributors = normalizeDistributors(status?.distributors ?? []);
if (distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
} catch (err) {
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
}
}
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
private async ensureDistributorPromptFromStatus(): Promise<void> {
const status = await this.safeGetStatus();
if (!status) return;
const distributors = normalizeDistributors(status.distributors);
if (!status.registered && distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
}
/** Show a one-time actionable prompt when no distributor app is installed. */
private showNoDistributorPrompt(): void {
if (this.distributorPromptShown) return;
this.distributorPromptShown = true;
const message =
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
const docsUrl = 'https://unifiedpush.org/users/distributors/';
if (typeof window === 'undefined') return;
try {
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
if (openDocs) {
window.open(docsUrl, '_blank', 'noopener,noreferrer');
}
} catch {
window.alert(message);
}
}
/** Install plugin listeners once for the active Matrix client. */
private async ensureListeners(): Promise<void> {
if (this.listenersReady) return;
this.listenerHandles = [
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
void this.handleNewEndpoint(event).catch((err) => {
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
});
}),
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
void this.handleUnregistered(event);
}),
await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
this.handleRegistrationFailed(event);
}),
];
this.listenersReady = true;
}
/** Remove all plugin listeners when the manager stops. */
private async disposeListeners(): Promise<void> {
await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
this.listenerHandles = [];
this.listenersReady = false;
}
/** Reconcile an already-persisted native endpoint after app startup. */
private async syncExistingEndpoint(): Promise<void> {
const client = this.client;
if (!client) return;
const status = await this.safeGetStatus();
console.log('[BackgroundSync] Native status before pusher sync:', status);
if (status?.registered && status.endpoint) {
await this.upsertPusher(client, status.endpoint);
}
}
/** Create or refresh the Matrix HTTP pusher for the current device. */
private async upsertPusher(mx: MatrixClient, endpoint: string): Promise<void> {
const deviceId = mx.getDeviceId();
const userId = mx.getUserId();
const appId = buildPusherAppId(deviceId);
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
console.log('[BackgroundSync] Upserting Matrix pusher', {
appId,
endpoint,
gatewayUrl,
deviceId,
userId,
});
try {
await mx.setPusher({
kind: 'http',
app_id: appId,
pushkey: endpoint,
app_display_name: 'Paarrot',
device_display_name: deviceId ?? 'Android',
lang: 'en',
data: {
url: gatewayUrl,
format: 'event_id_only',
},
append: false,
device_id: deviceId ?? undefined,
} as unknown as IPusherRequest);
const pushers = (await mx.getPushers())?.pushers ?? [];
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
totalPushers: pushers.length,
foundThisPusher: Boolean(thisPusher),
});
} catch (err) {
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
throw err;
}
saveStoredPusherState(userId, deviceId, { endpoint, appId });
}
/** Remove a previously-registered Matrix HTTP pusher for this device. */
private async removePusher(
mx: MatrixClient,
endpoint: string,
appId = buildPusherAppId(mx.getDeviceId())
): Promise<void> {
try {
await mx.setPusher({
pushkey: endpoint,
app_id: appId,
kind: null,
} as unknown as IPusherRequest);
} catch (err) {
console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
}
}
/** Read native registration state without failing the caller. */
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
return undefined;
}
}
}
const unifiedPushManager = new AndroidUnifiedPushManager();
/** Start native UnifiedPush registration and sync the Matrix pusher. */
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
await unifiedPushManager.start(mx);
};
/**
* Stops the native Matrix background sync service.
* Call on logout so the service stops and credentials are wiped.
* Manually triggers a one-shot native fetch.
* Useful for diagnostics and bridge testing.
*/
export const stopBackgroundSync = async (): Promise<void> => {
export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.stop();
console.log('[BackgroundSync] Service stopped');
await MatrixBackgroundSync.triggerPing({ reason });
} catch (err) {
console.warn('[BackgroundSync] Failed to stop:', err);
console.warn('[BackgroundSync] triggerPing failed:', err);
}
};
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
export const stopBackgroundSync = async (): Promise<void> => {
await unifiedPushManager.stop();
};
/**
* Tells the native service whether the app UI is in the foreground.
* Call when document visibility changes so the service can suppress
@@ -89,3 +484,35 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
console.warn('[BackgroundSync] setAppForeground failed:', err);
}
};
/**
* Re-opens the UnifiedPush distributor selection dialog.
* Allows users to re-select or change their push notification distributor without terminal access.
* Useful when the current endpoint becomes unavailable.
*/
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
if (!isBackgroundSyncSupported()) {
console.warn('[BackgroundSync] Background sync not supported on this platform');
return { success: false };
}
try {
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };
}
};
/** Returns the current native UnifiedPush status, or undefined if unavailable. */
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
if (!isBackgroundSyncSupported()) return undefined;
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] getStatus failed:', err);
return undefined;
}
};

View File

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

View File

@@ -30,6 +30,14 @@ const STASH_FLAG = join(ROOT, '.cinny-stash-pending');
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
if (cinnyStatus.trim()) {
console.log('[apply-overlay] Stashing pre-existing cinny changes...');
// Ensure android/ junction is excluded from the cinny git view — otherwise
// `git stash -u` would traverse the junction into the root android/ directory
// and delete files there when the stash is created.
const cinnyExclude = join(CINNY, '.git', 'info', 'exclude');
const excludeContent = existsSync(cinnyExclude) ? readFileSync(cinnyExclude, 'utf8') : '';
if (!excludeContent.includes('android/')) {
writeFileSync(cinnyExclude, excludeContent + (excludeContent.endsWith('\n') || !excludeContent ? '' : '\n') + 'android/\n', 'utf8');
}
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
writeFileSync(STASH_FLAG, '1', 'utf8');
console.log('[apply-overlay] Stash saved.');
@@ -120,21 +128,19 @@ if (!existsSync(androidJunction)) {
console.log(` android/ link already exists, skipping`);
}
// 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);
// 8. Sync time-based version into android/app/build.gradle
console.log('[apply-overlay] Syncing time-based version into build.gradle...');
const buildTimestampMs = Date.now();
const versionCode = Math.floor(buildTimestampMs / 1000);
const versionName = new Date(buildTimestampMs).toISOString().replace('T', '.').replaceAll(':', '').replace('Z', '');
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}"`);
.replace(/versionName\s+"[^"]+"/, `versionName "${versionName}"`);
writeFileSync(buildGradlePath, buildGradle, 'utf8');
console.log(` versionName "${version}", versionCode ${versionCode}`);
console.log(` versionName "${versionName}", 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;