Compare commits

...

4 Commits

Author SHA1 Message Date
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
8 changed files with 521 additions and 59 deletions

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

@@ -14,7 +14,14 @@ 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.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
@@ -69,7 +76,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()
@@ -85,7 +92,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
@@ -102,8 +114,17 @@ 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
// "foreground" flag is stale; JS path is suppressed while backgrounded.
val fromPush = triggerReason.startsWith("unifiedpush")
val suppressBecauseUi = appInForeground && !fromPush
if (!isFirstSync && !suppressBecauseUi) {
processRoomEvents(json, userId) processRoomEvents(json, userId)
} else {
Log.d(
TAG,
"Skipping notifications (firstSync=$isFirstSync, foreground=$appInForeground, reason=$triggerReason)",
)
} }
} }
} }
@@ -127,7 +148,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"
@@ -185,41 +207,71 @@ class MatrixSyncService : Service() {
// Homeserver unread counts already apply push rules — skip rooms with nothing to notify. // Homeserver unread counts already apply push rules — skip rooms with nothing to notify.
val unread = roomData.optJSONObject("unread_notifications") val unread = roomData.optJSONObject("unread_notifications")
val notificationCount = unread?.optInt("notification_count", 0) ?: 0 val notificationCount = unread?.optInt("notification_count", 0) ?: 0
val highlightCount = unread?.optInt("highlight_count", 0) ?: 0
if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue
val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS 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 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()
if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
val msgtype = content.optString("msgtype") // Plaintext mention/keyword filter; encrypted events already passed highlight_count.
val rawBody = content.optString("body") if (
val body = when (msgtype) { mentionsOnly &&
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo" eventType != "m.room.encrypted" &&
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video" !isSpecialMessage(content, notifyCtx)
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio" ) {
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File" continue
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
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://") }?.let { mxc -> content.optString("url").takeIf { it.startsWith("mxc://") }?.let { mxc ->
mxcToDownloadUrls(mxc, homeserver) mxcToDownloadUrls(mxc, homeserver)
@@ -236,6 +288,21 @@ class MatrixSyncService : Service() {
inlineImage, inlineImage,
resolveGroupInfo(roomId, notifyCtx), 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,
)
} }
} }
} }
@@ -540,18 +607,18 @@ class MatrixSyncService : Service() {
groupInfo: NotificationGroupInfo, groupInfo: NotificationGroupInfo,
) { ) {
val isDm = groupInfo.kind == "direct" val isDm = groupInfo.kind == "direct"
val title = if (isDm) sender else groupInfo.roomName.ifBlank { sender }
val text = if (isDm) body else "$sender: $body"
postMessageNotification( postMessageNotification(
this, this,
roomId = roomId, roomId = roomId,
title = title, senderName = sender,
body = text, messageText = body,
conversationTitle = if (isDm) null else groupInfo.roomName.ifBlank { null },
groupId = groupInfo.groupId, groupId = groupInfo.groupId,
groupName = groupInfo.groupName, groupName = groupInfo.groupName,
kind = groupInfo.kind, kind = groupInfo.kind,
largeIcon = largeIcon, largeIcon = largeIcon,
inlineImage = inlineImage, inlineImage = inlineImage,
path = null,
) )
} }
@@ -675,37 +742,107 @@ class MatrixSyncService : Service() {
} }
} }
/** 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")
/** /**
* Posts a room notification (+ space/DM group summary) for both background sync * Posts a room notification (+ space/DM group summary) for both background sync
* and JS-driven Capacitor notifications (with optional avatar bitmap). * and JS-driven Capacitor notifications.
*
* Avatar goes in the circular Person / largeIcon slot on the left.
* Optional image attachments use BigPictureStyle while keeping that circular avatar.
*/ */
fun postMessageNotification( fun postMessageNotification(
context: Context, context: Context,
roomId: String, roomId: String,
title: String, senderName: String,
body: String, messageText: String,
conversationTitle: String?,
groupId: String, groupId: String,
groupName: String, groupName: String,
kind: String, kind: String,
largeIcon: Bitmap? = null, largeIcon: Bitmap? = null,
inlineImage: 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 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannels(context) ensureMessageChannels(context)
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) val resolvedSender = senderName.ifBlank { title ?: "Someone" }
?: Intent(context, MainActivity::class.java) 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 roomNotifId = notificationIdForRoom(roomId)
val pi = PendingIntent.getActivity( val pi = PendingIntent.getActivity(
context, roomNotifId, launchIntent, context, roomNotifId, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val avatar = largeIcon?.let { toCircularBitmap(it) }
val personBuilder = Person.Builder()
.setName(resolvedSender)
.setKey(resolvedSender)
if (avatar != null) {
personBuilder.setIcon(IconCompat.createWithBitmap(avatar))
}
val senderPerson = personBuilder.build()
val channelId = channelIdForKindStatic(kind) val channelId = channelIdForKindStatic(kind)
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
resolvedSender
} else {
resolvedConversation
}
val collapsedText = if (isDm || resolvedConversation.isNullOrBlank()) {
resolvedMessage
} else {
"$resolvedSender: $resolvedMessage"
}
val builder = NotificationCompat.Builder(context, channelId) val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot) .setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(title) .setContentTitle(collapsedTitle)
.setContentText(body) .setContentText(collapsedText)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pi) .setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -713,24 +850,42 @@ class MatrixSyncService : Service() {
.setGroup(groupId) .setGroup(groupId)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setSubText(groupName) .setSubText(groupName)
.setShortcutId(roomId)
// 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_ROOM_ID, roomId)
builder.extras.putString(EXTRA_GROUP_ID, groupId) builder.extras.putString(EXTRA_GROUP_ID, groupId)
if (!path.isNullOrBlank()) {
builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path)
}
if (largeIcon != null) builder.setLargeIcon(largeIcon) // Circular avatar on the left (collapsed + MessagingStyle Person).
if (avatar != null) builder.setLargeIcon(avatar)
if (inlineImage != null) { if (inlineImage != null) {
// Expanded image attachment — keep avatar in the circular large-icon slot.
builder.setStyle( builder.setStyle(
NotificationCompat.BigPictureStyle() NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage) .bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?) .bigLargeIcon(avatar)
.setBigContentTitle(collapsedTitle)
.setSummaryText(collapsedText)
) )
} else { } else {
builder.setStyle( val messaging = NotificationCompat.MessagingStyle(senderPerson)
NotificationCompat.BigTextStyle() .addMessage(
.bigText(body) NotificationCompat.MessagingStyle.Message(
.setSummaryText(groupName) resolvedMessage,
) System.currentTimeMillis(),
senderPerson,
)
)
if (!resolvedConversation.isNullOrBlank() && !isDm) {
messaging.conversationTitle = resolvedConversation
messaging.isGroupConversation = true
}
builder.setStyle(messaging)
} }
nm.notify(roomNotifId, builder.build()) nm.notify(roomNotifId, builder.build())
@@ -746,6 +901,7 @@ class MatrixSyncService : Service() {
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupId) .setGroup(groupId)
.setGroupSummary(true) .setGroupSummary(true)
.setAllowSystemGeneratedContextualActions(false)
.setStyle( .setStyle(
NotificationCompat.InboxStyle() NotificationCompat.InboxStyle()
.setBigContentTitle(groupName) .setBigContentTitle(groupName)
@@ -792,7 +948,8 @@ class MatrixSyncService : Service() {
} }
} }
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */ /** 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

@@ -31,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) {
@@ -132,23 +163,33 @@ class SyncServicePlugin : Plugin() {
*/ */
@PluginMethod @PluginMethod
fun showNotification(call: PluginCall) { fun showNotification(call: PluginCall) {
val title = call.getString("title") ?: return call.reject("title required")
val body = call.getString("body") ?: return call.reject("body required")
val roomId = call.getString("roomId") ?: return call.reject("roomId required") val roomId = call.getString("roomId") ?: return call.reject("roomId required")
val groupId = call.getString("groupId") ?: "paarrot_home" val groupId = call.getString("groupId") ?: "paarrot_home"
val groupName = call.getString("groupName") ?: "Home" val groupName = call.getString("groupName") ?: "Home"
val kind = call.getString("kind") ?: "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 largeIconBase64 = call.getString("largeIconBase64")
val bigPictureBase64 = call.getString("bigPictureBase64")
MatrixSyncService.postMessageNotification( MatrixSyncService.postMessageNotification(
context = context, context = context,
roomId = roomId, roomId = roomId,
title = title, senderName = senderName,
body = body, messageText = messageText,
conversationTitle = conversationTitle,
groupId = groupId, groupId = groupId,
groupName = groupName, groupName = groupName,
kind = kind, kind = kind,
largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64), largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64),
inlineImage = MatrixSyncService.decodeBase64Bitmap(bigPictureBase64),
path = path,
) )
call.resolve(JSObject().put("shown", true)) call.resolve(JSObject().put("shown", true))
} }

View File

@@ -29,6 +29,8 @@ import {
getCanonicalAliasOrRoomId, getCanonicalAliasOrRoomId,
encryptFile, encryptFile,
downloadMedia, downloadMedia,
downloadEncryptedMedia,
decryptFile,
} from '../../utils/matrix'; } 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';
@@ -140,6 +142,15 @@ async function mediaUrlToBase64(
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
const blob = await downloadMedia(url, accessToken); 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 buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let binary = ''; let binary = '';
@@ -149,12 +160,73 @@ async function mediaUrlToBase64(
} }
const mime = blob.type || 'image/jpeg'; const mime = blob.type || 'image/jpeg';
return `data:${mime};base64,${btoa(binary)}`; return `data:${mime};base64,${btoa(binary)}`;
} catch (err) { } catch {
console.warn('[Notifications] Failed to fetch avatar for notification icon:', err);
return undefined; 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
@@ -319,10 +391,37 @@ 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;
});
}, [navigate]); 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 roomToUnread = useAtomValue(roomToUnreadAtom);
const previousUnreadRoomsRef = useRef<Set<string>>(new Set()); const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
@@ -343,6 +442,7 @@ function MessageNotifications() {
roomName, roomName,
roomAvatar, roomAvatar,
iconBase64, iconBase64,
bigPictureBase64,
username, username,
messageBody, messageBody,
roomId, roomId,
@@ -352,6 +452,7 @@ function MessageNotifications() {
roomName: string; roomName: string;
roomAvatar?: string; roomAvatar?: string;
iconBase64?: string; iconBase64?: string;
bigPictureBase64?: string;
username: string; username: string;
messageBody?: string; messageBody?: string;
roomId: string; roomId: string;
@@ -394,6 +495,8 @@ function MessageNotifications() {
: messageBody : messageBody
? `${username}: ${messageBody}` ? `${username}: ${messageBody}`
: `${username} sent a message`; : `${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 = () => {
@@ -438,11 +541,15 @@ function MessageNotifications() {
sendNotification({ sendNotification({
title: notificationTitle, title: notificationTitle,
body: notificationBody, body: notificationBody,
senderName: username,
messageText,
conversationTitle,
path: roomPath, path: roomPath,
roomId, roomId,
group, group,
icon: roomAvatar, icon: roomAvatar,
iconBase64, iconBase64,
bigPictureBase64,
onClick: () => { onClick: () => {
if (!window.closed) navigate(roomPath); if (!window.closed) navigate(roomPath);
}, },
@@ -551,10 +658,16 @@ function MessageNotifications() {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
let iconBase64: string | undefined; const username = resolveSenderDisplayName(mx, room, sender);
if (roomAvatar && isCapacitorNative()) {
iconBase64 = await mediaUrlToBase64(roomAvatar, mx.getAccessToken()); const [iconBase64, bigPictureBase64] = await Promise.all([
} roomAvatar && isCapacitorNative()
? mediaUrlToBase64(roomAvatar, mx.getAccessToken())
: Promise.resolve(undefined),
isCapacitorNative()
? fetchNotificationImageBase64(mx, mEvent, useAuthentication)
: Promise.resolve(undefined),
]);
const messageBody = notificationBodyFromEvent(mEvent); const messageBody = notificationBodyFromEvent(mEvent);
@@ -562,7 +675,8 @@ function MessageNotifications() {
roomName: room.name ?? 'Unknown', roomName: room.name ?? 'Unknown',
roomAvatar, roomAvatar,
iconBase64, iconBase64,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, bigPictureBase64,
username,
messageBody, messageBody,
roomId: room.roomId, roomId: room.roomId,
eventId, eventId,

View File

@@ -66,14 +66,25 @@ interface MatrixBackgroundSyncPlugin {
}): Promise<{ success: boolean }>; }): Promise<{ success: boolean }>;
/** Post a message notification with optional avatar (base64) from the JS layer. */ /** Post a message notification with optional avatar (base64) from the JS layer. */
showNotification(options: { showNotification(options: {
title: string; title?: string;
body: string; body?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string;
roomId: string; roomId: string;
groupId: string; groupId: string;
groupName: string; groupName: string;
kind: string; kind: string;
largeIconBase64?: string; largeIconBase64?: string;
bigPictureBase64?: string;
}): Promise<{ shown: boolean }>; }): 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
@@ -580,13 +591,18 @@ export const syncNotificationGroupMap = async (
* Prefer this over Capacitor LocalNotifications on Android. * Prefer this over Capacitor LocalNotifications on Android.
*/ */
export const showNativeNotification = async (options: { export const showNativeNotification = async (options: {
title: string; title?: string;
body: string; body?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string;
roomId: string; roomId: string;
groupId: string; groupId: string;
groupName: string; groupName: string;
kind: string; kind: string;
largeIconBase64?: string; largeIconBase64?: string;
bigPictureBase64?: string;
}): Promise<boolean> => { }): Promise<boolean> => {
if (!isBackgroundSyncSupported()) return false; if (!isBackgroundSyncSupported()) return false;
@@ -598,3 +614,30 @@ export const showNativeNotification = async (options: {
return false; 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

@@ -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);
}
} }
}; };
@@ -633,12 +663,29 @@ export const sendNotification = async (options: {
body: string; body: string;
icon?: string; icon?: string;
iconBase64?: string; iconBase64?: string;
bigPictureBase64?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string; path?: string;
roomId?: string; roomId?: string;
group?: NotificationGroupInfo; group?: NotificationGroupInfo;
onClick?: () => void; onClick?: () => void;
}): Promise<void> => { }): Promise<void> => {
const { title, body, icon, iconBase64, path, roomId, group, onClick } = options; const {
title,
body,
icon,
iconBase64,
bigPictureBase64,
senderName,
messageText,
conversationTitle,
path,
roomId,
group,
onClick,
} = options;
const extra = { const extra = {
...(path ? { path } : {}), ...(path ? { path } : {}),
...(roomId ? { roomId } : {}), ...(roomId ? { roomId } : {}),
@@ -726,11 +773,16 @@ export const sendNotification = async (options: {
const shown = await showNativeNotification({ const shown = await showNativeNotification({
title, title,
body, body,
senderName: senderName ?? title,
messageText: messageText ?? body,
conversationTitle,
path,
roomId, roomId,
groupId, groupId,
groupName, groupName,
kind: group.kind, kind: group.kind,
largeIconBase64: iconBase64, largeIconBase64: iconBase64,
bigPictureBase64,
}); });
if (shown) return; if (shown) return;
} catch (err) { } catch (err) {

View File

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