Compare commits

...

16 Commits

Author SHA1 Message Date
GitHub Actions
19a46c4d95 chore: bump version to 4.11.138 [skip ci] 2026-07-24 05:26:41 +00:00
8cf31b929d Fix Android media downloads and dismiss notifs when read elsewhere.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m5s
FileSaver fails in Capacitor WebView, so save via MediaStore; clear tray notifications when sync reports rooms as read on another device, and improve collapsed notification avatars with conversation shortcuts.
2026-07-24 15:26:35 +10:00
GitHub Actions
a4d56e095e chore: bump version to 4.11.137 [skip ci] 2026-07-24 05:09:02 +00:00
86dc7f849a Expose appInForeground to Java for MainActivity lifecycle updates.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m43s
2026-07-24 15:08:55 +10:00
GitHub Actions
ca889f2eec chore: bump version to 4.11.136 [skip ci] 2026-07-24 05:03:10 +00:00
157d707581 Fix Android notification avatars, E2EE wake alerts, and tap navigation.
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 5m3s
Show circular sender avatars with MessagingStyle, notify on encrypted background sync events, deep-link tray taps into the right room, and suppress Firefox open-link contextual actions.
2026-07-24 15:03:03 +10:00
GitHub Actions
3940441d9b chore: bump version to 4.11.135 [skip ci] 2026-07-24 04:47:38 +00:00
cfabcc5fbf Show decrypted notification bodies and authenticated sender avatars.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m8s
Wait for E2EE cleartext before building tray text, and post Android notifications natively so dynamic avatars fetched with secure media auth can appear as large icons.
2026-07-24 14:47:32 +10:00
GitHub Actions
a40319e8c8 chore: bump version to 4.11.134 [skip ci] 2026-07-24 04:36:18 +00:00
45bc475340 Group Android notifications by space and clear them on read.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m52s
Use stable per-room notification IDs with Directs/Spaces/Home channels and space group summaries so the shade nests deeper than a flat Messages list, and dismiss those tray entries when a room is marked read.
2026-07-24 14:36:11 +10:00
GitHub Actions
60edb063dd chore: bump version to 4.11.133 [skip ci] 2026-07-23 14:44:09 +00:00
0c85c36838 Skip Actions artifact upload for Android APKs.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m11s
upload-artifact times out against git.ruv.wtf from the runner; publish the APK via the reachable synbox Gitea API in the build job instead.
2026-07-24 00:44:01 +10:00
GitHub Actions
d59646c630 chore: bump version to 4.11.132 [skip ci] 2026-07-23 14:36:29 +00:00
c1985eda15 Use a relative cinny submodule URL so CI can clone it.
Some checks failed
Build / increment-version (push) Successful in 5s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
Absolute git.ruv.wtf URLs fail on runners that only reach the internal Gitea host.
2026-07-24 00:36:23 +10:00
GitHub Actions
2e018b0367 chore: bump version to 4.11.131 [skip ci] 2026-07-23 14:30:39 +00:00
8046474b8a Fix background push notifying for muted and mention-only rooms.
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 4m37s
Build / create-release (push) Has been skipped
Filter Android sync wakes by push rules/unread counts, align JS system notifications, and point the cinny submodule at git.ruv.wtf.
2026-07-24 00:30:33 +10:00
19 changed files with 2859 additions and 199 deletions

View File

@@ -103,28 +103,6 @@ jobs:
- name: Build Android APK - name: Build Android APK
run: npm run android:apk:linux run: npm run android:apk:linux
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: Paarrot-Android.apk
path: android/app/build/outputs/apk/debug/Paarrot-*.apk
create-release:
runs-on: ubuntu-latest
needs: [increment-version, build-android]
if: always() && needs.build-android.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/master'
run: git pull origin master
- name: Get version - name: Get version
id: version id: version
run: | run: |
@@ -136,6 +114,13 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION" echo "Version: $VERSION"
- name: Prepare release files
run: |
mkdir -p release-files
find android/app/build/outputs/apk/debug -type f -name "Paarrot-*.apk" -exec cp {} release-files/ \;
ls -la release-files/
test -n "$(ls -A release-files/*.apk 2>/dev/null)"
- name: Check if tag exists - name: Check if tag exists
id: tag_check id: tag_check
run: | run: |
@@ -156,17 +141,8 @@ jobs:
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}" git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}" git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts # Upload via the internal Gitea API. actions/upload-artifact times out against
uses: actions/download-artifact@v3 # git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release-files
find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
ls -la release-files/
- name: Create or Update Gitea Release - name: Create or Update Gitea Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "cinny"] [submodule "cinny"]
path = cinny path = cinny
url = http://synbox.ruv.wtf:8418/litruv/cinny.git url = ../cinny.git

View File

@@ -15,6 +15,19 @@ public class MainActivity extends BridgeActivity {
setupImageKeyboardSupport(); setupImageKeyboardSupport();
} }
@Override
public void onResume() {
super.onResume();
// Keep native sync suppression in sync with the real Activity lifecycle.
MatrixSyncService.appInForeground = true;
}
@Override
public void onPause() {
MatrixSyncService.appInForeground = false;
super.onPause();
}
/** Advertise image MIME types and receive Gboard / paste / drag-drop media. */ /** Advertise image MIME types and receive Gboard / paste / drag-drop media. */
private void setupImageKeyboardSupport() { private void setupImageKeyboardSupport() {
if (bridge == null) return; if (bridge == null) return;

View File

@@ -10,10 +10,20 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Base64
import android.util.Log import android.util.Log
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.Rect
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -68,7 +78,7 @@ class MatrixSyncService : Service() {
serviceScope.launch { serviceScope.launch {
try { try {
runSingleSyncFetch(homeserver, token, userId) runSingleSyncFetch(homeserver, token, userId, triggerReason)
Log.d(TAG, "One-shot sync completed (reason=$triggerReason)") Log.d(TAG, "One-shot sync completed (reason=$triggerReason)")
} finally { } finally {
stopForegroundCompat() stopForegroundCompat()
@@ -84,7 +94,12 @@ class MatrixSyncService : Service() {
job.cancel() job.cancel()
} }
private suspend fun runSingleSyncFetch(homeserver: String, token: String, userId: String) { private suspend fun runSingleSyncFetch(
homeserver: String,
token: String,
userId: String,
triggerReason: String,
) {
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val since = prefs.getString(KEY_SINCE, null) val since = prefs.getString(KEY_SINCE, null)
val isFirstSync = since == null val isFirstSync = since == null
@@ -101,8 +116,24 @@ class MatrixSyncService : Service() {
if (nextBatch != null) { if (nextBatch != null) {
prefs.edit().putString(KEY_SINCE, nextBatch).apply() prefs.edit().putString(KEY_SINCE, nextBatch).apply()
if (!isFirstSync && !appInForeground) { // Push wakes should still notify even if the WebView leftover
processRoomEvents(json, userId) // "foreground" flag is stale; JS path is suppressed while backgrounded.
val fromPush = triggerReason.startsWith("unifiedpush")
val suppressBecauseUi = appInForeground && !fromPush
if (!isFirstSync) {
// Always dismiss trays when another device (or this one) marked rooms read,
// even while the UI is foregrounded and posting is suppressed.
dismissClearedRoomNotifications(json)
if (!suppressBecauseUi) {
processRoomEvents(json, userId)
} else {
Log.d(
TAG,
"Skipping notification posts (foreground=$appInForeground, reason=$triggerReason)",
)
}
} else {
Log.d(TAG, "Skipping notifications (firstSync=true, reason=$triggerReason)")
} }
} }
} }
@@ -126,7 +157,8 @@ class MatrixSyncService : Service() {
} }
private fun buildSyncUrl(base: String, since: String?): String { private fun buildSyncUrl(base: String, since: String?): String {
val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}""" // Include encrypted events — most DMs/rooms are E2EE and never emit plaintext m.room.message.
val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message","m.room.encrypted","m.sticker"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}"""
val encodedFilter = URLEncoder.encode(filter, "UTF-8") val encodedFilter = URLEncoder.encode(filter, "UTF-8")
val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else "" val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam" return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam"
@@ -151,55 +183,292 @@ class MatrixSyncService : Service() {
} }
} }
private enum class RoomNotifyMode {
MUTE,
ALL_MESSAGES,
MENTIONS_AND_KEYWORDS,
}
private data class NotifyContext(
val myUserId: String,
val myDisplayName: String?,
val myLocalpart: String?,
val keywords: List<String>,
val directRoomIds: Set<String>,
val pushRules: JSONObject?,
)
/**
* Cancel tray notifications for rooms the homeserver now reports as fully read.
* Covers “marked as read on another device” while this phone is backgrounded.
* Only acts when [unread_notifications] is present in this sync batch (count changed).
*/
private fun dismissClearedRoomNotifications(sync: JSONObject) {
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
for (roomId in joinedRooms.keys().asSequence()) {
val roomData = joinedRooms.optJSONObject(roomId) ?: continue
if (!roomData.has("unread_notifications")) continue
val unread = roomData.optJSONObject("unread_notifications")
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
if (notificationCount <= 0) {
clearRoomNotifications(applicationContext, roomId)
}
}
}
private fun processRoomEvents(sync: JSONObject, myUserId: String) { private fun processRoomEvents(sync: JSONObject, myUserId: String) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
val token = prefs.getString(EXTRA_TOKEN, null) ?: return val token = prefs.getString(EXTRA_TOKEN, null) ?: return
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannel(nm) ensureMessageChannels(nm)
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
val roomIds = joinedRooms.keys().asSequence().toList() val roomIds = joinedRooms.keys().asSequence().toList()
for (roomId in roomIds) { for (roomId in roomIds) {
val timeline = joinedRooms.optJSONObject(roomId) val roomData = joinedRooms.optJSONObject(roomId) ?: continue
?.optJSONObject("timeline") ?: continue val mode = resolveRoomNotifyMode(roomId, notifyCtx)
// Homeserver unread counts already apply push rules — skip rooms with nothing to notify.
val unread = roomData.optJSONObject("unread_notifications")
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
val highlightCount = unread?.optInt("highlight_count", 0) ?: 0
if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue
val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS
if (mentionsOnly && highlightCount <= 0) {
// Mentions-only rooms: without a highlight, skip (encrypted bodies can't be scanned).
continue
}
val timeline = roomData.optJSONObject("timeline") ?: continue
val events = timeline.optJSONArray("events") ?: continue val events = timeline.optJSONArray("events") ?: continue
var notifiedForRoom = false
for (i in 0 until events.length()) { for (i in 0 until events.length()) {
val event = events.optJSONObject(i) ?: continue val event = events.optJSONObject(i) ?: continue
val eventId = event.optString("event_id") val eventId = event.optString("event_id")
val eventType = event.optString("type")
if (event.optString("type") != "m.room.message") continue val isMessageLike =
eventType == "m.room.message" ||
eventType == "m.room.encrypted" ||
eventType == "m.sticker"
if (!isMessageLike) continue
if (event.optString("sender") == myUserId) continue if (event.optString("sender") == myUserId) continue
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue val content = event.optJSONObject("content") ?: JSONObject()
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body") // Plaintext mention/keyword filter; encrypted events already passed highlight_count.
val body = when (msgtype) { if (
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo" mentionsOnly &&
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video" eventType != "m.room.encrypted" &&
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio" !isSpecialMessage(content, notifyCtx)
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File" ) {
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker" continue
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
} }
val body = when {
eventType == "m.room.encrypted" -> "Encrypted message"
eventType == "m.sticker" -> {
val raw = content.optString("body")
if (raw.isNotBlank()) "🖼️ $raw" else "🖼️ Sticker"
}
else -> {
val msgtype = content.optString("msgtype")
val rawBody = content.optString("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
val sender = event.optString("sender") val sender = event.optString("sender")
val profile = resolveProfile(sender, homeserver, token) val profile = resolveProfile(sender, homeserver, token)
// For unencrypted image/sticker messages, try to download a preview bitmap. val msgtype = content.optString("msgtype")
// Encrypted messages have a `file` object instead of a top-level `url`.
val inlineImage: Bitmap? = if ( val inlineImage: Bitmap? = if (
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file") eventType != "m.room.encrypted" &&
(msgtype == "m.image" || msgtype == "m.sticker" || eventType == "m.sticker") &&
!content.has("file")
) { ) {
content.optString("url").takeIf { it.startsWith("mxc://") } content.optString("url").takeIf { it.startsWith("mxc://") }?.let { mxc ->
?.let { mxcToDownloadUrl(it, homeserver) } mxcToDownloadUrls(mxc, homeserver)
?.let { downloadBitmap(it, token) } .firstNotNullOfOrNull { downloadBitmap(it, token) }
}
} else null } else null
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage) showMessageNotification(
nm,
roomId,
profile.displayName,
body,
profile.avatar,
inlineImage,
resolveGroupInfo(roomId, notifyCtx),
)
notifiedForRoom = true
} }
// Fallback: HS says there are notifications but timeline filter missed usable events.
if (!notifiedForRoom && notificationCount > 0) {
val groupInfo = resolveGroupInfo(roomId, notifyCtx)
showMessageNotification(
nm,
roomId,
groupInfo.roomName.ifBlank { "New message" },
if (mentionsOnly) "New mention" else "New message",
null,
null,
groupInfo,
)
}
}
}
/** Load push rules, DM list, keywords, and own display name for notification filtering. */
private fun loadNotifyContext(homeserver: String, token: String, myUserId: String): NotifyContext {
val pushRules = fetchJson("$homeserver/_matrix/client/v3/pushrules/", token)
val accountDataDirect = fetchJson("$homeserver/_matrix/client/v3/user/${urlEncode(myUserId)}/account_data/m.direct", token)
val profile = fetchJson("$homeserver/_matrix/client/v3/profile/${urlEncode(myUserId)}", token)
val directRoomIds = mutableSetOf<String>()
accountDataDirect?.keys()?.forEach { key ->
val rooms = accountDataDirect.optJSONArray(key) ?: return@forEach
for (i in 0 until rooms.length()) {
rooms.optString(i).takeIf { it.isNotBlank() }?.let { directRoomIds.add(it) }
}
}
val keywords = mutableListOf<String>()
val contentRules = pushRules?.optJSONObject("global")?.optJSONArray("content")
if (contentRules != null) {
for (i in 0 until contentRules.length()) {
val rule = contentRules.optJSONObject(i) ?: continue
if (rule.optBoolean("enabled", true).not()) continue
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) continue
rule.optString("pattern").takeIf { it.isNotBlank() }?.let { keywords.add(it) }
}
}
return NotifyContext(
myUserId = myUserId,
myDisplayName = profile?.optString("displayname")?.takeIf { it.isNotBlank() },
myLocalpart = myUserId.substringAfter("@").substringBefore(":").takeIf { it.isNotBlank() },
keywords = keywords,
directRoomIds = directRoomIds,
pushRules = pushRules,
)
}
/**
* Mirrors Cinny/Paarrot room notification modes:
* Mute override → mute; room notify → all; room dont_notify → mentions;
* unset DM → all; unset room → mentions (app Default).
*/
private fun resolveRoomNotifyMode(roomId: String, ctx: NotifyContext): RoomNotifyMode {
val global = ctx.pushRules?.optJSONObject("global")
// If push rules are unavailable, rely on homeserver unread_notifications only.
?: return RoomNotifyMode.ALL_MESSAGES
val overrides = global.optJSONArray("override")
if (overrides != null) {
for (i in 0 until overrides.length()) {
val rule = overrides.optJSONObject(i) ?: continue
if (rule.optString("rule_id") != roomId) continue
if (rule.optBoolean("enabled", true).not()) continue
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) {
return RoomNotifyMode.MUTE
}
}
}
val roomRules = global.optJSONArray("room")
if (roomRules != null) {
for (i in 0 until roomRules.length()) {
val rule = roomRules.optJSONObject(i) ?: continue
if (rule.optString("rule_id") != roomId) continue
if (rule.optBoolean("enabled", true).not()) continue
return if (actionsIncludeNotify(rule.optJSONArray("actions"))) {
RoomNotifyMode.ALL_MESSAGES
} else {
RoomNotifyMode.MENTIONS_AND_KEYWORDS
}
}
}
return if (ctx.directRoomIds.contains(roomId)) {
RoomNotifyMode.ALL_MESSAGES
} else {
RoomNotifyMode.MENTIONS_AND_KEYWORDS
}
}
private fun actionsIncludeNotify(actions: org.json.JSONArray?): Boolean {
if (actions == null) return false
for (i in 0 until actions.length()) {
when (val action = actions.opt(i)) {
is String -> if (action == "notify") return true
is JSONObject -> if (action.has("set_tweak")) { /* tweaks only */ }
}
}
return false
}
private fun isSpecialMessage(content: JSONObject, ctx: NotifyContext): Boolean {
val mentions = content.optJSONObject("m.mentions")
if (mentions != null) {
val userIds = mentions.optJSONArray("user_ids")
if (userIds != null) {
for (i in 0 until userIds.length()) {
if (userIds.optString(i) == ctx.myUserId) return true
}
}
if (mentions.optBoolean("room", false)) return true
}
val body = content.optString("body").lowercase()
if (body.isBlank()) return false
if (body.contains("@room")) return true
ctx.myDisplayName?.lowercase()?.takeIf { it.isNotBlank() }?.let {
if (body.contains(it)) return true
}
ctx.myLocalpart?.lowercase()?.let {
if (body.contains(it)) return true
}
for (keyword in ctx.keywords) {
if (body.contains(keyword.lowercase())) return true
}
return false
}
private fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8")
private fun fetchJson(urlString: String, token: String): JSONObject? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
conn.connectTimeout = 4_000
conn.readTimeout = 6_000
if (conn.responseCode != 200) return null
JSONObject(conn.inputStream.bufferedReader().readText())
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch $urlString: ${e.message}")
null
} }
} }
@@ -231,9 +500,7 @@ class MatrixSyncService : Service() {
conn.disconnect() conn.disconnect()
} }
displayNameCache[mxid] = displayName displayNameCache[mxid] = displayName
val avatar = avatarMxc val avatar = avatarMxc?.let { downloadAvatarBitmap(it, homeserver, token, size = 96) }
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
avatarCache[mxid] = avatar avatarCache[mxid] = avatar
UserProfile(displayName, avatar) UserProfile(displayName, avatar)
} catch (e: Exception) { } catch (e: Exception) {
@@ -244,24 +511,47 @@ class MatrixSyncService : Service() {
} }
} }
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? { private fun mxcToThumbnailUrls(mxcUrl: String, homeserver: String, size: Int): List<String> {
val withoutScheme = mxcUrl.removePrefix("mxc://") val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/') val slash = withoutScheme.indexOf('/')
if (slash < 0) return null if (slash < 0) return emptyList()
val serverName = withoutScheme.substring(0, slash) val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1) val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop" val query = "width=$size&height=$size&method=crop"
// Prefer MSC3916 authenticated media, fall back to legacy media repo.
return listOf(
"$homeserver/_matrix/client/v1/media/thumbnail/$serverName/$mediaId?$query",
"$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?$query",
)
} }
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? { private fun mxcToDownloadUrls(mxcUrl: String, homeserver: String): List<String> {
val withoutScheme = mxcUrl.removePrefix("mxc://") val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/') val slash = withoutScheme.indexOf('/')
if (slash < 0) return null if (slash < 0) return emptyList()
val serverName = withoutScheme.substring(0, slash) val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1) val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId" return listOf(
"$homeserver/_matrix/client/v1/media/download/$serverName/$mediaId",
"$homeserver/_matrix/media/v3/download/$serverName/$mediaId",
)
} }
private fun downloadAvatarBitmap(
mxcUrl: String,
homeserver: String,
token: String,
size: Int,
): Bitmap? {
for (url in mxcToThumbnailUrls(mxcUrl, homeserver, size)) {
downloadBitmap(url, token)?.let { return it }
}
return null
}
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? =
mxcToDownloadUrls(mxcUrl, homeserver).firstOrNull()
private fun downloadBitmap(urlString: String, token: String): Bitmap? { private fun downloadBitmap(urlString: String, token: String): Bitmap? {
return try { return try {
val conn = URL(urlString).openConnection() as HttpURLConnection val conn = URL(urlString).openConnection() as HttpURLConnection
@@ -280,39 +570,83 @@ class MatrixSyncService : Service() {
} }
} }
private data class NotificationGroupInfo(
val groupId: String,
val groupName: String,
val roomName: String,
val kind: String,
)
private fun resolveGroupInfo(roomId: String, ctx: NotifyContext): NotificationGroupInfo {
val mapped = loadNotificationGroupMap()[roomId]
if (mapped != null) return mapped
return if (ctx.directRoomIds.contains(roomId)) {
NotificationGroupInfo(
groupId = GROUP_DIRECTS,
groupName = "Direct messages",
roomName = roomId,
kind = "direct",
)
} else {
NotificationGroupInfo(
groupId = GROUP_HOME,
groupName = "Home",
roomName = roomId,
kind = "home",
)
}
}
private fun loadNotificationGroupMap(): Map<String, NotificationGroupInfo> {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val raw = prefs.getString(KEY_NOTIFICATION_GROUPS, null) ?: return emptyMap()
return try {
val root = JSONObject(raw)
val out = mutableMapOf<String, NotificationGroupInfo>()
val keys = root.keys()
while (keys.hasNext()) {
val roomId = keys.next()
val obj = root.optJSONObject(roomId) ?: continue
out[roomId] = NotificationGroupInfo(
groupId = obj.optString("groupId").ifBlank { GROUP_HOME },
groupName = obj.optString("groupName").ifBlank { "Home" },
roomName = obj.optString("roomName").ifBlank { roomId },
kind = obj.optString("kind").ifBlank { "home" },
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse notification group map: ${e.message}")
emptyMap()
}
}
private fun channelIdForKind(kind: String): String = channelIdForKindStatic(kind)
private fun showMessageNotification( private fun showMessageNotification(
nm: NotificationManager, nm: NotificationManager,
roomId: String,
sender: String, sender: String,
body: String, body: String,
largeIcon: Bitmap? = null, largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null, inlineImage: Bitmap? = null,
groupInfo: NotificationGroupInfo,
) { ) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) val isDm = groupInfo.kind == "direct"
?: Intent(this, MainActivity::class.java) postMessageNotification(
val pi = PendingIntent.getActivity( this,
this, 0, launchIntent, roomId = roomId,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, senderName = sender,
messageText = body,
conversationTitle = if (isDm) null else groupInfo.roomName.ifBlank { null },
groupId = groupInfo.groupId,
groupName = groupInfo.groupName,
kind = groupInfo.kind,
largeIcon = largeIcon,
inlineImage = inlineImage,
path = null,
) )
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)
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 { private fun buildStatusNotification(): Notification {
@@ -346,24 +680,8 @@ class MatrixSyncService : Service() {
} }
} }
private fun ensureMessageChannel(nm: NotificationManager) { private fun ensureMessageChannels(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ensureMessageChannels(this)
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"
setSound(
soundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
nm.createNotificationChannel(channel)
}
} }
companion object { companion object {
@@ -372,17 +690,335 @@ class MatrixSyncService : Service() {
const val EXTRA_TOKEN = "access_token" const val EXTRA_TOKEN = "access_token"
const val EXTRA_USER_ID = "user_id" const val EXTRA_USER_ID = "user_id"
const val EXTRA_DEVICE_ID = "device_id" const val EXTRA_DEVICE_ID = "device_id"
const val EXTRA_ROOM_ID = "room_id"
const val EXTRA_GROUP_ID = "group_id"
const val EXTRA_TRIGGER_REASON = "trigger_reason" const val EXTRA_TRIGGER_REASON = "trigger_reason"
const val KEY_NOTIFICATION_GROUPS = "notification_groups"
const val PREFS = "matrix_sync_prefs" const val PREFS = "matrix_sync_prefs"
const val KEY_SINCE = "since_token" const val KEY_SINCE = "since_token"
private const val KEY_LAST_WAKE_MS = "last_wake_ms" private const val KEY_LAST_WAKE_MS = "last_wake_ms"
private const val NOTIF_ID_STATUS = 1001 private const val NOTIF_ID_STATUS = 1001
private const val CHANNEL_STATUS = "sync_status" private const val CHANNEL_STATUS = "sync_status"
private const val CHANNEL_MESSAGES = "messages_paarrot" private const val CHANNEL_MESSAGES = "messages_paarrot"
private const val CHANNEL_DIRECTS = "messages_directs"
private const val CHANNEL_SPACES = "messages_spaces"
private const val CHANNEL_HOME = "messages_home"
private const val GROUP_DIRECTS = "paarrot_directs"
private const val GROUP_HOME = "paarrot_home"
private const val MIN_WAKE_INTERVAL_MS = 7_500L private const val MIN_WAKE_INTERVAL_MS = 7_500L
const val MODE_ONE_SHOT = "one_shot" const val MODE_ONE_SHOT = "one_shot"
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */ /** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */
fun notificationIdForRoom(roomId: String): Int {
var hash = 0
for (ch in roomId) {
hash = (hash shl 5) - hash + ch.code
}
// Match JS Math.abs(...); avoid Int.MIN_VALUE abs edge case.
val positive = if (hash == Int.MIN_VALUE) 0 else kotlin.math.abs(hash)
var id = positive % 2147483647
if (id == 0 || id == NOTIF_ID_STATUS) {
id = NOTIF_ID_STATUS + 1
}
return id
}
fun notificationIdForGroupSummary(groupId: String): Int =
notificationIdForRoom("summary:$groupId")
private fun channelIdForKindStatic(kind: String): String = when (kind) {
"direct" -> CHANNEL_DIRECTS
"space" -> CHANNEL_SPACES
else -> CHANNEL_HOME
}
fun ensureMessageChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val soundUri = Uri.parse("android.resource://${context.packageName}/${R.raw.paarrot_notification}")
val soundAttrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channels = listOf(
Triple(CHANNEL_DIRECTS, "Direct messages", "One-to-one Matrix conversations"),
Triple(CHANNEL_SPACES, "Spaces", "Messages from rooms inside Spaces"),
Triple(CHANNEL_HOME, "Other rooms", "Rooms that are not in a Space"),
Triple(CHANNEL_MESSAGES, "Messages", "Legacy message channel"),
)
for ((id, name, description) in channels) {
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH).apply {
this.description = description
setSound(soundUri, soundAttrs)
}
nm.createNotificationChannel(channel)
}
}
fun decodeBase64Bitmap(base64: String?): Bitmap? {
if (base64.isNullOrBlank()) return null
return try {
val raw = if (base64.contains(',')) base64.substringAfter(',') else base64
val bytes = Base64.decode(raw, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (e: Exception) {
Log.w(TAG, "Failed to decode notification icon: ${e.message}")
null
}
}
/** Crop a bitmap into a circle for the notification avatar slot. */
fun toCircularBitmap(bitmap: Bitmap): Bitmap {
val size = minOf(bitmap.width, bitmap.height)
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val radius = size / 2f
canvas.drawCircle(radius, radius, radius, paint)
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
val left = (bitmap.width - size) / 2
val top = (bitmap.height - size) / 2
canvas.drawBitmap(
bitmap,
Rect(left, top, left + size, top + size),
Rect(0, 0, size, size),
paint,
)
return output
}
/** Soften URLs so Android Assistant doesn't add "Open link in Firefox" actions. */
fun sanitizeNotificationText(text: String): String =
text.replace(Regex("""https?://\S+""", RegexOption.IGNORE_CASE), "🔗 link")
/**
* Publish a long-lived conversation shortcut so Android 11+ shows the sender
* avatar in the collapsed shade (MessagingStyle alone only shows it expanded).
*/
private fun publishConversationShortcut(
context: Context,
roomId: String,
label: String,
person: Person,
launchIntent: Intent,
): ShortcutInfoCompat {
val shortcutIntent = Intent(launchIntent).apply {
// Shortcuts require an explicit action.
if (action.isNullOrBlank()) {
action = NotificationNavStore.ACTION_OPEN_NOTIFICATION
}
}
val icon = person.icon
?: IconCompat.createWithResource(context, R.drawable.ic_stat_paarrot)
val shortcut = ShortcutInfoCompat.Builder(context, "room:$roomId")
.setShortLabel(label.take(25).ifBlank { "Chat" })
.setLongLabel(label.ifBlank { "Chat" })
.setIcon(icon)
.setIntent(shortcutIntent)
.setPerson(person)
.setLongLived(true)
.setCategories(setOf("android.shortcut.conversation"))
.build()
try {
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
} catch (e: Exception) {
Log.w(TAG, "Failed to publish conversation shortcut: ${e.message}")
}
return shortcut
}
/**
* Posts a room notification (+ space/DM group summary) for both background sync
* and JS-driven Capacitor notifications.
*
* Avatar goes in the circular largeIcon / conversation-shortcut slot (visible when
* collapsed). The small status-bar icon stays the Paarrot mark (Android requirement).
* Optional image attachments use BigPictureStyle while keeping that circular avatar.
*/
fun postMessageNotification(
context: Context,
roomId: String,
senderName: String,
messageText: String,
conversationTitle: String?,
groupId: String,
groupName: String,
kind: String,
largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null,
path: String? = null,
// Back-compat for older call sites that passed title/body.
title: String? = null,
body: String? = null,
) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannels(context)
val resolvedSender = senderName.ifBlank { title ?: "Someone" }
val resolvedMessage = sanitizeNotificationText(
messageText.ifBlank { body ?: "New message" },
)
val isDm = kind == "direct"
val resolvedConversation =
conversationTitle?.takeIf { it.isNotBlank() }
?: title?.takeIf { !isDm && it != resolvedSender }
val launchIntent = Intent(context, MainActivity::class.java).apply {
action = NotificationNavStore.ACTION_OPEN_NOTIFICATION
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
putExtra(EXTRA_ROOM_ID, roomId)
if (!path.isNullOrBlank()) {
putExtra(NotificationNavStore.EXTRA_NAV_PATH, path)
}
}
val roomNotifId = notificationIdForRoom(roomId)
val pi = PendingIntent.getActivity(
context, roomNotifId, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val avatar = largeIcon?.let { toCircularBitmap(it) }
val personBuilder = Person.Builder()
.setName(resolvedSender)
.setKey("$roomId:$resolvedSender")
.setImportant(true)
if (avatar != null) {
personBuilder.setIcon(IconCompat.createWithBitmap(avatar))
}
val senderPerson = personBuilder.build()
val channelId = channelIdForKindStatic(kind)
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
resolvedSender
} else {
resolvedConversation
}
val collapsedText = if (isDm || resolvedConversation.isNullOrBlank()) {
resolvedMessage
} else {
"$resolvedSender: $resolvedMessage"
}
val shortcut = publishConversationShortcut(
context,
roomId,
collapsedTitle,
senderPerson,
launchIntent,
)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(collapsedTitle)
.setContentText(collapsedText)
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupId)
.setOnlyAlertOnce(true)
.setSubText(groupName)
.setShortcutId(shortcut.id)
.setShortcutInfo(shortcut)
// Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies.
.setAllowSystemGeneratedContextualActions(false)
builder.extras.putString(EXTRA_ROOM_ID, roomId)
builder.extras.putString(EXTRA_GROUP_ID, groupId)
if (!path.isNullOrBlank()) {
builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path)
}
// Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
if (avatar != null) builder.setLargeIcon(avatar)
if (inlineImage != null) {
// Expanded image attachment — keep avatar in the circular large-icon slot.
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(avatar)
.setBigContentTitle(collapsedTitle)
.setSummaryText(collapsedText)
)
} else {
// BigTextStyle keeps setLargeIcon visible when collapsed on most OEMs.
// MessagingStyle alone often hides the avatar until the user expands.
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(collapsedText)
.setBigContentTitle(collapsedTitle)
.setSummaryText(groupName)
)
}
nm.notify(roomNotifId, builder.build())
val summaryId = notificationIdForGroupSummary(groupId)
val summary = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(groupName)
.setContentText("New messages")
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupId)
.setGroupSummary(true)
.setAllowSystemGeneratedContextualActions(false)
.setStyle(
NotificationCompat.InboxStyle()
.setBigContentTitle(groupName)
.setSummaryText(groupName)
)
summary.extras.putString(EXTRA_GROUP_ID, groupId)
nm.notify(summaryId, summary.build())
}
/** Cancel the tray notification posted for [roomId], if any. */
fun clearRoomNotifications(context: Context, roomId: String) {
if (roomId.isBlank()) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val roomNotifId = notificationIdForRoom(roomId)
var groupId: String? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
groupId = nm.activeNotifications
.firstOrNull { it.id == roomNotifId }
?.notification?.extras?.getString(EXTRA_GROUP_ID)
}
nm.cancel(roomNotifId)
// Also drop any legacy stacked notifications that still carry this room id.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (active in nm.activeNotifications) {
val tagged = active.notification.extras.getString(EXTRA_ROOM_ID)
if (tagged == roomId && active.id != roomNotifId) {
nm.cancel(active.id)
}
}
if (!groupId.isNullOrBlank()) {
val summaryId = notificationIdForGroupSummary(groupId)
val siblingsRemain = nm.activeNotifications.any { active ->
active.id != summaryId &&
active.notification.extras.getString(EXTRA_GROUP_ID) == groupId
}
if (!siblingsRemain) {
nm.cancel(summaryId)
}
}
}
}
/** Set by [SyncServicePlugin] / [MainActivity] — true when the Capacitor UI is visible. */
@JvmField
@Volatile @Volatile
var appInForeground = false var appInForeground = false

View File

@@ -0,0 +1,42 @@
package com.paarrot.app
import android.content.Intent
/**
* Bridges notification tap intents into the Capacitor JS layer so we can
* navigate to the correct room when a tray notification is opened.
*/
object NotificationNavStore {
const val EXTRA_NAV_PATH = "paarrot_nav_path"
const val ACTION_OPEN_NOTIFICATION = "com.paarrot.app.OPEN_NOTIFICATION"
@Volatile
var pendingPath: String? = null
@Volatile
var pendingRoomId: String? = null
@Volatile
var plugin: SyncServicePlugin? = null
/** Read navigation extras from a notification tap [intent] and notify JS. */
fun handleIntent(intent: Intent?) {
if (intent == null) return
val path = intent.getStringExtra(EXTRA_NAV_PATH)?.takeIf { it.isNotBlank() }
val roomId = intent.getStringExtra(MatrixSyncService.EXTRA_ROOM_ID)?.takeIf { it.isNotBlank() }
if (path == null && roomId == null) return
pendingPath = path
pendingRoomId = roomId
plugin?.emitNotificationOpened(path, roomId)
}
/** Consume and clear the pending navigation target. */
fun consume(): Pair<String?, String?> {
val path = pendingPath
val roomId = pendingRoomId
pendingPath = null
pendingRoomId = null
return path to roomId
}
}

View File

@@ -1,10 +1,14 @@
package com.paarrot.app package com.paarrot.app
import android.app.Activity import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore
import android.util.Base64
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
@@ -213,4 +217,168 @@ class ShareHandlerPlugin : Plugin() {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt) ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
call.resolve() call.resolve()
} }
/**
* Save a base64-encoded file to the device gallery / Downloads.
* Capacitor WebView cannot use FileSaver / `<a download>`, so downloads go through MediaStore.
* Images → Pictures/Paarrot, videos → Movies/Paarrot, other → Download/Paarrot (API 29+).
* On older APIs or MediaStore failure, falls back to the system share sheet.
*/
@PluginMethod
fun saveFile(call: PluginCall) {
val filename = call.getString("filename") ?: run {
call.reject("filename required")
return
}
val mimeType = call.getString("mimeType")
?: guessMime(filename)
?: "application/octet-stream"
val base64 = call.getString("base64") ?: run {
call.reject("base64 required")
return
}
try {
val cleaned = base64.substringAfter("base64,", base64).trim()
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
if (bytes.isEmpty()) {
call.reject("empty file data")
return
}
val uri = saveBytes(bytes, filename, mimeType)
if (uri != null) {
android.widget.Toast.makeText(
context,
"Saved $filename",
android.widget.Toast.LENGTH_SHORT
).show()
call.resolve(
JSObject()
.put("saved", true)
.put("uri", uri.toString())
)
} else {
shareBytes(bytes, filename, mimeType)
call.resolve(JSObject().put("saved", false).put("shared", true))
}
} catch (e: Exception) {
android.util.Log.e(TAG, "saveFile failed", e)
call.reject("Failed to save file: ${e.message}", e)
}
}
/**
* Open the system share sheet for a base64 file (Save to Files, Drive, etc.).
*/
@PluginMethod
fun shareFile(call: PluginCall) {
val filename = call.getString("filename") ?: run {
call.reject("filename required")
return
}
val mimeType = call.getString("mimeType")
?: guessMime(filename)
?: "application/octet-stream"
val base64 = call.getString("base64") ?: run {
call.reject("base64 required")
return
}
try {
val cleaned = base64.substringAfter("base64,", base64).trim()
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
shareBytes(bytes, filename, mimeType)
call.resolve(JSObject().put("shared", true))
} catch (e: Exception) {
android.util.Log.e(TAG, "shareFile failed", e)
call.reject("Failed to share file: ${e.message}", e)
}
}
private fun saveBytes(bytes: ByteArray, filename: String, mimeType: String): Uri? {
return when {
mimeType.startsWith("image/") ->
insertMedia(bytes, filename, mimeType, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "Pictures/Paarrot")
mimeType.startsWith("video/") ->
insertMedia(bytes, filename, mimeType, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "Movies/Paarrot")
mimeType.startsWith("audio/") ->
insertMedia(bytes, filename, mimeType, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, "Music/Paarrot")
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
insertMedia(bytes, filename, mimeType, MediaStore.Downloads.EXTERNAL_CONTENT_URI, "Download/Paarrot")
else -> null // share-sheet fallback (avoids WRITE_EXTERNAL_STORAGE on API < 29)
}
}
private fun insertMedia(
bytes: ByteArray,
filename: String,
mimeType: String,
collection: Uri,
relativePath: String
): Uri? {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri = resolver.insert(collection, values) ?: return null
try {
resolver.openOutputStream(uri)?.use { it.write(bytes) }
?: run {
resolver.delete(uri, null, null)
return null
}
} catch (e: Exception) {
resolver.delete(uri, null, null)
throw e
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
return uri
}
private fun shareBytes(bytes: ByteArray, filename: String, mimeType: String) {
val cacheDir = File(context.cacheDir, "shares").apply { mkdirs() }
val safeName = filename.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
val file = File(cacheDir, safeName)
FileOutputStream(file).use { it.write(bytes) }
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, filename)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "Save or share").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(chooser)
}
private fun guessMime(filename: String): String? {
val ext = filename.substringAfterLast('.', "").lowercase()
if (ext.isEmpty()) return null
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
}
companion object {
private const val TAG = "ShareHandlerPlugin"
}
} }

View File

@@ -17,6 +17,9 @@ import com.getcapacitor.annotation.CapacitorPlugin
* - `stop()` — clear persisted credentials and unregister UnifiedPush * - `stop()` — clear persisted credentials and unregister UnifiedPush
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible * - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
* - `getStatus()` — returns current fetch and UnifiedPush state * - `getStatus()` — returns current fetch and UnifiedPush state
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
*/ */
@CapacitorPlugin(name = "MatrixBackgroundSync") @CapacitorPlugin(name = "MatrixBackgroundSync")
class SyncServicePlugin : Plugin() { class SyncServicePlugin : Plugin() {
@@ -28,13 +31,44 @@ class SyncServicePlugin : Plugin() {
override fun load() { override fun load() {
super.load() super.load()
UnifiedPushManager.setPlugin(this) UnifiedPushManager.setPlugin(this)
NotificationNavStore.plugin = this
NotificationNavStore.handleIntent(activity?.intent)
}
override fun handleOnNewIntent(intent: Intent?) {
super.handleOnNewIntent(intent)
if (intent != null) {
activity?.intent = intent
}
NotificationNavStore.handleIntent(intent)
} }
override fun handleOnDestroy() { override fun handleOnDestroy() {
UnifiedPushManager.clearPlugin(this) UnifiedPushManager.clearPlugin(this)
if (NotificationNavStore.plugin === this) {
NotificationNavStore.plugin = null
}
super.handleOnDestroy() super.handleOnDestroy()
} }
/** Emit a notificationOpened event toward the JS layer. */
fun emitNotificationOpened(path: String?, roomId: String?) {
val payload = JSObject()
if (path != null) payload.put("path", path)
if (roomId != null) payload.put("roomId", roomId)
notifyListeners("notificationOpened", payload, true)
}
/** Returns any pending notification navigation target from a tray tap. */
@PluginMethod
fun getPendingNotificationNav(call: PluginCall) {
val (path, roomId) = NotificationNavStore.consume()
val result = JSObject()
result.put("path", path)
result.put("roomId", roomId)
call.resolve(result)
}
/** Persists Matrix credentials and starts UnifiedPush registration. */ /** Persists Matrix credentials and starts UnifiedPush registration. */
@PluginMethod @PluginMethod
fun start(call: PluginCall) { fun start(call: PluginCall) {
@@ -97,6 +131,69 @@ class SyncServicePlugin : Plugin() {
call.resolve(result) call.resolve(result)
} }
/**
* Cancels native tray notifications for a Matrix room after it has been marked as read.
*/
@PluginMethod
fun clearRoomNotifications(call: PluginCall) {
val roomId = call.getString("roomId")
?: return call.reject("roomId required")
MatrixSyncService.clearRoomNotifications(context, roomId)
call.resolve(JSObject().put("cleared", true))
}
/**
* Persists room → space/group metadata from the JS layer so background
* notifications can nest under Direct messages / Space name / Home.
*/
@PluginMethod
fun setNotificationGroups(call: PluginCall) {
val rooms = call.getObject("rooms")
?: return call.reject("rooms required")
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(MatrixSyncService.KEY_NOTIFICATION_GROUPS, rooms.toString())
.apply()
call.resolve(JSObject().put("success", true))
}
/**
* Shows a message notification from the JS layer (supports dynamic avatar icons).
* Capacitor LocalNotifications cannot load authenticated/dynamic large icons, so
* we post through the same native NotificationManager path as background sync.
*/
@PluginMethod
fun showNotification(call: PluginCall) {
val roomId = call.getString("roomId") ?: return call.reject("roomId required")
val groupId = call.getString("groupId") ?: "paarrot_home"
val groupName = call.getString("groupName") ?: "Home"
val kind = call.getString("kind") ?: "home"
val senderName = call.getString("senderName")
?: call.getString("title")
?: "Someone"
val messageText = call.getString("messageText")
?: call.getString("body")
?: "New message"
val conversationTitle = call.getString("conversationTitle")
val path = call.getString("path")
val largeIconBase64 = call.getString("largeIconBase64")
val bigPictureBase64 = call.getString("bigPictureBase64")
MatrixSyncService.postMessageNotification(
context = context,
roomId = roomId,
senderName = senderName,
messageText = messageText,
conversationTitle = conversationTitle,
groupId = groupId,
groupName = groupName,
kind = kind,
largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64),
inlineImage = MatrixSyncService.decodeBase64Bitmap(bigPictureBase64),
path = path,
)
call.resolve(JSObject().put("shown", true))
}
companion object { companion object {
const val PREFS = "sync_service_prefs" const val PREFS = "sync_service_prefs"
} }

2
cinny

Submodule cinny updated: 61d41900cc...b52926f7d8

View File

@@ -0,0 +1,251 @@
/* eslint-disable no-param-reassign */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
import { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react';
import { saveMediaBlob } from '../../utils/saveMedia';
import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
export type PdfViewerProps = {
name: string;
src: string;
requestClose: () => void;
};
export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
const [docState, loadPdfDocument] = usePdfDocumentLoader(
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
src
);
const isLoading =
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
const isError =
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
const [pageNo, setPageNo] = useState(1);
const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
useEffect(() => {
loadPdfJS();
}, [loadPdfJS]);
useEffect(() => {
if (pdfJSState.status === AsyncStatus.Success) {
loadPdfDocument();
}
}, [pdfJSState, loadPdfDocument]);
useEffect(() => {
if (docState.status === AsyncStatus.Success) {
const doc = docState.data;
if (pageNo < 0 || pageNo > doc.numPages) return;
createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
const container = containerRef.current;
if (!container) return;
container.textContent = '';
container.append(canvas);
scrollRef.current?.scrollTo({
top: 0,
});
});
}
}, [docState, pageNo, zoom]);
const handleDownload = async () => {
try {
const res = await fetch(src);
if (!res.ok) throw new Error('Failed to fetch PDF');
await saveMediaBlob(await res.blob(), name);
} catch (error) {
console.warn('[PdfViewer] Failed to download:', error);
}
};
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (docState.status !== AsyncStatus.Success) return;
const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
if (!jumpInput) return;
const jumpTo = parseInt(jumpInput.value, 10);
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
setJumpAnchor(undefined);
};
const handlePrevPage = () => {
setPageNo((n) => Math.max(n - 1, 1));
};
const handleNextPage = () => {
if (docState.status !== AsyncStatus.Success) return;
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
};
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{name}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
{isLoading && <Spinner variant="Secondary" size="600" />}
{isError && (
<>
<Text>Failed to load PDF</Text>
<Button
variant="Critical"
fill="Soft"
size="300"
radii="300"
before={<Icon src={Icons.Warning} size="50" />}
onClick={loadPdfJS}
>
<Text size="B300">Retry</Text>
</Button>
</>
)}
{docState.status === AsyncStatus.Success && (
<Scroll
ref={scrollRef}
size="300"
direction="Both"
variant="Surface"
visibility="Hover"
>
<Box>
<div className={css.PdfViewerContent} ref={containerRef} />
</Box>
</Scroll>
)}
</Box>
{docState.status === AsyncStatus.Success && (
<Header as="footer" className={css.PdfViewerFooter} size="400">
<Chip
variant="Secondary"
radii="300"
before={<Icon size="50" src={Icons.ChevronLeft} />}
onClick={handlePrevPage}
aria-disabled={pageNo <= 1}
>
<Text size="B300">Previous</Text>
</Chip>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<PopOut
anchor={jumpAnchor}
align="Center"
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setJumpAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface">
<Box
as="form"
onSubmit={handleJumpSubmit}
style={{ padding: config.space.S200 }}
direction="Column"
gap="200"
>
<Input
name="jumpInput"
size="300"
variant="Background"
defaultValue={pageNo}
min={1}
max={docState.data.numPages}
step={1}
outlined
type="number"
radii="300"
aria-label="Page Number"
/>
<Button type="submit" size="300" variant="Primary" radii="300">
<Text size="B300">Jump To Page</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
onClick={handleOpenJump}
variant="SurfaceVariant"
radii="300"
aria-pressed={jumpAnchor !== undefined}
>
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
</Chip>
</PopOut>
</Box>
<Chip
variant="Primary"
radii="300"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={handleNextPage}
aria-disabled={pageNo >= docState.data.numPages}
>
<Text size="B300">Next</Text>
</Chip>
</Header>
)}
</Box>
);
}
);

View File

@@ -0,0 +1,107 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth';
export type ImageViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
try {
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
} catch (error) {
console.warn('[ImageViewer] Failed to download media:', error);
try {
window.open(src, '_blank');
} catch {
// ignore
}
}
};
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
src={src}
alt={alt}
draggable={false}
onMouseDown={onMouseDown}
/>
</Box>
</Box>
);
}
);

View File

@@ -0,0 +1,84 @@
import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
import { getCurrentAccessToken } from '../../utils/auth';
import { saveMediaBlob } from '../../utils/saveMedia';
const badgeStyles = { maxWidth: toRem(100) };
type FileDownloadButtonProps = {
filename: string;
url: string;
mimeType: string;
encInfo?: EncryptedAttachmentInfo;
};
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
await saveMediaBlob(fileContent, filename);
return true;
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
);
const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error;
return (
<IconButton
disabled={downloading}
onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : (
<Icon size="100" src={Icons.Download} />
)}
</IconButton>
);
}
export type FileHeaderProps = {
body: string;
mimeType: string;
after?: ReactNode;
};
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
<Box shrink="No">
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
<Text size="O400" truncate>
{mimeTypeToExt(mimeType)}
</Text>
</Badge>
</Box>
<Box grow="Yes">
<Text size="T300" truncate>
{body}
</Text>
</Box>
{after}
</Box>
));

View File

@@ -0,0 +1,303 @@
import React, { ReactNode, useCallback, useState } from 'react';
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { saveMediaBlob } from '../../../utils/saveMedia';
import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common';
import {
READABLE_EXT_TO_MIME_TYPE,
READABLE_TEXT_MIME_TYPES,
getFileNameExt,
mimeTypeToExt,
} from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getCurrentAccessToken } from '../../../utils/auth';
import { ModalWide } from '../../../styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider
tooltip={
<Tooltip variant="Critical">
<Text>Failed to load file!</Text>
</Tooltip>
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Button
ref={triggerRef}
size="400"
variant="Critical"
fill="Soft"
outlined
radii="300"
onClick={retry}
before={<Icon size="100" src={Icons.Warning} filled />}
>
<Text size="B400" truncate>
{text}
</Text>
</Button>
)}
</TooltipProvider>
);
type RenderTextViewerProps = {
name: string;
text: string;
langName: string;
requestClose: () => void;
};
type ReadTextFileProps = {
body: string;
mimeType: string;
url: string;
encInfo?: EncryptedAttachmentInfo;
renderViewer: (props: RenderTextViewerProps) => ReactNode;
};
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [textViewer, setTextViewer] = useState(false);
const [textState, loadText] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
const text = fileContent.text();
setTextViewer(true);
return text;
}, [mx, useAuthentication, mimeType, encInfo, url])
);
return (
<>
{textState.status === AsyncStatus.Success && (
<Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setTextViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
name: body,
text: textState.data,
langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
? mimeTypeToExt(mimeType)
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
requestClose: () => setTextViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{textState.status === AsyncStatus.Error ? (
renderErrorButton(loadText, 'Open File')
) : (
<Button
variant="Secondary"
fill="Solid"
radii="300"
size="400"
onClick={() =>
textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
}
disabled={textState.status === AsyncStatus.Loading}
before={
textState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.ArrowRight} filled />
)
}
>
<Text size="B400" truncate>
Open File
</Text>
</Button>
)}
</>
);
}
type RenderPdfViewerProps = {
name: string;
src: string;
requestClose: () => void;
};
export type ReadPdfFileProps = {
body: string;
mimeType: string;
url: string;
encInfo?: EncryptedAttachmentInfo;
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
};
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [pdfViewer, setPdfViewer] = useState(false);
const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
setPdfViewer(true);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
return (
<>
{pdfState.status === AsyncStatus.Success && (
<Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPdfViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
name: body,
src: pdfState.data,
requestClose: () => setPdfViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{pdfState.status === AsyncStatus.Error ? (
renderErrorButton(loadPdf, 'Open PDF')
) : (
<Button
variant="Secondary"
fill="Solid"
radii="300"
size="400"
onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
disabled={pdfState.status === AsyncStatus.Loading}
before={
pdfState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.ArrowRight} filled />
)
}
>
<Text size="B400" truncate>
Open PDF
</Text>
</Button>
)}
</>
);
}
export type DownloadFileProps = {
body: string;
mimeType: string;
url: string;
info: IFileInfo;
encInfo?: EncryptedAttachmentInfo;
};
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
await saveMediaBlob(fileContent, body);
return fileContent;
}, [mx, url, useAuthentication, mimeType, encInfo, body])
);
return downloadState.status === AsyncStatus.Error ? (
renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
) : (
<Button
variant="Secondary"
fill="Soft"
radii="300"
size="400"
onClick={() =>
downloadState.status === AsyncStatus.Success
? saveMediaBlob(downloadState.data, body)
: download()
}
disabled={downloadState.status === AsyncStatus.Loading}
before={
downloadState.status === AsyncStatus.Loading ? (
<Spinner fill="Soft" size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.Download} filled />
)
}
>
<Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
</Button>
);
}
type FileContentProps = {
body: string;
mimeType: string;
renderAsTextFile: () => ReactNode;
renderAsPdfFile: () => ReactNode;
};
export const FileContent = as<'div', FileContentProps>(
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
<Box direction="Column" gap="300" {...props} ref={ref}>
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
renderAsTextFile()}
{mimeType === 'application/pdf' && renderAsPdfFile()}
{children}
</Box>
)
);

View File

@@ -0,0 +1,74 @@
import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './VideoViewer.css';
import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth';
export type VideoViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const VideoViewer = as<'div', VideoViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const handleDownload = async () => {
try {
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
} catch (error) {
console.warn('[VideoViewer] Failed to download media:', error);
try {
window.open(src, '_blank');
} catch {
// ignore
}
}
};
return (
<Box
className={classNames(css.VideoViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.VideoViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.VideoViewerContent}
justifyContent="Center"
alignItems="Center"
>
<video
className={css.VideoViewerVideo}
src={src}
title={alt}
controls
autoPlay
/>
</Box>
</Box>
);
}
);

View File

@@ -1,7 +1,7 @@
import { useAtomValue, useStore } from 'jotai'; import { useAtomValue, useStore } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { RoomEvent, RoomEventHandlerMap, MatrixClient, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/svg/paarrot.svg'; import LogoSVG from '../../../../public/res/svg/paarrot.svg';
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg'; import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
@@ -15,6 +15,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils'; import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
import { import {
getMemberDisplayName, getMemberDisplayName,
getMemberAvatarMxc,
getNotificationType, getNotificationType,
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
@@ -22,7 +23,15 @@ import {
guessPerfectParent, guessPerfectParent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } from '../../utils/matrix'; import {
getMxIdLocalPart,
mxcUrlToHttp,
getCanonicalAliasOrRoomId,
encryptFile,
downloadMedia,
downloadEncryptedMedia,
decryptFile,
} from '../../utils/matrix';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
@@ -35,12 +44,17 @@ import {
isCapacitorNative, isCapacitorNative,
sendNotification, sendNotification,
setupNotificationTapListener, setupNotificationTapListener,
clearNotificationsForRoom,
NOTIF_GROUP_DIRECTS,
NOTIF_GROUP_HOME,
type NotificationGroupInfo,
} from '../../utils/tauri'; } from '../../utils/tauri';
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api'; import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
import { import {
startBackgroundSync, startBackgroundSync,
stopBackgroundSync, stopBackgroundSync,
setAppForegroundState, setAppForegroundState,
syncNotificationGroupMap,
} from '../../utils/backgroundSync'; } from '../../utils/backgroundSync';
import { import {
TUploadItem, TUploadItem,
@@ -57,6 +71,162 @@ import {
materializeSharedFile, materializeSharedFile,
} from '../../utils/androidShare'; } from '../../utils/androidShare';
/** Wait briefly for an encrypted event to decrypt before reading its body. */
async function waitForEventDecryption(mx: MatrixClient, mEvent: MatrixEvent) {
if (!mEvent.isEncrypted() || mEvent.getClearContent() || mEvent.isDecryptionFailure()) {
return;
}
const crypto = mx.getCrypto();
if (crypto) {
try {
await mEvent.attemptDecryption(crypto as any, { isRetry: true });
} catch {
// Decryption may complete asynchronously via the Decrypted listener below.
}
}
if (mEvent.getClearContent() || mEvent.isDecryptionFailure()) return;
await new Promise<void>((resolve) => {
const timeout = window.setTimeout(() => {
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
resolve();
}, 2000);
const onDecrypted = () => {
window.clearTimeout(timeout);
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
resolve();
};
mEvent.once(MatrixEventEvent.Decrypted, onDecrypted);
});
}
/** Build a human-readable notification body from (possibly decrypted) event content. */
function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
const content = mEvent.getClearContent() ?? mEvent.getContent();
const eventType = mEvent.getType();
if (eventType === 'm.reaction') {
const reactionKey = content['m.relates_to']?.key;
return reactionKey ? `reacted with ${reactionKey}` : 'reacted to a message';
}
if (eventType === 'm.room.encrypted' || mEvent.isDecryptionFailure()) {
return 'Encrypted message';
}
const rawBody = typeof content.body === 'string' ? content.body : undefined;
switch (content.msgtype) {
case 'm.image':
return rawBody ? `📷 ${rawBody}` : '📷 Photo';
case 'm.video':
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
case 'm.audio':
return rawBody ? `🎵 ${rawBody}` : '🎵 Audio';
case 'm.file':
return rawBody ? `📎 ${rawBody}` : '📎 File';
case 'm.sticker':
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker';
default:
return rawBody;
}
}
/** Download an authenticated media URL to a base64 string for native notification icons. */
async function mediaUrlToBase64(
url: string,
accessToken: string | null | undefined
): Promise<string | undefined> {
try {
const blob = await downloadMedia(url, accessToken);
return blobToBase64(blob);
} catch (err) {
console.warn('[Notifications] Failed to fetch media for notification:', err);
return undefined;
}
}
async function blobToBase64(blob: Blob): Promise<string | undefined> {
try {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
const mime = blob.type || 'image/jpeg';
return `data:${mime};base64,${btoa(binary)}`;
} catch {
return undefined;
}
}
/** Resolve a sender display name from room membership, user directory, then MXID. */
function resolveSenderDisplayName(mx: MatrixClient, room: { roomId: string }, sender: string): string {
return (
getMemberDisplayName(room as any, sender) ??
mx.getUser(sender)?.displayName ??
getMxIdLocalPart(sender) ??
sender
);
}
/** Fetch image attachment preview (plaintext or encrypted) for the notification shade. */
async function fetchNotificationImageBase64(
mx: MatrixClient,
mEvent: MatrixEvent,
useAuthentication: boolean
): Promise<string | undefined> {
const content = (mEvent.getClearContent() ?? mEvent.getContent()) as Record<string, any>;
if (content.msgtype !== 'm.image' && mEvent.getType() !== 'm.sticker') return undefined;
const accessToken = mx.getAccessToken();
const info = content.info ?? {};
try {
// Prefer a smaller thumbnail when available.
if (info.thumbnail_file?.url) {
const mediaUrl =
mxcUrlToHttp(mx, info.thumbnail_file.url, useAuthentication) ?? info.thumbnail_file.url;
const blob = await downloadEncryptedMedia(
mediaUrl,
(encBuf) =>
decryptFile(encBuf, info.thumbnail_info?.mimetype ?? 'image/jpeg', info.thumbnail_file),
accessToken
);
return blobToBase64(blob);
}
if (typeof info.thumbnail_url === 'string') {
const mediaUrl =
mxcUrlToHttp(mx, info.thumbnail_url, useAuthentication, 512, 512, 'scale') ??
mxcUrlToHttp(mx, info.thumbnail_url, useAuthentication);
if (mediaUrl) return mediaUrlToBase64(mediaUrl, accessToken);
}
if (content.file?.url) {
const mediaUrl = mxcUrlToHttp(mx, content.file.url, useAuthentication) ?? content.file.url;
const blob = await downloadEncryptedMedia(
mediaUrl,
(encBuf) => decryptFile(encBuf, info.mimetype ?? 'image/jpeg', content.file),
accessToken
);
return blobToBase64(blob);
}
if (typeof content.url === 'string') {
const mediaUrl =
mxcUrlToHttp(mx, content.url, useAuthentication, 512, 512, 'scale') ??
mxcUrlToHttp(mx, content.url, useAuthentication);
if (mediaUrl) return mediaUrlToBase64(mediaUrl, accessToken);
}
} catch (err) {
console.warn('[Notifications] Failed to fetch image attachment for notification:', err);
}
return undefined;
}
/** /**
* Applies the selected emoji style font to the document. * Applies the selected emoji style font to the document.
* - System: Uses the native OS emoji font * - System: Uses the native OS emoji font
@@ -221,15 +391,72 @@ function MessageNotifications() {
// Set up notification tap listener for mobile // Set up notification tap listener for mobile
useEffect(() => { useEffect(() => {
setupNotificationTapListener((path) => { const openFromNotification = (target: string) => {
navigate(path); if (!target) return;
if (target.startsWith('__room__:')) {
const roomId = target.slice('__room__:'.length);
if (!mx || !roomId) return;
try {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias));
return;
}
const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias));
return;
}
navigate(getHomeRoomPath(roomIdOrAlias));
} catch (err) {
console.error('[Notifications] Navigate from roomId error:', err);
}
return;
}
navigate(target);
};
setupNotificationTapListener(openFromNotification);
}, [navigate, mx, mDirects, roomToParents]);
const roomToUnread = useAtomValue(roomToUnreadAtom);
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
const previousUnreadTotalsRef = useRef<Map<string, number>>(new Map());
// Dismiss OS notifications when a room's unread is cleared
// (local mark-as-read, or our receipt syncing from another device).
useEffect(() => {
const currentRooms = new Set(roomToUnread.keys());
const nextTotals = new Map<string, number>();
roomToUnread.forEach((unread, roomId) => {
nextTotals.set(roomId, unread.total);
const prevTotal = previousUnreadTotalsRef.current.get(roomId);
if (prevTotal !== undefined && prevTotal > 0 && unread.total === 0) {
void clearNotificationsForRoom(roomId);
}
}); });
}, [navigate]);
for (const roomId of previousUnreadRoomsRef.current) {
if (!currentRooms.has(roomId)) {
void clearNotificationsForRoom(roomId);
}
}
previousUnreadRoomsRef.current = currentRooms;
previousUnreadTotalsRef.current = nextTotals;
}, [roomToUnread]);
const notify = useCallback( const notify = useCallback(
({ ({
roomName, roomName,
roomAvatar, roomAvatar,
iconBase64,
bigPictureBase64,
username, username,
messageBody, messageBody,
roomId, roomId,
@@ -238,14 +465,52 @@ function MessageNotifications() {
}: { }: {
roomName: string; roomName: string;
roomAvatar?: string; roomAvatar?: string;
iconBase64?: string;
bigPictureBase64?: string;
username: string; username: string;
messageBody?: string; messageBody?: string;
roomId: string; roomId: string;
eventId: string; eventId: string;
isDm: boolean; isDm: boolean;
}) => { }) => {
const notificationTitle = username; let group: NotificationGroupInfo;
const notificationBody = messageBody || 'New message'; if (isDm) {
group = {
groupId: NOTIF_GROUP_DIRECTS,
groupName: 'Direct messages',
kind: 'direct',
roomName,
};
} else {
const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0 && mx) {
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
const spaceRoom = mx.getRoom(parentSpace);
group = {
groupId: parentSpace,
groupName: spaceRoom?.name || 'Space',
kind: 'space',
roomName,
};
} else {
group = {
groupId: NOTIF_GROUP_HOME,
groupName: 'Home',
kind: 'home',
roomName,
};
}
}
// DMs: sender as title. Rooms: room name as title, sender in body.
const notificationTitle = isDm ? username : roomName || username;
const notificationBody = isDm
? messageBody || 'New message'
: messageBody
? `${username}: ${messageBody}`
: `${username} sent a message`;
const messageText = messageBody || 'New message';
const conversationTitle = isDm ? undefined : roomName || undefined;
/** Replicates TitleBar click navigation logic */ /** Replicates TitleBar click navigation logic */
const navigateToRoom = () => { const navigateToRoom = () => {
@@ -290,7 +555,15 @@ function MessageNotifications() {
sendNotification({ sendNotification({
title: notificationTitle, title: notificationTitle,
body: notificationBody, body: notificationBody,
senderName: username,
messageText,
conversationTitle,
path: roomPath, path: roomPath,
roomId,
group,
icon: roomAvatar,
iconBase64,
bigPictureBase64,
onClick: () => { onClick: () => {
if (!window.closed) navigate(roomPath); if (!window.closed) navigate(roomPath);
}, },
@@ -376,39 +649,54 @@ function MessageNotifications() {
return; return;
} }
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
const notificationType = getNotificationType(mx, room.roomId);
const isDm = mDirects.has(room.roomId);
const mentionsOnly =
notificationType === NotificationType.MentionsAndKeywords ||
(notificationType === NotificationType.Default && !isDm);
if (mentionsOnly && unreadInfo.highlight === 0) return;
if ( if (
showNotifications && showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted')) ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
) { ) {
const avatarMxc = void (async () => {
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); await waitForEventDecryption(mx, mEvent);
const content = mEvent.getContent();
let messageBody: string | undefined; const avatarMxc =
if (mEvent.getType() === 'm.reaction') { getMemberAvatarMxc(room, sender) ??
// For reactions, show "reacted with {emoji}" room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
const reactionKey = content['m.relates_to']?.key; room.getMxcAvatarUrl();
if (reactionKey) { const roomAvatar = avatarMxc
messageBody = `reacted with ${reactionKey}`;
} else {
messageBody = 'reacted to a message';
}
} else {
messageBody = typeof content.body === 'string' ? content.body : undefined;
}
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined, : undefined;
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
messageBody, const username = resolveSenderDisplayName(mx, room, sender);
roomId: room.roomId,
eventId, const [iconBase64, bigPictureBase64] = await Promise.all([
isDm, roomAvatar && isCapacitorNative()
}); ? mediaUrlToBase64(roomAvatar, mx.getAccessToken())
: Promise.resolve(undefined),
isCapacitorNative()
? fetchNotificationImageBase64(mx, mEvent, useAuthentication)
: Promise.resolve(undefined),
]);
const messageBody = notificationBodyFromEvent(mEvent);
notify({
roomName: room.name ?? 'Unknown',
roomAvatar,
iconBase64,
bigPictureBase64,
username,
messageBody,
roomId: room.roomId,
eventId,
isDm,
});
})();
} }
if (notificationSound) { if (notificationSound) {
@@ -428,6 +716,7 @@ function MessageNotifications() {
notify, notify,
selectedRoomId, selectedRoomId,
useAuthentication, useAuthentication,
mDirects,
]); ]);
return null; return null;
@@ -441,6 +730,9 @@ function MessageNotifications() {
*/ */
function BackgroundSyncSetup() { function BackgroundSyncSetup() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
// Try to make a simple fetch to verify component is rendering // Try to make a simple fetch to verify component is rendering
try { try {
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {}); fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
@@ -460,6 +752,49 @@ function BackgroundSyncSetup() {
}; };
}, [mx]); }, [mx]);
// Keep native background notifier informed of space → room grouping.
useEffect(() => {
const rooms: Record<
string,
{ groupId: string; groupName: string; roomName: string; kind: 'direct' | 'space' | 'home' }
> = {};
for (const room of mx.getRooms()) {
if (room.isSpaceRoom()) continue;
const roomId = room.roomId;
const roomName = room.name || roomId;
if (mDirects.has(roomId)) {
rooms[roomId] = {
groupId: NOTIF_GROUP_DIRECTS,
groupName: 'Direct messages',
roomName,
kind: 'direct',
};
continue;
}
const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
const spaceRoom = mx.getRoom(parentSpace);
rooms[roomId] = {
groupId: parentSpace,
groupName: spaceRoom?.name || 'Space',
roomName,
kind: 'space',
};
} else {
rooms[roomId] = {
groupId: NOTIF_GROUP_HOME,
groupName: 'Home',
roomName,
kind: 'home',
};
}
}
void syncNotificationGroupMap(rooms);
}, [mx, mDirects, roomToParents]);
return null; return null;
} }

View File

@@ -21,6 +21,18 @@ interface AndroidShareHandlerPlugin {
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>; getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
/** Clears the last pending share payload after it has been consumed. */ /** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>; clearPendingShare(): Promise<void>;
/** Save a base64 file to gallery / Downloads (MediaStore), or share-sheet fallback. */
saveFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
/** Open the system share sheet for a base64 file. */
shareFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ shared: boolean }>;
addListener( addListener(
eventName: 'shareReceived', eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void listenerFunc: (payload: AndroidSharePayload) => void

View File

@@ -10,6 +10,10 @@ type UnifiedPushStatus = {
distributors: string[] | string; distributors: string[] | string;
}; };
type ClearRoomNotificationsResult = {
cleared: boolean;
};
type UnifiedPushEndpointEvent = { type UnifiedPushEndpointEvent = {
endpoint: string; endpoint: string;
previousEndpoint: string; previousEndpoint: string;
@@ -48,6 +52,39 @@ interface MatrixBackgroundSyncPlugin {
setAppForeground(options: { foreground: boolean }): Promise<void>; setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns fetch state and current UnifiedPush registration details. */ /** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>; getStatus(): Promise<UnifiedPushStatus>;
/** Cancel native tray notifications posted for a Matrix room. */
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
/**
* Persist room → space/group metadata so background notifications can nest
* under Direct messages / Space name / Home.
*/
setNotificationGroups(options: {
rooms: Record<
string,
{ groupId: string; groupName: string; roomName: string; kind: string }
>;
}): Promise<{ success: boolean }>;
/** Post a message notification with optional avatar (base64) from the JS layer. */
showNotification(options: {
title?: string;
body?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string;
roomId: string;
groupId: string;
groupName: string;
kind: string;
largeIconBase64?: string;
bigPictureBase64?: string;
}): Promise<{ shown: boolean }>;
/** Pending navigation target from a native notification tap. */
getPendingNotificationNav(): Promise<{ path?: string | null; roomId?: string | null }>;
addListener(
eventName: 'notificationOpened',
listenerFunc: (event: { path?: string; roomId?: string }) => void
): Promise<PluginListenerHandle>;
addListener( addListener(
eventName: 'unifiedPushNewEndpoint', eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void listenerFunc: (event: UnifiedPushEndpointEvent) => void
@@ -516,3 +553,91 @@ export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | und
return undefined; return undefined;
} }
}; };
/**
* Clears native (MatrixSyncService) tray notifications for a room after it is read.
* No-op when background sync / native Android is unavailable.
*/
export const clearNativeRoomNotifications = async (roomId: string): Promise<void> => {
if (!isBackgroundSyncSupported() || !roomId) return;
try {
await MatrixBackgroundSync.clearRoomNotifications({ roomId });
} catch (err) {
console.warn('[BackgroundSync] clearRoomNotifications failed:', err);
}
};
/**
* Syncs space/DM grouping metadata to the native layer for background notifications.
*/
export const syncNotificationGroupMap = async (
rooms: Record<
string,
{ groupId: string; groupName: string; roomName: string; kind: string }
>
): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.setNotificationGroups({ rooms });
} catch (err) {
console.warn('[BackgroundSync] setNotificationGroups failed:', err);
}
};
/**
* Posts a native tray notification (supports dynamic/authenticated avatar icons).
* Prefer this over Capacitor LocalNotifications on Android.
*/
export const showNativeNotification = async (options: {
title?: string;
body?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string;
roomId: string;
groupId: string;
groupName: string;
kind: string;
largeIconBase64?: string;
bigPictureBase64?: string;
}): Promise<boolean> => {
if (!isBackgroundSyncSupported()) return false;
try {
await MatrixBackgroundSync.showNotification(options);
return true;
} catch (err) {
console.warn('[BackgroundSync] showNotification failed:', err);
return false;
}
};
/** Consume a pending native notification navigation target, if any. */
export const getPendingNotificationNav = async (): Promise<{
path?: string | null;
roomId?: string | null;
}> => {
if (!isBackgroundSyncSupported()) return {};
try {
return await MatrixBackgroundSync.getPendingNotificationNav();
} catch (err) {
console.warn('[BackgroundSync] getPendingNotificationNav failed:', err);
return {};
}
};
/** Subscribe to native notification taps that open the app. */
export const listenForNotificationOpens = async (
listener: (event: { path?: string; roomId?: string }) => void
): Promise<PluginListenerHandle | undefined> => {
if (!isBackgroundSyncSupported()) return undefined;
try {
return await MatrixBackgroundSync.addListener('notificationOpened', listener);
} catch (err) {
console.warn('[BackgroundSync] notificationOpened listener failed:', err);
return undefined;
}
};

View File

@@ -0,0 +1,103 @@
import FileSaver from 'file-saver';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { downloadMedia } from './matrix';
import { getCurrentAccessToken } from './auth';
interface AndroidShareHandlerSavePlugin {
saveFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
shareFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ shared: boolean }>;
}
const AndroidShareHandler = registerPlugin<AndroidShareHandlerSavePlugin>('AndroidShareHandler');
const isAndroidNative = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Convert a Blob to raw base64 (no data: prefix). */
const blobToBase64 = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Failed to read blob as base64'));
return;
}
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
reader.readAsDataURL(blob);
});
const guessMimeFromName = (filename: string): string | undefined => {
const ext = filename.split('.').pop()?.toLowerCase();
if (!ext) return undefined;
const map: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
webm: 'video/webm',
mkv: 'video/x-matroska',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
pdf: 'application/pdf',
txt: 'text/plain',
};
return map[ext];
};
/**
* Persist a Blob to disk. On Capacitor Android, uses MediaStore via the native
* ShareHandler plugin (FileSaver / `<a download>` does not work in WebView).
* Elsewhere falls back to FileSaver.
*/
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
if (isAndroidNative()) {
const base64 = await blobToBase64(blob);
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
return;
}
FileSaver.saveAs(blob, filename);
};
/**
* Fetch media (auth download or blob:/http URL) and save it via [saveMediaBlob].
*/
export const downloadAndSaveMedia = async (
src: string,
filename: string,
accessToken?: string | null
): Promise<void> => {
let blob: Blob;
try {
if (src.startsWith('blob:') || src.startsWith('data:')) {
const res = await fetch(src);
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
blob = await res.blob();
} else {
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
}
} catch (error) {
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
const res = await fetch(src);
if (!res.ok) throw error;
blob = await res.blob();
}
await saveMediaBlob(blob, filename);
};

View File

@@ -207,6 +207,36 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
} catch (err) { } catch (err) {
console.warn('Failed to set up Capacitor notification tap listener:', err); console.warn('Failed to set up Capacitor notification tap listener:', err);
} }
// Native NotificationManager posts (MatrixBackgroundSync.showNotification) open the
// Activity with extras — LocalNotifications never sees those taps.
try {
const {
getPendingNotificationNav,
listenForNotificationOpens,
} = await import('./backgroundSync');
const deliver = (path?: string | null, roomId?: string | null) => {
if (path && notificationTapCallback) {
notificationTapCallback(path);
return;
}
if (roomId && notificationTapCallback) {
// Encode a synthetic marker path that ClientNonUIFeatures can resolve.
notificationTapCallback(`__room__:${roomId}`);
}
};
const pending = await getPendingNotificationNav();
deliver(pending.path, pending.roomId);
await listenForNotificationOpens((event) => {
void focusWindow();
deliver(event.path, event.roomId);
});
} catch (err) {
console.warn('Failed to set up native notification open listener:', err);
}
} }
}; };
@@ -351,24 +381,7 @@ const ensureNotificationChannel = async (): Promise<void> => {
}; };
const ensureCapacitorNotificationChannel = async (): Promise<void> => { const ensureCapacitorNotificationChannel = async (): Promise<void> => {
if (notificationChannelCreated || !isAndroid()) return; await ensureCapacitorNotificationChannels();
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
await LocalNotifications.createChannel({
id: 'messages',
name: 'Messages',
description: 'Message notifications from Matrix',
importance: 5,
sound: 'default',
visibility: 1,
vibration: true,
lights: true,
});
notificationChannelCreated = true;
} catch (err) {
console.warn('Failed to create Capacitor notification channel:', err);
}
}; };
/** /**
@@ -422,6 +435,225 @@ export const getSystemNotificationPermissionState = async (): Promise<Permission
return 'denied'; return 'denied';
}; };
/** Foreground sync status notification — must not collide with room message IDs. */
const ANDROID_STATUS_NOTIFICATION_ID = 1001;
/** Android notification group keys for rooms outside a space. */
export const NOTIF_GROUP_DIRECTS = 'paarrot_directs';
export const NOTIF_GROUP_HOME = 'paarrot_home';
/** Capacitor / native channel ids (under the Paarrot app). */
export const NOTIF_CHANNEL_DIRECTS = 'messages_directs';
export const NOTIF_CHANNEL_SPACES = 'messages_spaces';
export const NOTIF_CHANNEL_HOME = 'messages_home';
/** Legacy channel — kept so older installs still receive. */
export const NOTIF_CHANNEL_MESSAGES_LEGACY = 'messages';
export type NotificationGroupKind = 'direct' | 'space' | 'home';
export type NotificationGroupInfo = {
groupId: string;
groupName: string;
kind: NotificationGroupKind;
roomName?: string;
};
/**
* Stable Android notification id for a Matrix room.
* Re-using the same id replaces the previous tray entry instead of stacking.
*/
export const notificationIdForRoom = (roomId: string): number => {
let hash = 0;
for (let i = 0; i < roomId.length; i += 1) {
hash = (hash << 5) - hash + roomId.charCodeAt(i);
hash |= 0;
}
let id = Math.abs(hash) % 2147483647;
if (id === 0 || id === ANDROID_STATUS_NOTIFICATION_ID) {
id = ANDROID_STATUS_NOTIFICATION_ID + 1;
}
return id;
};
/** Stable id for a space/group summary notification. */
export const notificationIdForGroupSummary = (groupId: string): number =>
notificationIdForRoom(`summary:${groupId}`);
const channelIdForKind = (kind: NotificationGroupKind): string => {
switch (kind) {
case 'direct':
return NOTIF_CHANNEL_DIRECTS;
case 'space':
return NOTIF_CHANNEL_SPACES;
default:
return NOTIF_CHANNEL_HOME;
}
};
const ensureCapacitorNotificationChannels = async (): Promise<void> => {
if (!isAndroid()) return;
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const channels: Array<{
id: string;
name: string;
description: string;
}> = [
{
id: NOTIF_CHANNEL_DIRECTS,
name: 'Direct messages',
description: 'One-to-one Matrix conversations',
},
{
id: NOTIF_CHANNEL_SPACES,
name: 'Spaces',
description: 'Messages from rooms inside Spaces',
},
{
id: NOTIF_CHANNEL_HOME,
name: 'Other rooms',
description: 'Rooms that are not in a Space',
},
{
id: NOTIF_CHANNEL_MESSAGES_LEGACY,
name: 'Messages',
description: 'Legacy message channel',
},
];
await Promise.all(
channels.map((channel) =>
LocalNotifications.createChannel({
id: channel.id,
name: channel.name,
description: channel.description,
importance: 5,
sound: 'default',
visibility: 1,
vibration: true,
lights: true,
})
)
);
notificationChannelCreated = true;
} catch (err) {
console.warn('Failed to create Capacitor notification channels:', err);
}
};
const notificationBelongsToRoom = (
notification: { id?: number; extra?: Record<string, unknown> },
roomId: string
): boolean => {
if (notification.id === notificationIdForRoom(roomId)) return true;
const extra = notification.extra;
if (!extra || typeof extra !== 'object') return false;
if (extra.roomId === roomId) return true;
const path = extra.path;
if (typeof path === 'string') {
return path.includes(encodeURIComponent(roomId)) || path.includes(roomId);
}
return false;
};
const clearCapacitorGroupSummaryIfEmpty = async (
LocalNotifications: {
getDeliveredNotifications: () => Promise<{ notifications: Array<{ id?: number; extra?: Record<string, unknown>; group?: string }> }>;
cancel: (opts: { notifications: Array<{ id: number }> }) => Promise<void>;
removeDeliveredNotifications: (opts: { notifications: unknown[] }) => Promise<void>;
},
groupId: string
): Promise<void> => {
const summaryId = notificationIdForGroupSummary(groupId);
const delivered = await LocalNotifications.getDeliveredNotifications().catch(() => null);
if (!delivered?.notifications) return;
const siblings = delivered.notifications.filter((n) => {
if (n.id === summaryId) return false;
if (n.group === groupId) return true;
return n.extra?.groupId === groupId;
});
if (siblings.length > 0) return;
await LocalNotifications.cancel({ notifications: [{ id: summaryId }] }).catch(() => undefined);
const summary = delivered.notifications.find((n) => n.id === summaryId);
if (summary) {
await LocalNotifications.removeDeliveredNotifications({
notifications: [summary],
}).catch(() => undefined);
}
};
/**
* Dismiss system notifications tied to a room (after mark-as-read / receipt).
* Clears Capacitor LocalNotifications and native MatrixSyncService tray entries.
* Also drops the space/group summary when no child notifications remain.
*/
export const clearNotificationsForRoom = async (roomId: string): Promise<void> => {
if (!roomId) return;
if (isCapacitorNative()) {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const id = notificationIdForRoom(roomId);
const deliveredBefore = await LocalNotifications.getDeliveredNotifications().catch(() => null);
const roomNotif = deliveredBefore?.notifications?.find((n: { id?: number; extra?: Record<string, unknown> }) =>
notificationBelongsToRoom(n, roomId)
);
const groupId =
(typeof roomNotif?.extra?.groupId === 'string' && roomNotif.extra.groupId) ||
(typeof (roomNotif as { group?: string } | undefined)?.group === 'string'
? (roomNotif as { group?: string }).group
: undefined);
await LocalNotifications.cancel({ notifications: [{ id }] }).catch(() => undefined);
if (deliveredBefore?.notifications?.length) {
const matching = deliveredBefore.notifications.filter((n: { id?: number; extra?: Record<string, unknown> }) =>
notificationBelongsToRoom(n, roomId)
);
if (matching.length > 0) {
await LocalNotifications.removeDeliveredNotifications({
notifications: matching,
}).catch(() => undefined);
}
}
if (groupId) {
await clearCapacitorGroupSummaryIfEmpty(LocalNotifications, groupId);
}
} catch (err) {
console.warn('Failed to clear Capacitor notifications for room:', err);
}
try {
const { clearNativeRoomNotifications } = await import('./backgroundSync');
await clearNativeRoomNotifications(roomId);
} catch (err) {
console.warn('Failed to clear native room notifications:', err);
}
return;
}
if (isTauri() && !isElectron()) {
try {
const { cancel, removeActive } = await import('@tauri-apps/plugin-notification');
const id = notificationIdForRoom(roomId);
if (typeof cancel === 'function') {
await cancel([id]).catch(() => undefined);
}
if (typeof removeActive === 'function') {
await removeActive([{ id }]).catch(() => undefined);
}
} catch (err) {
console.warn('Failed to clear Tauri notifications for room:', err);
}
}
};
/** /**
* Send a notification using Tauri's notification plugin * Send a notification using Tauri's notification plugin
* Falls back to browser Notification API if not in Tauri * Falls back to browser Notification API if not in Tauri
@@ -430,10 +662,39 @@ export const sendNotification = async (options: {
title: string; title: string;
body: string; body: string;
icon?: string; icon?: string;
iconBase64?: string;
bigPictureBase64?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string; path?: string;
roomId?: string;
group?: NotificationGroupInfo;
onClick?: () => void; onClick?: () => void;
}): Promise<void> => { }): Promise<void> => {
const { title, body, icon, path, onClick } = options; const {
title,
body,
icon,
iconBase64,
bigPictureBase64,
senderName,
messageText,
conversationTitle,
path,
roomId,
group,
onClick,
} = options;
const extra = {
...(path ? { path } : {}),
...(roomId ? { roomId } : {}),
...(group?.groupId ? { groupId: group.groupId, groupName: group.groupName, groupKind: group.kind } : {}),
};
const hasExtra = Object.keys(extra).length > 0;
const channelId = group ? channelIdForKind(group.kind) : NOTIF_CHANNEL_HOME;
const groupId = group?.groupId;
const groupName = group?.groupName;
// Use Electron's native notification API if in Electron // Use Electron's native notification API if in Electron
if (isElectron()) { if (isElectron()) {
@@ -475,11 +736,27 @@ export const sendNotification = async (options: {
await tauriSendNotification({ await tauriSendNotification({
title, title,
body, body,
// Stable per-room id replaces prior tray entry instead of stacking
id: roomId ? notificationIdForRoom(roomId) : undefined,
// Use the channel on Android // Use the channel on Android
channelId: isAndroid() ? 'messages' : undefined, channelId: isAndroid() ? channelId : undefined,
// Store path in extra data for notification tap handling group: groupId,
extra: path ? { path } : undefined, icon,
// Store path/roomId in extra data for tap handling + clear-on-read
extra: hasExtra ? extra : undefined,
}); });
if (isAndroid() && groupId && groupName) {
await tauriSendNotification({
title: groupName,
body: 'New messages',
id: notificationIdForGroupSummary(groupId),
channelId,
group: groupId,
groupSummary: true,
extra: { groupId, groupName, isGroupSummary: true },
}).catch(() => undefined);
}
} }
return; return;
} catch (err) { } catch (err) {
@@ -488,6 +765,31 @@ export const sendNotification = async (options: {
} }
if (isCapacitorNative()) { if (isCapacitorNative()) {
// Prefer native NotificationManager so we can set dynamic/authenticated avatars.
// Capacitor LocalNotifications only supports drawable resource largeIcon names.
if (roomId && groupId && groupName && group) {
try {
const { showNativeNotification } = await import('./backgroundSync');
const shown = await showNativeNotification({
title,
body,
senderName: senderName ?? title,
messageText: messageText ?? body,
conversationTitle,
path,
roomId,
groupId,
groupName,
kind: group.kind,
largeIconBase64: iconBase64,
bigPictureBase64,
});
if (shown) return;
} catch (err) {
console.warn('Native showNotification failed, falling back to LocalNotifications:', err);
}
}
try { try {
const { LocalNotifications } = await import('@capacitor/local-notifications'); const { LocalNotifications } = await import('@capacitor/local-notifications');
let perm = await LocalNotifications.checkPermissions(); let perm = await LocalNotifications.checkPermissions();
@@ -496,21 +798,53 @@ export const sendNotification = async (options: {
} }
if (perm.display === 'granted') { if (perm.display === 'granted') {
await ensureCapacitorNotificationChannel(); await ensureCapacitorNotificationChannels();
// One notification per room: same id updates the existing tray entry.
const id = roomId
? notificationIdForRoom(roomId)
: Math.floor(Date.now() % 2147483647);
const roomNotification: Record<string, unknown> = {
id,
title,
body,
channelId: isAndroid() ? channelId : undefined,
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
extra: hasExtra ? extra : undefined,
threadIdentifier: groupId,
};
if (isAndroid() && groupId) {
roomNotification.group = groupId;
if (groupName) {
roomNotification.summaryText = groupName;
}
if (group?.roomName && group.roomName !== title) {
roomNotification.largeBody = body;
}
}
const notifications: Array<Record<string, unknown>> = [roomNotification];
// Nest rooms under a space / Directs / Home summary in the shade.
if (isAndroid() && groupId && groupName) {
notifications.push({
id: notificationIdForGroupSummary(groupId),
title: groupName,
body: 'New messages',
channelId,
smallIcon: ANDROID_NOTIFICATION_SMALL_ICON,
iconColor: ANDROID_NOTIFICATION_ICON_COLOR,
group: groupId,
groupSummary: true,
extra: { groupId, groupName, isGroupSummary: true },
});
}
const id = Math.floor(Date.now() % 2147483647);
await LocalNotifications.schedule({ await LocalNotifications.schedule({
notifications: [ notifications: notifications as any,
{
id,
title,
body,
channelId: isAndroid() ? 'messages' : undefined,
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
extra: path ? { path } : undefined,
},
],
}); });
} }
return; return;

View File

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